diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d246a1b..d2cd3c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,3 +99,8 @@ jobs: # without failing the job, so main stays green until the next change. continue-on-error: ${{ github.event_name == 'schedule' }} run: npm audit --audit-level=low + - name: Report baseline freshness + # Offline and deterministic: reads graph.json and the date, fetches nothing, and writes a + # table of baselines that may have a newer release to the job summary. It never fails. + if: github.event_name == 'schedule' + run: npm run check:freshness diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5777a3..83433b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,17 +62,18 @@ link. A node is an indicator or a lever. Its fields are defined in `src/lib/schema.ts` and enforced by `src/lib/validate.ts`. -| Field | Rule | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Lowercase words joined by single underscores, unique. | -| `category` | One of `economic`, `fiscal`, `social`, `institutional`, `policy`. | -| `valueType` | `observed` is a measured series. `projected` is a forecast, never shown as an observation. `index` is an abstract 0 to 100 lever. | -| `baseline` | A number, or `null`. An `index` node must be `null`. | -| `range` | An editorial display scale that comfortably holds the baseline. It is not data. Explain your choice in the pull request. | -| `asOf` | The period the baseline describes: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-Qn` or `FYYYYY`. Required when there is a baseline. | -| `verification` | `primary`, `secondary` or `pending` for observed and projected nodes. `null` for an `index` node. | -| `sourceUrl`, `sourceDetail`, `retrievedDate` | Required for `primary` and `secondary`. For `pending`, `sourceUrl` and `retrievedDate` must be `null`. | -| `terminal` | Why nothing is modeled downstream, required exactly when no edge leaves the node. Otherwise `null`. | +| Field | Rule | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Lowercase words joined by single underscores, unique. | +| `category` | One of `economic`, `fiscal`, `social`, `institutional`, `policy`. | +| `valueType` | `observed` is a measured series. `projected` is a forecast, never shown as an observation. `index` is an abstract 0 to 100 lever. | +| `baseline` | A number, or `null`. An `index` node must be `null`. | +| `range` | An editorial display scale that comfortably holds the baseline. It is not data. Explain your choice in the pull request. | +| `asOf` | The period the baseline describes: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-Qn` or `FYYYYY`. Required when there is a baseline. | +| `verification` | `primary`, `secondary` or `pending` for observed and projected nodes. `null` for an `index` node. | +| `sourceUrl`, `sourceDetail`, `retrievedDate` | Required for `primary` and `secondary`. For `pending`, `sourceUrl` and `retrievedDate` must be `null`. | +| `cadence` | How often the publisher releases a new figure: `daily`, `weekly`, `monthly`, `quarterly`, `annual` or `irregular`. `null` for `index` nodes. | +| `terminal` | Why nothing is modeled downstream, required exactly when no edge leaves the node. Otherwise `null`. | How to verify a starting value: @@ -89,6 +90,18 @@ How to verify a starting value: - Do not store a derived number, such as a growth rate you computed from two readings, as though it had been fetched. `debt_growth_rate` is empty for that reason. +## Refreshing a baseline + +`npm run check:freshness` lists the baselines that may have a newer release, judged from each +node's `cadence` and the end of its period. It reads no network and does not fail. The weekly CI run +writes the same table to its job summary. A flag is a prompt to read the source, not a finding that +the value is wrong, and a date that looks current does not catch a revision to the same period. + +To refresh a node, open the publisher's page, read the new figure, and update `baseline`, `asOf`, +`sourceUrl`, `sourceDetail` and `retrievedDate` together in one change, with the release date and +any revision noted in `sourceDetail`. Update the matching row of the table in README.md. Never +change the value alone, and never take it from a search result or summary. + ## Adding an edge - `id` is `from__to`, using the two node ids, and both nodes must exist. No self loops, and no two diff --git a/README.md b/README.md index e5db96d..27b9530 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,10 @@ and not an observation. | Trust in media | 28% | Sep 2025 | [Gallup, Trust in Media](https://news.gallup.com/poll/695762/trust-media-new-low.aspx) | | Reported hate crimes | 10,606 reported incidents | 2025 | [FBI, 2025 Reported Crimes in the Nation](https://www.fbi.gov/news/press-releases/fbi-releases-2025-reported-crimes-in-the-nation-statistics) | +Each node records how often its figure is published. `npm run check:freshness`, which also runs +weekly in CI, lists the baselines that may have a newer release. It is a reminder to read the +source, not a check of any value. [CONTRIBUTING.md](CONTRIBUTING.md) describes how to refresh one. + Two nodes have no baseline. `worker_bargaining_power` is an abstract 0-to-100 lever. `debt_growth_rate` is a rate that would be derived from two Treasury readings a year apart, and a derived number is computed where it is shown and not stored as though it had been fetched. diff --git a/TESTING.md b/TESTING.md index 600a5dc..28cd94d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -88,6 +88,10 @@ whether those assumptions are true of the economy. Automated rules catch only part of WCAG, so the axe-core scan is not an accessibility certification. +`npm run check:freshness` reports baselines that may have a newer release. It is offline and +deterministic, runs on the weekly schedule in CI, writes to the job summary and never fails the +run. It flags likely staleness from each node's cadence; it does not check any value. + `npm run check:sources` produces a best-effort external source reachability report. It needs network access and is not part of `verify` or CI. A 2xx or 3xx response counts as reachable. The report does not establish source validity, accuracy, or currency; read the source itself. diff --git a/package.json b/package.json index 6e4f71a..4cbe4fb 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "preview": "vite preview", "test:e2e": "npm run build && npm run test:math && node scripts/block-e2e.mjs", "check:sources": "node scripts/check-sources.mjs", + "check:freshness": "node scripts/check-freshness.mjs", "test:math": "node scripts/model-audit.mjs", "check:attribution": "python3 scripts/check-attribution.py" }, diff --git a/scripts/check-freshness.mjs b/scripts/check-freshness.mjs new file mode 100644 index 0000000..bb4dea7 --- /dev/null +++ b/scripts/check-freshness.mjs @@ -0,0 +1,73 @@ +/** + * Freshness report for stored baselines. Offline and deterministic: it reads graph.json and a date, + * and fetches nothing, so a publisher's site being down can never fail it. + * + * npm run check:freshness report as of today (UTC) + * npm run check:freshness -- --today 2026-09-27 report as of a fixed date + * npm run check:freshness -- --strict exit 1 when any baseline is flagged + * + * A flag means a newer release has probably been published, judged from the node's cadence and the + * end of its period (src/lib/freshness.ts). It does not mean the stored value is wrong. Refresh a + * flagged node by reading the publisher's page and updating baseline, asOf, sourceUrl, sourceDetail + * and retrievedDate together, as CONTRIBUTING.md describes. The weekly CI run writes this report to + * the job summary and never fails on it. + */ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const args = process.argv.slice(2); +const strict = args.includes("--strict"); +const todayIndex = args.indexOf("--today"); +const today = todayIndex >= 0 ? args[todayIndex + 1] : new Date().toISOString().slice(0, 10); +// Round-trip the date, so an impossible one such as 2026-02-30 is refused, not rolled over. +const parsed = new Date(`${today ?? ""}T00:00:00Z`); +if ( + !/^\d{4}-\d{2}-\d{2}$/.test(today ?? "") || + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== today +) { + console.error("--today needs a date as YYYY-MM-DD"); + process.exit(2); +} + +const source = await fs.readFile(path.join(root, "src/lib/freshness.ts"), "utf8"); +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "usl-freshness-")); +let freshness; +try { + const file = path.join(temporary, "freshness.mjs"); + await fs.writeFile( + file, + ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.ESNext }, + }).outputText, + ); + ({ freshness } = await import(pathToFileURL(file).href)); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} + +const graph = JSON.parse(await fs.readFile(path.join(root, "src/data/graph.json"), "utf8")); +const rows = graph.nodes.map((node) => ({ node, result: freshness(node, today) })); +const flagged = rows.filter((row) => row.result.status === "check"); +const lines = [ + `## Baseline freshness as of ${today}`, + "", + `${String(flagged.length)} of ${String(rows.length)} baselines may have a newer release. A flag is a prompt to read the source, not a finding that the value is wrong.`, + "", + "| Indicator | As of | Cadence | Days since period end | Status |", + "| --- | --- | --- | --- | --- |", + ...rows.map( + ({ node, result }) => + `| ${node.id} | ${node.asOf ?? "none"} | ${node.cadence ?? "none"} | ${result.ageDays === null ? "n/a" : String(result.ageDays)} | ${result.status === "check" ? "check source" : result.status === "current" ? "current" : "not applicable"} |`, + ), + "", +]; +const text = lines.join("\n"); +console.log(text); +if (process.env.GITHUB_STEP_SUMMARY) await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, text); +if (strict && flagged.length > 0) process.exit(1); diff --git a/src/data/graph.json b/src/data/graph.json index dec3092..81ccf0f 100644 --- a/src/data/graph.json +++ b/src/data/graph.json @@ -13,6 +13,7 @@ "sourceUrl": "https://www.bls.gov/news.release/archives/cpi_09112026.htm", "sourceDetail": "BLS, Consumer Price Index news release for August 2026 (released 2026-09-11): CPI-U all items, 12 months ending August, not seasonally adjusted. Read through a fetch tool because BLS blocks scripted requests.", "retrievedDate": "2026-09-19", + "cadence": "monthly", "description": "Consumer Price Index for All Urban Consumers, all items, 12-month change.", "terminal": null }, @@ -29,6 +30,7 @@ "sourceUrl": "https://www.federalreserve.gov/newsevents/pressreleases/monetary20260916a.htm", "sourceDetail": "FOMC statement, 2026-09-16, approved 12-0: target range raised by 1/4 percentage point to 3-3/4 to 4 percent. The baseline is the midpoint of that range.", "retrievedDate": "2026-09-19", + "cadence": "irregular", "description": "Federal Open Market Committee target range for the federal funds rate.", "terminal": null }, @@ -45,6 +47,7 @@ "sourceUrl": "https://www.freddiemac.com/pmms", "sourceDetail": "Freddie Mac Primary Mortgage Market Survey, weekly average as of 2026-09-17: 6.95 percent, up from 6.76 percent the prior week. The page shows the latest weekly reading and will change; the cited value is the 2026-09-17 reading.", "retrievedDate": "2026-09-19", + "cadence": "weekly", "description": "Weekly average 30-year fixed-rate mortgage rate.", "terminal": null }, @@ -61,6 +64,7 @@ "sourceUrl": "https://www.bea.gov/news/2026/gdp-second-estimate-and-corporate-profits-2nd-quarter-2026", "sourceDetail": "BEA, GDP (Second Estimate) and Corporate Profits, 2nd Quarter 2026 (released 2026-08-26): real GDP increased at an annual rate of 1.5 percent in Q2; it increased 2.1 percent in Q1. Second estimate, subject to revision.", "retrievedDate": "2026-09-19", + "cadence": "quarterly", "description": "Real gross domestic product, quarterly change at an annual rate.", "terminal": null }, @@ -77,6 +81,7 @@ "sourceUrl": "https://www.bls.gov/news.release/archives/empsit_09042026.htm", "sourceDetail": "BLS, Employment Situation for August 2026 (released 2026-09-04): total nonfarm payroll employment increased by 162,000 in August. First published estimate, which BLS later revises. Read through a fetch tool because BLS blocks scripted requests.", "retrievedDate": "2026-09-19", + "cadence": "monthly", "description": "Headline change in total nonfarm payroll employment for the month.", "terminal": "One month's change is too noisy to drive the annual income and poverty measures in this graph, so nothing is modeled downstream." }, @@ -93,6 +98,7 @@ "sourceUrl": "https://www.bls.gov/news.release/archives/realer_09112026.htm", "sourceDetail": "BLS, Real Earnings for August 2026 (released 2026-09-11): from August 2025 to August 2026, real average hourly earnings decreased 0.3 percent, seasonally adjusted. Read through a fetch tool because BLS blocks scripted requests.", "retrievedDate": "2026-09-19", + "cadence": "monthly", "description": "Change in real average hourly earnings of all employees over 12 months.", "terminal": null }, @@ -109,6 +115,7 @@ "sourceUrl": "https://www.newyorkfed.org/newsevents/news/research/2026/20260811", "sourceDetail": "New York Fed, Quarterly Report on Household Debt and Credit, Q2 2026 (press release 2026-08-11): total household debt decreased by $13 billion, 0.1 percent, to $18.8 trillion.", "retrievedDate": "2026-09-19", + "cadence": "quarterly", "description": "Total outstanding U.S. household debt at the end of the quarter.", "terminal": null }, @@ -125,6 +132,7 @@ "sourceUrl": "https://www.bea.gov/news/2026/personal-income-and-outlays-july-2026", "sourceDetail": "BEA, Personal Income and Outlays, July 2026: personal saving was $712.0 billion in July and the personal saving rate, personal saving as a percentage of disposable personal income, was 3.0 percent.", "retrievedDate": "2026-09-19", + "cadence": "monthly", "description": "Personal saving as a share of disposable personal income.", "terminal": "Effects of saving on spending and growth are contested and run both ways, so nothing is modeled downstream." }, @@ -141,6 +149,7 @@ "sourceUrl": "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v2/accounting/od/debt_to_penny?filter=record_date:eq:2026-08-18&fields=record_date,tot_pub_debt_out_amt", "sourceDetail": "U.S. Treasury Fiscal Data, Debt to the Penny, record date 2026-08-18: total public debt outstanding $40,047,425,768,420.22, rounded to $40.05 trillion.", "retrievedDate": "2026-09-19", + "cadence": "daily", "description": "Total public debt outstanding.", "terminal": null }, @@ -157,6 +166,7 @@ "sourceUrl": null, "sourceDetail": "No value is stored. It would be derived from two Treasury Debt to the Penny readings twelve months apart, which is a calculation and not a fetched figure, so it is left empty until the interface can show the calculation.", "retrievedDate": null, + "cadence": "daily", "description": "Year-over-year growth in total public debt outstanding.", "terminal": null }, @@ -173,6 +183,7 @@ "sourceUrl": "https://www.cbo.gov/publication/61882", "sourceDetail": "CBO, The Budget and Economic Outlook: 2026 to 2036 (2026-02-11), chart 'Total Deficits, Net Outlays for Interest, and Primary Deficits': net interest outlays for 2026 are 3.3 percent of GDP. A projection under laws in place as of January 2026, not an observation. Read from the chart data embedded in the report page; the report's dollar figure for net interest was not read.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "CBO projection of federal net interest outlays as a share of GDP.", "terminal": null }, @@ -189,6 +200,7 @@ "sourceUrl": "https://www.bls.gov/news.release/archives/prod2_09032026.htm", "sourceDetail": "BLS, Productivity and Costs, second quarter 2026 revised (released 2026-09-03): from the same quarter a year ago, nonfarm business sector labor productivity increased 2.2 percent. Read through a fetch tool because BLS blocks scripted requests.", "retrievedDate": "2026-09-19", + "cadence": "quarterly", "description": "Nonfarm business sector labor productivity, change from the same quarter a year earlier.", "terminal": null }, @@ -205,6 +217,7 @@ "sourceUrl": "https://www.census.gov/library/publications/2026/demo/p60-289.html", "sourceDetail": "Census Bureau, Income in the United States: 2025 (P60-289): median household income was $87,460 in 2025, up 2.6 percent from $85,210 in 2024.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "Median annual household income.", "terminal": null }, @@ -221,6 +234,7 @@ "sourceUrl": "https://www.census.gov/library/publications/2026/demo/p60-290.html", "sourceDetail": "Census Bureau, Poverty in the United States: 2025 (P60-290): the official poverty rate fell 0.5 percentage points to 10.2 percent; 34.5 million people were in poverty. This is a different report from the income report cited for median household income.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "Share of people below the official poverty threshold.", "terminal": null }, @@ -237,6 +251,7 @@ "sourceUrl": "https://www.ers.usda.gov/publications/pub-details?pubid=113622", "sourceDetail": "USDA Economic Research Service, Household Food Security in the United States in 2024: 86.3 percent of households were food secure all year; the remaining 13.7 percent were food insecure at some time during 2024.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "Share of U.S. households that were food insecure at some time during the year.", "terminal": "Outcome indicator. The model does not trace effects of food insecurity onward." }, @@ -253,6 +268,7 @@ "sourceUrl": "https://www.hud.gov/news/hud-no-26-037", "sourceDetail": "HUD press release on the 2025 Annual Homelessness Assessment Report, Part 1 (2026-05-29): 745,652 people were homeless on a single night in January 2025, a 3 percent decrease since 2024. The figure is taken from the press release; the AHAR Part 1 report is the underlying document.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "Point-in-time count of people experiencing homelessness on one night in January.", "terminal": "Outcome indicator. The model does not trace effects of homelessness onward." }, @@ -269,6 +285,7 @@ "sourceUrl": "https://news.gallup.com/poll/712436/confidence-institutions-remains-near-time-low.aspx", "sourceDetail": "Gallup, 'Confidence in U.S. Institutions Remains Near All-Time Low' (2026-07-13): the share of Americans expressing a great deal or quite a lot of confidence, averaged across 14 core institutions measured each year since 1993, is 27 percent, one point above the record-low average in 2023.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "Gallup average confidence across 14 core U.S. institutions.", "terminal": "Outcome indicator. Effects of institutional confidence on other indicators are argued but not established well enough to model." }, @@ -285,6 +302,7 @@ "sourceUrl": "https://news.gallup.com/poll/695762/trust-media-new-low.aspx", "sourceDetail": "Gallup, 'Trust in Media at New Low of 28% in U.S.' (published 2025-10-02): 28 percent, from a poll conducted Sept. 2-16, 2025, the first reading below 30 percent.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "Gallup measure of trust in the mass media.", "terminal": null }, @@ -301,6 +319,7 @@ "sourceUrl": "https://www.fbi.gov/news/press-releases/fbi-releases-2025-reported-crimes-in-the-nation-statistics", "sourceDetail": "FBI, 'FBI Releases 2025 Reported Crimes in the Nation Statistics' (2026-08-14): reported hate crime incidents decreased 7.0 percent from 11,404 in 2024 to 10,606 in 2025, using a matched-agency trend dataset. The 10,881 incidents also cited on that page are the total submitted, a different count.", "retrievedDate": "2026-09-19", + "cadence": "annual", "description": "Reported hate crime incidents in the FBI trend dataset.", "terminal": "Isolated on purpose. Research on economic conditions and hate crime is mixed, and no relationship into or out of it meets the evidence bar." }, @@ -317,6 +336,7 @@ "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, + "cadence": null, "description": "Abstract lever from the solution map. No measured series and no baseline.", "terminal": null } diff --git a/src/lib/freshness.test.ts b/src/lib/freshness.test.ts new file mode 100644 index 0000000..7deb7d1 --- /dev/null +++ b/src/lib/freshness.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import graphJson from "../data/graph.json"; +import { DUE_AFTER_DAYS, freshness, periodEnd } from "./freshness"; +import { parseGraph } from "./validate"; + +const day = (iso: string) => Date.parse(`${iso}T00:00:00Z`); + +describe("periodEnd", () => { + it.each([ + ["2026-09-17", "2026-09-17"], + ["2026-08", "2026-08-31"], + ["2024-02", "2024-02-29"], + ["2026-Q2", "2026-06-30"], + ["2026-Q4", "2026-12-31"], + ["FY2026", "2026-09-30"], + ["2025", "2025-12-31"], + ])("puts %s at the end of %s", (asOf, end) => { + expect(periodEnd(asOf)).toBe(day(end)); + }); + + it("returns null for text it cannot read", () => { + expect(periodEnd("last week")).toBeNull(); + }); +}); + +describe("freshness", () => { + it("flags a weekly figure once nine days have passed since its date", () => { + expect(freshness({ asOf: "2026-09-17", cadence: "weekly" }, "2026-09-26")).toEqual({ + status: "current", + ageDays: 9, + dueAfterDays: 9, + }); + expect(freshness({ asOf: "2026-09-17", cadence: "weekly" }, "2026-09-27").status).toBe("check"); + }); + + it("counts from the end of a month, quarter, fiscal year or year", () => { + expect(freshness({ asOf: "2026-08", cadence: "monthly" }, "2026-09-27").ageDays).toBe(27); + expect(freshness({ asOf: "2026-Q2", cadence: "quarterly" }, "2026-09-27").ageDays).toBe(89); + // The fiscal year has three days to run, so its projection is not yet aged at all. + expect(freshness({ asOf: "FY2026", cadence: "annual" }, "2026-09-27").ageDays).toBe(0); + expect(freshness({ asOf: "2024", cadence: "annual" }, "2026-09-27").status).toBe("current"); + expect(freshness({ asOf: "2023", cadence: "annual" }, "2026-09-27")).toEqual({ + status: "check", + ageDays: 1001, + dueAfterDays: 730, + }); + }); + + it("does not apply to a node with no period, no cadence, or an unreadable date", () => { + const none = { status: "not_applicable", ageDays: null, dueAfterDays: null }; + expect(freshness({ asOf: null, cadence: "daily" }, "2026-09-27")).toEqual(none); + expect(freshness({ asOf: "2026-08", cadence: null }, "2026-09-27")).toEqual(none); + expect(freshness({ asOf: "2026-08", cadence: "monthly" }, "today")).toEqual(none); + }); + + it("has a due period for every cadence, shortest for the most frequent", () => { + const { daily, weekly, monthly, quarterly, annual } = DUE_AFTER_DAYS; + expect(daily < weekly && weekly < monthly && monthly < quarterly && quarterly < annual).toBe( + true, + ); + }); + + it("gives the stored graph a result for every node on a fixed date", () => { + const graph = parseGraph(graphJson); + const flagged = graph.nodes + .filter((node) => freshness(node, "2026-09-27").status === "check") + .map((node) => node.id) + .sort(); + // federal_debt is a daily series last read for 2026-08-18, mortgage_rate is weekly from + // 2026-09-17, and savings_rate is monthly for July 2026, 58 days before this date. + expect(flagged).toEqual(["federal_debt", "mortgage_rate", "savings_rate"]); + }); +}); diff --git a/src/lib/freshness.ts b/src/lib/freshness.ts new file mode 100644 index 0000000..b7c6d3c --- /dev/null +++ b/src/lib/freshness.ts @@ -0,0 +1,67 @@ +/** + * Freshness heuristic for stored baselines. Pure: no I/O and no clock; the caller passes today. + * + * Each observed or projected node records how often its publisher releases a new figure + * (`cadence`). A baseline is flagged "check" when more time has passed since the end of its period + * than a release usually takes at that cadence, so a newer figure has probably been published. Age + * alone does not show that a value is wrong, and a fresh date does not catch revisions to the same + * period, such as a third GDP estimate. The report says where to look; a person reads the source. + */ +import type { Cadence } from "./schema"; + +/** + * Days after the end of a period by which the next release has usually appeared. Annual series are + * slow: a 2024 survey is often published late in 2025 and replaced late in 2026, so two years pass + * before an annual figure is overdue. + */ +export const DUE_AFTER_DAYS: Readonly> = { + daily: 3, + weekly: 9, + monthly: 50, + quarterly: 120, + irregular: 60, + annual: 730, +}; + +export type FreshnessStatus = "current" | "check" | "not_applicable"; + +export interface Freshness { + readonly status: FreshnessStatus; + /** + * Whole days from the end of the baseline period to today, and 0 while the period is still open, + * as for a projection of the current fiscal year; null when not applicable. + */ + readonly ageDays: number | null; + readonly dueAfterDays: number | null; +} + +const DAY_MS = 86_400_000; + +/** The last day of the period an asOf string names, as a UTC timestamp, or null if unparsable. */ +export function periodEnd(asOf: string): number | null { + let match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(asOf); + if (match) return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + match = /^(\d{4})-(\d{2})$/.exec(asOf); + if (match) return Date.UTC(Number(match[1]), Number(match[2]), 0); + match = /^(\d{4})-Q([1-4])$/.exec(asOf); + if (match) return Date.UTC(Number(match[1]), Number(match[2]) * 3, 0); + match = /^FY(\d{4})$/.exec(asOf); + if (match) return Date.UTC(Number(match[1]), 8, 30); + match = /^(\d{4})$/.exec(asOf); + if (match) return Date.UTC(Number(match[1]), 11, 31); + return null; +} + +export function freshness( + node: { readonly asOf: string | null; readonly cadence: Cadence | null }, + today: string, +): Freshness { + const end = node.asOf === null ? null : periodEnd(node.asOf); + const now = periodEnd(today); + if (node.cadence === null || end === null || now === null) { + return { status: "not_applicable", ageDays: null, dueAfterDays: null }; + } + const ageDays = Math.max(0, Math.round((now - end) / DAY_MS)); + const dueAfterDays = DUE_AFTER_DAYS[node.cadence]; + return { status: ageDays > dueAfterDays ? "check" : "current", ageDays, dueAfterDays }; +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 60ea52f..3e9a80e 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -47,6 +47,13 @@ export type Confidence = (typeof CONFIDENCES)[number]; export const KINDS = ["accounting", "causal", "association"] as const; export type Kind = (typeof KINDS)[number]; +/** + * How often the publisher releases a new figure for a node, used only by the freshness report. + * irregular covers schedules such as the eight FOMC meetings a year. null for index nodes. + */ +export const CADENCES = ["daily", "weekly", "monthly", "quarterly", "annual", "irregular"] as const; +export type Cadence = (typeof CADENCES)[number]; + /** Roughly how long the relationship takes to show: months, a year or two, or several years. */ export const HORIZONS = ["short", "medium", "long"] as const; export type Horizon = (typeof HORIZONS)[number]; @@ -72,6 +79,8 @@ export interface GraphNode { sourceDetail: string | null; /** ISO date (YYYY-MM-DD) on which the source page was fetched and read. */ retrievedDate: string | null; + /** How often a new figure is published; null for index nodes. */ + cadence: Cadence | null; description: string; /** * Why the node drives nothing, required exactly when no edge leaves it, so that a dead end is a @@ -127,6 +136,7 @@ export type ValidationCode = | "invalid_strength" | "invalid_claim" | "forbidden_word" + | "cadence_mismatch" | "terminal_required" | "terminal_forbidden"; diff --git a/src/lib/validate.test.ts b/src/lib/validate.test.ts index 014a799..6bb23aa 100644 --- a/src/lib/validate.test.ts +++ b/src/lib/validate.test.ts @@ -23,6 +23,7 @@ function fixture(): RawGraph { sourceUrl: null, sourceDetail: null, retrievedDate: null, + cadence: null, }; const terminal = "Fixture: nothing downstream is modeled."; return { @@ -40,6 +41,7 @@ function fixture(): RawGraph { sourceUrl: "https://www.bls.gov/fixture/cpi", sourceDetail: "Fixture: CPI release, table 1", retrievedDate: "2026-09-19", + cadence: "monthly", terminal: terminal, description: "Fixture node.", }, @@ -56,6 +58,7 @@ function fixture(): RawGraph { sourceUrl: "https://news.example.com/fixture/fomc", sourceDetail: "Fixture: news report of the decision", retrievedDate: "2026-09-19", + cadence: "monthly", terminal: null, description: "Fixture node.", }, @@ -72,6 +75,7 @@ function fixture(): RawGraph { sourceUrl: null, sourceDetail: "Fixture: primary page not yet located", retrievedDate: null, + cadence: "monthly", terminal: terminal, description: "Fixture node.", }, @@ -648,6 +652,32 @@ describe("validateGraph: relationship kind, horizon and terminal nodes", () => { }); }); +describe("validateGraph: cadence", () => { + it("accepts every cadence on a measured node and null on an abstract lever", () => { + for (const cadence of ["daily", "weekly", "monthly", "quarterly", "annual", "irregular"]) { + expect(summarize(withNode("inflation", { cadence }))).toEqual([]); + } + }); + + it("requires a cadence on observed and projected nodes", () => { + expect(summarize(withNode("inflation", { cadence: null }))).toEqual([ + ["nodes[0].cadence", "cadence_mismatch"], + ]); + expect(summarize(withNode("net_interest", { cadence: null }))).toEqual([ + ["nodes[2].cadence", "cadence_mismatch"], + ]); + }); + + it("rejects a cadence on an index node and an unknown cadence", () => { + expect(summarize(withNode("spare_lever", { cadence: "weekly" }))).toEqual([ + ["nodes[4].cadence", "cadence_mismatch"], + ]); + expect(summarize(withNode("inflation", { cadence: "hourly" }))).toEqual([ + ["nodes[0].cadence", "invalid_enum"], + ]); + }); +}); + describe("parseGraph", () => { it("returns the graph when it is valid", () => { const g = fixture(); diff --git a/src/lib/validate.ts b/src/lib/validate.ts index 0e6973f..3ac56cc 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -1,4 +1,5 @@ import { + CADENCES, CATEGORIES, CONFIDENCES, HORIZONS, @@ -48,6 +49,7 @@ const NODE_KEYS = [ "sourceUrl", "sourceDetail", "retrievedDate", + "cadence", "description", "terminal", ]; @@ -345,6 +347,7 @@ function validateNode(raw: unknown, index: number, seen: Set, report: Re } checkSource(raw, path, mode, report); + checkCadence(raw, path, valueType, report); checkKeys(raw, NODE_KEYS, path, report); } @@ -418,6 +421,21 @@ function validateEdge( checkKeys(raw, EDGE_KEYS, path, report); } +/** Measured and projected nodes say how often they are published; an abstract lever does not. */ +function checkCadence(raw: Raw, path: string, valueType: string | undefined, report: Report): void { + const cadence = raw["cadence"]; + if (cadence === null) { + if (valueType === "observed" || valueType === "projected") { + report(join(path, "cadence"), "cadence_mismatch", `${valueType} nodes need a cadence`); + } + return; + } + const value = readEnum(raw, "cadence", CADENCES, path, report); + if (value !== undefined && valueType === "index") { + report(join(path, "cadence"), "cadence_mismatch", "index nodes are not published"); + } +} + /** A node states why it drives nothing exactly when no edge leaves it. */ function checkTerminals(nodes: unknown[], drivers: ReadonlySet, report: Report): void { nodes.forEach((raw, index) => { diff --git a/src/repo/ci-workflow.test.ts b/src/repo/ci-workflow.test.ts index 40888f6..d084cf9 100644 --- a/src/repo/ci-workflow.test.ts +++ b/src/repo/ci-workflow.test.ts @@ -196,6 +196,13 @@ describe("the steps", () => { expect(step).toContain("continue-on-error: ${{ github.event_name == 'schedule' }}"); }); + it("reports baseline freshness on the weekly run only, and never fails on it", () => { + const step = /- name: Report baseline freshness\n((?: {8}.*\n?)+)/.exec(workflow)?.[1] ?? ""; + expect(step).toContain("if: github.event_name == 'schedule'"); + expect(step).toContain("run: npm run check:freshness"); + expect(step).not.toContain("--strict"); + }); + it("does not deploy or publish anything; production changes only by a manual owner deploy", () => { expect(workflow).not.toMatch(/\b(deploy|publish|wrangler|cloudflare)\b/i); }); diff --git a/src/repo/freshness-report.test.ts b/src/repo/freshness-report.test.ts new file mode 100644 index 0000000..948ebbc --- /dev/null +++ b/src/repo/freshness-report.test.ts @@ -0,0 +1,42 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +/** The freshness report is offline: a fixed date gives a fixed report. */ +function run(...args: string[]): { status: number; output: string } { + try { + const output = execFileSync("node", ["scripts/check-freshness.mjs", ...args], { + encoding: "utf8", + env: { ...process.env, GITHUB_STEP_SUMMARY: "" }, + }); + return { status: 0, output }; + } catch (error) { + const failed = error as { status: number; stdout: string }; + return { status: failed.status, output: failed.stdout }; + } +} + +describe("scripts/check-freshness.mjs", () => { + it("lists every indicator and flags the likely stale ones for a fixed date", () => { + const { status, output } = run("--today", "2026-09-27"); + expect(status).toBe(0); + expect(output).toContain("3 of 20 baselines may have a newer release."); + expect(output).toContain("| mortgage_rate | 2026-09-17 | weekly | 10 | check source |"); + expect(output).toContain("| inflation | 2026-08 | monthly | 27 | current |"); + expect(output).toContain("| worker_bargaining_power | none | none | n/a | not applicable |"); + }); + + it("gives the same report every time for the same date", () => { + expect(run("--today", "2026-09-27").output).toBe(run("--today", "2026-09-27").output); + }); + + it("fails only in strict mode, and only when something is flagged", () => { + expect(run("--today", "2026-09-27", "--strict").status).toBe(1); + expect(run("--today", "2026-08-20", "--strict").status).toBe(0); + }); + + it("rejects a malformed date", () => { + expect(run("--today", "27/09/2026").status).toBe(2); + expect(run("--today", "2026-02-30").status).toBe(2); + expect(run("--today", "2026-13-45").status).toBe(2); + }); +}); diff --git a/src/ui/present.test.ts b/src/ui/present.test.ts index cc459d7..a5b53ac 100644 --- a/src/ui/present.test.ts +++ b/src/ui/present.test.ts @@ -22,6 +22,7 @@ function node(overrides: Partial = {}): GraphNode { sourceUrl: "https://example.com", sourceDetail: "Example", retrievedDate: "2026-09-19", + cadence: "monthly", description: "A sample node.", terminal: null, ...overrides,