From 57e1ecc2eebc90a09552f10ed93fd38c56b8613d Mon Sep 17 00:00:00 2001 From: Thor Thor <37605399+codethor0@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:43:44 +0000 Subject: [PATCH 1/3] feat: record relationship kind, horizon and intentional dead ends Model integrity release, part 1. - Every edge now states what it asserts (kind: accounting, causal or association) and a rough horizon (short, medium, long), separately from confidence (empirical or modeled). The arithmetic ignores both. - Every node with no outgoing edge states why in a terminal field, and the validator rejects an unexplained dead end or a terminal node that drives an edge. - Two accounting links close the federal borrowing loop: net_interest -> debt_growth_rate and debt_growth_rate -> federal_debt. Both are modeled, with no citation. - Claims are qualified where the old wording overstated: refinancing lags, distribution versus median, association versus causation. No baseline or confidence changes: primary sources could not be read from the build environment, so nothing is marked as verified. Hand-worked expectations are recomputed in the test comments. --- CONTRIBUTING.md | 25 +++++- README.md | 41 ++++++--- scripts/block-e2e.mjs | 2 +- src/data/graph.json | 148 +++++++++++++++++++++++++------- src/data/graph.test.ts | 27 +++++- src/lib/propagation.test.ts | 15 +++- src/lib/schema.ts | 26 +++++- src/lib/validate.test.ts | 72 ++++++++++++++++ src/lib/validate.ts | 36 +++++++- src/model/effects.test.ts | 3 +- src/repo/connectivity.test.ts | 7 +- src/ui/blocks/BlockApp.test.tsx | 4 +- src/ui/present.test.ts | 1 + 13 files changed, 354 insertions(+), 53 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e6d509..b5777a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,28 @@ citation. Until there is one, the edge stays modeled. Some lines are absent on purpose. `hate_crimes` has no edges, and a test records that. An edge into or out of it needs new evidence and a citation, not an argument. +## Relationship kind and horizon + +Every edge also states what it asserts, independent of its `confidence`: + +- `kind` is `accounting` when the link follows from how the measures are defined or added up, + `causal` when it names a mechanism by which the first indicator moves the second, and + `association` when the two move together and the edge does not say which causes which. Use + `association` whenever the source, or the argument, shows co-movement only. A claim on an + association edge must not use causal verbs such as causes, drives, raises or lowers. +- `horizon` is `short` (months), `medium` (a year or two) or `long` (several years). + +Both labels are for readers. The arithmetic ignores them. A citation for an `empirical` edge must +support the kind it is labeled with: a table that shows co-movement supports `association`, not +`causal`. + +## Dead ends + +A node with no outgoing edge must say why in its `terminal` field, and a node that drives an edge +must leave `terminal` as `null`. The validator enforces both, so a dead end is always a recorded +decision. Do not add an edge only so that a tile moves; a stated dead end is better than an invented +link. + ## Adding a node A node is an indicator or a lever. Its fields are defined in `src/lib/schema.ts` and enforced by @@ -50,6 +72,7 @@ A node is an indicator or a lever. Its fields are defined in `src/lib/schema.ts` | `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`. | How to verify a starting value: @@ -71,7 +94,7 @@ How to verify a starting value: - `id` is `from__to`, using the two node ids, and both nodes must exist. No self loops, and no two edges with the same id. - `direction` is `1` or `-1`. `strength` is one of 0.25, 0.5, 0.75, 1. `confidence` follows the hard - rule above. + rule above. `kind` and `horizon` follow the section on relationship kind and horizon. - **Edges are locked by tests, on purpose.** `src/data/graph.test.ts` lists the accepted edge ids, and `src/lib/propagation.test.ts` contains hand-computed expectations for the real graph. Adding, removing or reweighting an edge changes them. Update the list, redo the arithmetic by hand in the diff --git a/README.md b/README.md index 87b7b35..79b63cb 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,20 @@ still shows its signed score, in scientific notation if needed, rather than a fa ## What moves what The relationships are one-way. An input moves the indicators downstream of it and never the ones -upstream. Eight indicators have no outgoing relationship, so moving their input changes only their -own tile: `debt_growth_rate`, `food_insecurity`, `hate_crimes`, `homelessness`, -`institutional_confidence`, `net_interest`, `payrolls_headline` and `savings_rate`. They still -respond when other indicators move, except `hate_crimes`, which has no relationships at all, on -purpose. Every tile states what it directly drives. Adding a relationship is a change to the model -and follows the rules in [CONTRIBUTING.md](CONTRIBUTING.md). +upstream. Six indicators have no outgoing relationship, so moving their input changes only their +own tile: `food_insecurity`, `hate_crimes`, `homelessness`, `institutional_confidence`, +`payrolls_headline` and `savings_rate`. Each one records why in its `terminal` field, and the +validator rejects a dead end with no stated reason. Four are outcomes the model does not trace +further; for `payrolls_headline` and `savings_rate`, the downstream effects are too noisy or too +contested to model. They still respond when other indicators move, except `hate_crimes`, which has +no relationships at all, on purpose. + +Federal borrowing is a loop of two accounting links and one earlier link: interest outlays add to +the deficit and so to debt growth (`net_interest` to `debt_growth_rate`), debt growth adds to the +debt stock (`debt_growth_rate` to `federal_debt`), and a larger stock raises interest outlays +(`federal_debt` to `net_interest`). A path never revisits an indicator, so the loop is counted once +and does not compound. Every tile states what it directly drives. Adding a relationship is a change +to the model and follows the rules in [CONTRIBUTING.md](CONTRIBUTING.md). ## Status @@ -81,13 +89,26 @@ labels, and the difference between them is the most important thing in this proj An arrow is modeled unless someone supplies a checkable citation. Nothing is promoted because "everyone knows". +Separately, each arrow records what kind of relationship it asserts, because a citation can support +an association without showing that one thing causes the other: + +- **Accounting**: follows from how the measures are defined or added up. Real earnings are nominal + earnings adjusted for prices, so inflation lowers them unless pay keeps pace. +- **Causal**: a mechanism by which one indicator moves the other, such as policy rates feeding into + mortgage rates. +- **Association**: the two move together, and the arrow does not claim which causes which. + +Each arrow also carries a rough horizon, short, medium or long, because some relationships take +months and others years. The arithmetic ignores both labels; they are there so a reader can judge +each arrow. + The starting value of each indicator has its own, separate label. `primary` means a page at the primary publisher was read on a stated date and the figure was found on it. `secondary` means a news page or aggregator relays the figure. `pending` means no source page has been read yet, so no source is stored. Abstract levers, such as worker bargaining power, sit on an index from 0 to 100 and have no baseline at all, because there is nothing measured to check. -The graph currently has 20 nodes and 20 edges: 0 empirical and 20 modeled. Every arrow is therefore +The graph currently has 20 nodes and 22 edges: 0 empirical and 22 modeled. Every arrow is therefore an argument today, and none is yet a finding. ## How the arithmetic works @@ -121,9 +142,9 @@ bounded, that it always terminates, and that it is deterministic. The data lives in [src/data/graph.json](src/data/graph.json), and [a validator](src/lib/validate.ts) checks every change to it. The nodes and levers follow two posts -by the project's author that map U.S. indicators and policy levers. The 20 edges are editorial -drafts, reviewed and accepted by the author. They are not taken from those posts, and none has a -citation yet. +by the project's author that map U.S. indicators and policy levers. The first 20 edges are editorial +drafts, reviewed and accepted by the author, plus two accounting links that close the federal +borrowing loop. They are not taken from those posts, and none has a citation yet. Each starting value below was read from the linked page on 2026-09-19. The `sourceDetail` field of each node records the caveats, such as figures that are revised later, and one that is a projection diff --git a/scripts/block-e2e.mjs b/scripts/block-e2e.mjs index 3697671..3a8b0da 100644 --- a/scripts/block-e2e.mjs +++ b/scripts/block-e2e.mjs @@ -17,7 +17,7 @@ requireCheck( "Oracle requires review: propagation defaults changed", ); requireCheck( - graph.nodes.length === 20 && graph.edges.length === 20, + graph.nodes.length === 20 && graph.edges.length === 22, "Graph inventory changed; review required", ); const expectedBuild = /BLOCK_BUILD\s*=\s*["']([^"']+)/.exec( diff --git a/src/data/graph.json b/src/data/graph.json index 88cc2c2..dec3092 100644 --- a/src/data/graph.json +++ b/src/data/graph.json @@ -13,7 +13,8 @@ "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", - "description": "Consumer Price Index for All Urban Consumers, all items, 12-month change." + "description": "Consumer Price Index for All Urban Consumers, all items, 12-month change.", + "terminal": null }, { "id": "fed_rate", @@ -28,7 +29,8 @@ "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", - "description": "Federal Open Market Committee target range for the federal funds rate." + "description": "Federal Open Market Committee target range for the federal funds rate.", + "terminal": null }, { "id": "mortgage_rate", @@ -43,7 +45,8 @@ "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", - "description": "Weekly average 30-year fixed-rate mortgage rate." + "description": "Weekly average 30-year fixed-rate mortgage rate.", + "terminal": null }, { "id": "gdp_growth", @@ -58,7 +61,8 @@ "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", - "description": "Real gross domestic product, quarterly change at an annual rate." + "description": "Real gross domestic product, quarterly change at an annual rate.", + "terminal": null }, { "id": "payrolls_headline", @@ -73,7 +77,8 @@ "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", - "description": "Headline change in total nonfarm payroll employment for the month." + "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." }, { "id": "real_avg_hourly_earnings", @@ -88,7 +93,8 @@ "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", - "description": "Change in real average hourly earnings of all employees over 12 months." + "description": "Change in real average hourly earnings of all employees over 12 months.", + "terminal": null }, { "id": "household_debt", @@ -103,7 +109,8 @@ "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", - "description": "Total outstanding U.S. household debt at the end of the quarter." + "description": "Total outstanding U.S. household debt at the end of the quarter.", + "terminal": null }, { "id": "savings_rate", @@ -118,7 +125,8 @@ "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", - "description": "Personal saving as a share of disposable personal income." + "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." }, { "id": "federal_debt", @@ -133,7 +141,8 @@ "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", - "description": "Total public debt outstanding." + "description": "Total public debt outstanding.", + "terminal": null }, { "id": "debt_growth_rate", @@ -148,7 +157,8 @@ "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, - "description": "Year-over-year growth in total public debt outstanding." + "description": "Year-over-year growth in total public debt outstanding.", + "terminal": null }, { "id": "net_interest", @@ -163,7 +173,8 @@ "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", - "description": "CBO projection of federal net interest outlays as a share of GDP." + "description": "CBO projection of federal net interest outlays as a share of GDP.", + "terminal": null }, { "id": "productivity", @@ -178,7 +189,8 @@ "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", - "description": "Nonfarm business sector labor productivity, change from the same quarter a year earlier." + "description": "Nonfarm business sector labor productivity, change from the same quarter a year earlier.", + "terminal": null }, { "id": "median_household_income", @@ -193,7 +205,8 @@ "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", - "description": "Median annual household income." + "description": "Median annual household income.", + "terminal": null }, { "id": "poverty_rate", @@ -208,7 +221,8 @@ "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", - "description": "Share of people below the official poverty threshold." + "description": "Share of people below the official poverty threshold.", + "terminal": null }, { "id": "food_insecurity", @@ -223,7 +237,8 @@ "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", - "description": "Share of U.S. households that were food insecure at some time during the year." + "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." }, { "id": "homelessness", @@ -238,7 +253,8 @@ "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", - "description": "Point-in-time count of people experiencing homelessness on one night in January." + "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." }, { "id": "institutional_confidence", @@ -253,7 +269,8 @@ "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", - "description": "Gallup average confidence across 14 core U.S. institutions." + "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." }, { "id": "media_trust", @@ -268,7 +285,8 @@ "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", - "description": "Gallup measure of trust in the mass media." + "description": "Gallup measure of trust in the mass media.", + "terminal": null }, { "id": "hate_crimes", @@ -283,7 +301,8 @@ "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", - "description": "Reported hate crime incidents in the FBI trend dataset." + "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." }, { "id": "worker_bargaining_power", @@ -298,7 +317,8 @@ "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "description": "Abstract lever from the solution map. No measured series and no baseline." + "description": "Abstract lever from the solution map. No measured series and no baseline.", + "terminal": null } ], "edges": [ @@ -309,6 +329,8 @@ "direction": 1, "strength": 0.75, "confidence": "modeled", + "kind": "causal", + "horizon": "short", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -321,6 +343,8 @@ "direction": -1, "strength": 0.5, "confidence": "modeled", + "kind": "causal", + "horizon": "long", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -333,6 +357,8 @@ "direction": -1, "strength": 0.5, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -345,10 +371,12 @@ "direction": 1, "strength": 0.75, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Higher policy rates raise the interest cost of short-term federal borrowing." + "claim": "Higher policy rates raise federal interest costs as short-term debt is refinanced, not all at once." }, { "id": "fed_rate__savings_rate", @@ -357,6 +385,8 @@ "direction": 1, "strength": 0.25, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -369,6 +399,8 @@ "direction": -1, "strength": 0.25, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -381,10 +413,12 @@ "direction": -1, "strength": 0.75, "confidence": "modeled", + "kind": "accounting", + "horizon": "short", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "When prices rise faster than wages, real earnings fall; this is close to an accounting identity." + "claim": "Real earnings are nominal earnings adjusted for consumer prices, so higher inflation lowers them unless pay keeps pace." }, { "id": "inflation__institutional_confidence", @@ -393,10 +427,12 @@ "direction": -1, "strength": 0.25, "confidence": "modeled", + "kind": "association", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Higher inflation is commonly argued to erode confidence in institutions." + "claim": "Periods of higher inflation are commonly argued to coincide with lower confidence in institutions." }, { "id": "gdp_growth__payrolls_headline", @@ -405,6 +441,8 @@ "direction": 1, "strength": 0.5, "confidence": "modeled", + "kind": "association", + "horizon": "short", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -417,6 +455,8 @@ "direction": -1, "strength": 0.25, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -429,10 +469,12 @@ "direction": 1, "strength": 0.25, "confidence": "modeled", + "kind": "causal", + "horizon": "long", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Higher productivity growth is commonly argued to support real wage growth over time." + "claim": "Over long periods, higher productivity growth is commonly argued to support real wage growth; the two can diverge for years." }, { "id": "worker_bargaining_power__real_avg_hourly_earnings", @@ -441,6 +483,8 @@ "direction": 1, "strength": 0.25, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -453,10 +497,12 @@ "direction": 1, "strength": 0.5, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Higher real earnings raise household incomes." + "claim": "Pay is the largest part of household income, so higher real earnings tend to raise it; hours worked and household makeup also matter." }, { "id": "real_avg_hourly_earnings__savings_rate", @@ -465,6 +511,8 @@ "direction": 1, "strength": 0.25, "confidence": "modeled", + "kind": "causal", + "horizon": "short", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -477,10 +525,12 @@ "direction": -1, "strength": 0.5, "confidence": "modeled", + "kind": "association", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Rising median incomes tend to accompany lower poverty." + "claim": "Rising median incomes tend to accompany lower poverty, though poverty depends on the bottom of the income distribution, not the middle." }, { "id": "poverty_rate__food_insecurity", @@ -489,10 +539,12 @@ "direction": 1, "strength": 0.5, "confidence": "modeled", + "kind": "association", + "horizon": "short", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Higher poverty tends to accompany more food insecurity." + "claim": "Food insecurity is far more common among households in poverty; the two are associated, not identical." }, { "id": "poverty_rate__homelessness", @@ -501,6 +553,8 @@ "direction": 1, "strength": 0.25, "confidence": "modeled", + "kind": "association", + "horizon": "long", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, @@ -513,10 +567,12 @@ "direction": -1, "strength": 0.25, "confidence": "modeled", + "kind": "causal", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Higher debt service is commonly argued to reduce saving." + "claim": "Higher debt payments are commonly argued to reduce saving; total debt is only a rough stand-in for the payment burden." }, { "id": "federal_debt__net_interest", @@ -525,10 +581,12 @@ "direction": 1, "strength": 0.75, "confidence": "modeled", + "kind": "accounting", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "A larger debt stock raises interest outlays, other things equal." + "claim": "A larger debt stock raises interest outlays, other things equal; interest is paid mainly on the part held by the public, at rates that reset as debt matures." }, { "id": "media_trust__institutional_confidence", @@ -537,10 +595,40 @@ "direction": 1, "strength": 0.25, "confidence": "modeled", + "kind": "association", + "horizon": "medium", "sourceUrl": null, "sourceDetail": null, "retrievedDate": null, - "claim": "Lower media trust is commonly argued to lower trust in other institutions." + "claim": "Trust in the mass media tends to move with confidence in other institutions." + }, + { + "id": "net_interest__debt_growth_rate", + "from": "net_interest", + "to": "debt_growth_rate", + "direction": 1, + "strength": 0.5, + "confidence": "modeled", + "kind": "accounting", + "horizon": "short", + "sourceUrl": null, + "sourceDetail": null, + "retrievedDate": null, + "claim": "Interest outlays add to the deficit, which is financed by borrowing, so higher interest raises debt growth, other things equal." + }, + { + "id": "debt_growth_rate__federal_debt", + "from": "debt_growth_rate", + "to": "federal_debt", + "direction": 1, + "strength": 0.75, + "confidence": "modeled", + "kind": "accounting", + "horizon": "short", + "sourceUrl": null, + "sourceDetail": null, + "retrievedDate": null, + "claim": "Faster debt growth raises the debt stock; the growth rate is the change in that stock." } ] } diff --git a/src/data/graph.test.ts b/src/data/graph.test.ts index 31632b7..00cb26f 100644 --- a/src/data/graph.test.ts +++ b/src/data/graph.test.ts @@ -25,7 +25,10 @@ const SEED_NODE_IDS = [ "debt_growth_rate", ]; -/** Edges accepted in review, E1 to E20, with E10 retargeted to debt_growth_rate. */ +/** + * Edges accepted in review, E1 to E20, with E10 retargeted to debt_growth_rate, and E21 and E22, + * the two accounting links that close the federal borrowing loop. + */ const ACCEPTED_EDGE_IDS = [ "fed_rate__mortgage_rate", "fed_rate__inflation", @@ -47,6 +50,8 @@ const ACCEPTED_EDGE_IDS = [ "household_debt__savings_rate", "federal_debt__net_interest", "media_trust__institutional_confidence", + "net_interest__debt_growth_rate", + "debt_growth_rate__federal_debt", ]; describe("src/data/graph.json", () => { @@ -95,6 +100,26 @@ describe("src/data/graph.json", () => { expect(growth?.direction).toBe(-1); }); + it("labels the two federal borrowing links as accounting, not as causal findings", () => { + for (const id of ["net_interest__debt_growth_rate", "debt_growth_rate__federal_debt"]) { + const edge = graph.edges.find((e) => e.id === id); + expect(edge?.kind, id).toBe("accounting"); + expect(edge?.direction, id).toBe(1); + } + }); + + it("labels the inflation to real earnings link as accounting, because real earnings are deflated by prices", () => { + const edge = graph.edges.find((e) => e.id === "inflation__real_avg_hourly_earnings"); + expect(edge?.kind).toBe("accounting"); + expect(edge?.direction).toBe(-1); + }); + + it("never claims causation for a relationship recorded as an association", () => { + for (const edge of graph.edges.filter((e) => e.kind === "association")) { + expect(edge.claim, edge.id).not.toMatch(/\b(?:causes?|caused|drives?|raises|lowers)\b/i); + } + }); + it("stores no derived value for debt_growth_rate", () => { const node = graph.nodes.find((n) => n.id === "debt_growth_rate"); expect(node?.baseline).toBeNull(); diff --git a/src/lib/propagation.test.ts b/src/lib/propagation.test.ts index dc9d411..4681c8d 100644 --- a/src/lib/propagation.test.ts +++ b/src/lib/propagation.test.ts @@ -751,6 +751,7 @@ describe("the real graph, worked by hand", () => { * E13 real_avg_hourly_earnings->median_household_income +0.5 * E14 real_avg_hourly_earnings->savings_rate +0.25 * E18 household_debt->savings_rate -0.25 + * E21 net_interest->debt_growth_rate +0.5 E22 debt_growth_rate->federal_debt +0.75 * * mortgage_rate E1 = 0.75 * inflation E2 = -0.5 @@ -760,15 +761,20 @@ describe("the real graph, worked by hand", () => { * real_avg_hourly_earnings E2,E7 -0.5*-0.75*0.7 = 0.2625 * institutional_confidence E2,E8 -0.5*-0.25*0.7 = 0.0875 * payrolls_headline E3,E9 -0.5*0.5*0.7 = -0.175 - * debt_growth_rate E3,E10 -0.5*-0.25*0.7 = 0.0875 + * debt_growth_rate E3,E10 -0.5*-0.25*0.7 = 0.0875 + * E4,E21 0.75*0.5*0.7 = 0.2625 + * total 0.35 + * federal_debt E3,E10,E22 -0.5*-0.25*0.75*0.49 = 0.09375*0.49 = 0.0459375 + * E4,E21,E22 0.75*0.5*0.75*0.49 = 0.28125*0.49 = 0.1378125 + * total 0.18375 * median_household_income E2,E7,E13 -0.5*-0.75*0.5*0.49 = 0.091875 * savings_rate E5 = 0.25 * E1,E6,E18 0.75*-0.25*-0.25*0.49 = 0.046875*0.49 = 0.02296875 * E2,E7,E14 -0.5*-0.75*0.25*0.49 = 0.09375*0.49 = 0.0459375 * total 0.31890625 - * Eleven nodes are reached. poverty_rate is four edges away and is not. + * Twelve nodes are reached. poverty_rate is four edges away and is not. */ - it("fed_rate at +1 reaches exactly eleven nodes with the values above", () => { + it("fed_rate at +1 reaches exactly twelve nodes with the values above", () => { const effects = propagate(real, "fed_rate", 1, DEFAULT_PARAMS); const expected: Record = { mortgage_rate: 0.75, @@ -779,7 +785,8 @@ describe("the real graph, worked by hand", () => { real_avg_hourly_earnings: 0.2625, institutional_confidence: 0.0875, payrolls_headline: -0.175, - debt_growth_rate: 0.0875, + debt_growth_rate: 0.35, + federal_debt: 0.18375, median_household_income: 0.091875, savings_rate: 0.31890625, }; diff --git a/src/lib/schema.ts b/src/lib/schema.ts index cb4f4c0..60ea52f 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -36,6 +36,21 @@ export type Verification = (typeof VERIFICATIONS)[number]; export const CONFIDENCES = ["empirical", "modeled"] as const; export type Confidence = (typeof CONFIDENCES)[number]; +/** + * What an edge asserts, independent of whether a citation backs it (`confidence`). + * + * accounting: follows from how the measures are defined or added up, such as prices deflating + * earnings or interest outlays adding to borrowing. + * causal: a mechanism by which the first indicator moves the second. + * association: the two move together; the edge does not claim which causes which. + */ +export const KINDS = ["accounting", "causal", "association"] as const; +export type Kind = (typeof KINDS)[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]; + /** Coarse tiers, so that 0.7 versus 0.8 is never mistaken for a finding. */ export const STRENGTH_TIERS = [0.25, 0.5, 0.75, 1] as const; export type StrengthTier = (typeof STRENGTH_TIERS)[number]; @@ -58,6 +73,11 @@ export interface GraphNode { /** ISO date (YYYY-MM-DD) on which the source page was fetched and read. */ retrievedDate: string | null; description: string; + /** + * Why the node drives nothing, required exactly when no edge leaves it, so that a dead end is a + * recorded decision and never an accident of the topology. null when the node drives an edge. + */ + terminal: string | null; } export interface GraphEdge { @@ -68,6 +88,8 @@ export interface GraphEdge { direction: 1 | -1; strength: StrengthTier; confidence: Confidence; + kind: Kind; + horizon: Horizon; sourceUrl: string | null; sourceDetail: string | null; retrievedDate: string | null; @@ -104,7 +126,9 @@ export type ValidationCode = | "invalid_direction" | "invalid_strength" | "invalid_claim" - | "forbidden_word"; + | "forbidden_word" + | "terminal_required" + | "terminal_forbidden"; export interface ValidationError { path: string; diff --git a/src/lib/validate.test.ts b/src/lib/validate.test.ts index da41814..014a799 100644 --- a/src/lib/validate.test.ts +++ b/src/lib/validate.test.ts @@ -24,6 +24,7 @@ function fixture(): RawGraph { sourceDetail: null, retrievedDate: null, }; + const terminal = "Fixture: nothing downstream is modeled."; return { nodes: [ { @@ -39,6 +40,7 @@ function fixture(): RawGraph { sourceUrl: "https://www.bls.gov/fixture/cpi", sourceDetail: "Fixture: CPI release, table 1", retrievedDate: "2026-09-19", + terminal: terminal, description: "Fixture node.", }, { @@ -54,6 +56,7 @@ function fixture(): RawGraph { sourceUrl: "https://news.example.com/fixture/fomc", sourceDetail: "Fixture: news report of the decision", retrievedDate: "2026-09-19", + terminal: null, description: "Fixture node.", }, { @@ -69,6 +72,7 @@ function fixture(): RawGraph { sourceUrl: null, sourceDetail: "Fixture: primary page not yet located", retrievedDate: null, + terminal: terminal, description: "Fixture node.", }, { @@ -76,6 +80,7 @@ function fixture(): RawGraph { label: "Worker bargaining power", category: "institutional", ...index, + terminal: null, description: "Fixture lever.", }, { @@ -83,6 +88,7 @@ function fixture(): RawGraph { label: "Spare lever", category: "policy", ...index, + terminal: terminal, description: "Fixture lever referenced by no edge.", }, ], @@ -94,6 +100,8 @@ function fixture(): RawGraph { direction: -1, strength: 0.5, confidence: "empirical", + kind: "causal", + horizon: "medium", sourceUrl: "https://www.federalreserve.gov/fixture/paper", sourceDetail: "Fixture: working paper", retrievedDate: "2026-09-19", @@ -106,6 +114,8 @@ function fixture(): RawGraph { direction: 1, strength: 0.25, confidence: "modeled", + kind: "association", + horizon: "long", sourceUrl: null, sourceDetail: null, retrievedDate: null, @@ -205,6 +215,8 @@ describe("validateGraph: rejects, edges", () => { it("an edge whose source does not exist", () => { const g = withEdge("fed_rate__inflation", { from: "nope", id: "nope__inflation" }); + // fed_rate no longer drives anything, so it states why; only the dangling reference remains. + Object.assign(find(g.nodes, "fed_rate"), { terminal: "Fixture." }); expect(summarize(g)).toEqual([["edges[0].from", "dangling_reference"]]); }); @@ -214,6 +226,9 @@ describe("validateGraph: rejects, edges", () => { to: "inflation", id: "inflation__inflation", }); + // Keep the terminal rule satisfied, so the self loop is the only fault. + Object.assign(find(g.nodes, "fed_rate"), { terminal: "Fixture." }); + Object.assign(find(g.nodes, "inflation"), { terminal: null }); expect(summarize(g)).toEqual([["edges[0]", "self_loop"]]); }); @@ -576,6 +591,63 @@ describe("validateGraph: rejects, wrong types", () => { }); }); +describe("validateGraph: relationship kind, horizon and terminal nodes", () => { + it("accepts every kind and horizon, sourced or modeled", () => { + for (const kind of ["accounting", "causal", "association"]) { + for (const horizon of ["short", "medium", "long"]) { + expect(summarize(withEdge("fed_rate__inflation", { kind, horizon }))).toEqual([]); + expect( + summarize(withEdge("worker_bargaining_power__inflation", { kind, horizon })), + ).toEqual([]); + } + } + }); + + it("rejects a kind outside the three allowed", () => { + expect(summarize(withEdge("fed_rate__inflation", { kind: "correlation" }))).toEqual([ + ["edges[0].kind", "invalid_enum"], + ]); + }); + + it("rejects a missing kind or horizon", () => { + const g = fixture(); + const edge = find(g.edges, "fed_rate__inflation"); + delete edge["kind"]; + delete edge["horizon"]; + expect(summarize(g)).toEqual([ + ["edges[0].kind", "invalid_type"], + ["edges[0].horizon", "invalid_type"], + ]); + }); + + it("rejects a horizon outside the three allowed", () => { + expect(summarize(withEdge("fed_rate__inflation", { horizon: "instant" }))).toEqual([ + ["edges[0].horizon", "invalid_enum"], + ]); + }); + + it("requires a stated reason on a node with no outgoing relationship", () => { + expect(summarize(withNode("spare_lever", { terminal: null }))).toEqual([ + ["nodes[4].terminal", "terminal_required"], + ]); + expect(summarize(withNode("inflation", { terminal: " " }))).toEqual([ + ["nodes[0].terminal", "terminal_required"], + ]); + }); + + it("rejects a terminal reason on a node that drives another", () => { + expect(summarize(withNode("fed_rate", { terminal: "Not really." }))).toEqual([ + ["nodes[1].terminal", "terminal_forbidden"], + ]); + }); + + it("rejects a terminal reason that is not a string or null", () => { + expect(summarize(withNode("spare_lever", { terminal: 7 }))).toEqual([ + ["nodes[4].terminal", "invalid_type"], + ]); + }); +}); + 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 08d950a..0e6973f 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -1,4 +1,12 @@ -import { CATEGORIES, CONFIDENCES, STRENGTH_TIERS, VALUE_TYPES, VERIFICATIONS } from "./schema"; +import { + CATEGORIES, + CONFIDENCES, + HORIZONS, + KINDS, + STRENGTH_TIERS, + VALUE_TYPES, + VERIFICATIONS, +} from "./schema"; import type { Graph, ValidationCode, ValidationError, Verification } from "./schema"; type Raw = Record; @@ -41,6 +49,7 @@ const NODE_KEYS = [ "sourceDetail", "retrievedDate", "description", + "terminal", ]; const EDGE_KEYS = [ "id", @@ -49,6 +58,8 @@ const EDGE_KEYS = [ "direction", "strength", "confidence", + "kind", + "horizon", "sourceUrl", "sourceDetail", "retrievedDate", @@ -381,6 +392,8 @@ function validateEdge( ); } const confidence = readEnum(raw, "confidence", CONFIDENCES, path, report); + readEnum(raw, "kind", KINDS, path, report); + readEnum(raw, "horizon", HORIZONS, path, report); const claim = raw["claim"]; if (typeof claim !== "string") { @@ -405,6 +418,24 @@ function validateEdge( checkKeys(raw, EDGE_KEYS, path, report); } +/** 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) => { + if (!isRecord(raw) || typeof raw["id"] !== "string") return; + const path = join(`nodes[${String(index)}]`, "terminal"); + const terminal = raw["terminal"]; + if (terminal !== null && typeof terminal !== "string") { + report(path, "invalid_type", "terminal must be a string or null"); + } else if (drivers.has(raw["id"])) { + if (terminal !== null) { + report(path, "terminal_forbidden", "a node that drives an edge is not terminal"); + } + } else if (terminal === null || terminal.trim() === "") { + report(path, "terminal_required", "a node with no outgoing edge must say why"); + } + }); +} + export function validateGraph(input: unknown): ValidationError[] { const errors: ValidationError[] = []; const report: Report = (path, code, message) => { @@ -417,6 +448,7 @@ export function validateGraph(input: unknown): ValidationError[] { } const nodeIds = new Set(); + const drivers = new Set(); const nodes = input["nodes"]; if (Array.isArray(nodes)) { nodes.forEach((node: unknown, index) => { @@ -431,10 +463,12 @@ export function validateGraph(input: unknown): ValidationError[] { if (Array.isArray(edges)) { edges.forEach((edge: unknown, index) => { validateEdge(edge, index, nodeIds, edgeIds, report); + if (isRecord(edge) && typeof edge["from"] === "string") drivers.add(edge["from"]); }); } else { report("edges", "invalid_type", "edges must be an array"); } + if (Array.isArray(nodes) && Array.isArray(edges)) checkTerminals(nodes, drivers, report); checkKeys(input, TOP_KEYS, "", report); return errors; diff --git a/src/model/effects.test.ts b/src/model/effects.test.ts index 63cdbf7..1175cb9 100644 --- a/src/model/effects.test.ts +++ b/src/model/effects.test.ts @@ -133,7 +133,7 @@ describe("two levers add before the clamp", () => { * median_household_income: fed 0.091875 (E2, E7, E13), worker 0.0875 total 0.179375 * poverty_rate: only worker reaches it within 3 hops: -0.030625 * fed_rate and worker_bargaining_power: own 1 each, nothing reaches them. - * Fourteen nodes are listed: the 11 fed_rate reaches, poverty_rate, and the two levers. + * Fifteen nodes are listed: the 12 fed_rate reaches, poverty_rate, and the two levers. */ const real = parseGraph(graphJson); const effects = combineEffects(real, levers({ fed_rate: 1, worker_bargaining_power: 1 })); @@ -142,6 +142,7 @@ describe("two levers add before the clamp", () => { expect(effects.map((e) => e.nodeId)).toEqual([ "debt_growth_rate", "fed_rate", + "federal_debt", "gdp_growth", "household_debt", "inflation", diff --git a/src/repo/connectivity.test.ts b/src/repo/connectivity.test.ts index 90549b8..3aff782 100644 --- a/src/repo/connectivity.test.ts +++ b/src/repo/connectivity.test.ts @@ -14,12 +14,10 @@ const hasOutgoing = new Set(graph.edges.map((edge) => edge.from)); const hasIncoming = new Set(graph.edges.map((edge) => edge.to)); const NO_OUTGOING = [ - "debt_growth_rate", "food_insecurity", "hate_crimes", "homelessness", "institutional_confidence", - "net_interest", "payrolls_headline", "savings_rate", ]; @@ -35,6 +33,11 @@ describe("graph connectivity", () => { ]); }); + it("records a reason on exactly the indicators with no outgoing relationship", () => { + const withReason = graph.nodes.filter((node) => node.terminal !== null).map((node) => node.id); + expect(withReason.sort()).toEqual(NO_OUTGOING); + }); + it("documents every no-outgoing indicator in the README by id", () => { const readme = readFileSync("README.md", "utf8"); const section = /## What moves what([\s\S]*?)(?=\n## )/.exec(readme)?.[1] ?? ""; diff --git a/src/ui/blocks/BlockApp.test.tsx b/src/ui/blocks/BlockApp.test.tsx index d354e64..40da40d 100644 --- a/src/ui/blocks/BlockApp.test.tsx +++ b/src/ui/blocks/BlockApp.test.tsx @@ -299,8 +299,10 @@ describe("URL synchronization under rapid input", () => { }); describe("review regressions", () => { + // debt_growth_rate and gdp_growth stay unset: both reach net_interest through the federal debt + // loop, and setting them would move its exact total away from 1. const EXACT_ONE_SCENARIO = - "debt_growth_rate:-70,fed_rate:80,federal_debt:40,food_insecurity:70,gdp_growth:-60,hate_crimes:40," + + "fed_rate:80,federal_debt:40,food_insecurity:70,hate_crimes:40," + "homelessness:-30,household_debt:60,inflation:80,institutional_confidence:-20,media_trust:10," + "median_household_income:-100,mortgage_rate:50,net_interest:10,payrolls_headline:-50," + "poverty_rate:-60,productivity:70,savings_rate:90,worker_bargaining_power:20"; diff --git a/src/ui/present.test.ts b/src/ui/present.test.ts index a5bc397..cc459d7 100644 --- a/src/ui/present.test.ts +++ b/src/ui/present.test.ts @@ -23,6 +23,7 @@ function node(overrides: Partial = {}): GraphNode { sourceDetail: "Example", retrievedDate: "2026-09-19", description: "A sample node.", + terminal: null, ...overrides, }; } From 180735b3d54b6e3633af30a1a2446d96bb623060 Mon Sep 17 00:00:00 2001 From: Thor Thor <37605399+codethor0@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:58:40 +0000 Subject: [PATCH 2/3] feat: separate real baselines from model response and add analysis views Model transparency release, part 2. - Each tile shows its stored real-world baseline under the title, apart from the normalized MODEL RESPONSE, which is no longer called a combined result. - Why & source lists paths strongest first with the direction and the kind, citation status and horizon of every step, labels every relationship the same way, and states why a dead end drives nothing. - Sensitivity: every scenario is rerun under nine nearby settings (decay 0.5, 0.7, 0.9; up to 2, 3, 4 relationships) and each moved tile says whether its direction holds, depends on the path limit, or flips. Same engine, other parameters (src/model/analysis.ts). - Category filter. Hidden tiles still take part in the calculation. - Cited relationships only: the same engine on empirical edges alone. None exist yet, and the board says that is a gap in citations, not evidence of no effect. Not part of Share links, which carry inputs only; the page says so. - Scenario A: saved in memory for the tab only, compared tile by tile, never written to the address bar; Reset and history leave it alone. No new dependency. Built JS 269.9 kB to 274.8 kB. Browser suite covers the new tools at 1440, 390 and 320 px and runs axe with them active. --- README.md | 41 +++++- TESTING.md | 6 +- scripts/block-e2e.mjs | 63 ++++++++++ src/model/analysis.test.ts | 198 +++++++++++++++++++++++++++++ src/model/analysis.ts | 107 ++++++++++++++++ src/ui/blocks/BlockApp.test.tsx | 113 ++++++++++++++++- src/ui/blocks/BlockApp.tsx | 6 +- src/ui/blocks/board.css | 93 +++++++++++++- src/ui/blocks/board.ts | 216 ++++++++++++++++++++++++++++---- src/ui/blocks/view.test.ts | 42 +++++++ src/ui/blocks/view.ts | 38 ++++++ 11 files changed, 888 insertions(+), 35 deletions(-) create mode 100644 src/model/analysis.test.ts create mode 100644 src/model/analysis.ts diff --git a/README.md b/README.md index 79b63cb..e5db96d 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,29 @@ squares. Move one input and watch the tiles it is connected to gain or lose squa - Every tile starts at **50 out of 100** in neutral grey, labeled **Not adjusted**. The 50 is a display convention that leaves room to move both up and down. It is not a health rating. - Set **your input** for a tile with its slider, or click a square. Inputs move in steps of five. -- The squares and the **Combined response (automatic)** bar show the tile's **combined result**: - your input plus the effects that reach it from other inputs over paths of up to three - relationships. The bar is continuous; the squares are rounded. Results never feed back in as - inputs. +- The squares and the **Model response** bar show the tile's normalized model response: your + input plus the effects that reach it from other inputs over paths of up to three relationships. + The bar is continuous; the squares are rounded. Results never feed back in as inputs. +- The line under each title is the **real-world baseline**, the stored figure with its period. It + never changes with your input. The model response is a position on an editorial 0 to 100 scale, + not a predicted value of the real indicator. - Color shows level: grey at 50, blue below, orange above. Blue and orange stay distinguishable with the common forms of color blindness. Color does not show worse to better: a higher federal debt is not a better one. - The page follows the system light or dark setting. -- Open **Why & source** on a tile for the paths behind its result, the relationships it belongs to, - and the stored baseline with its source. A stored baseline does not change with your input. +- Open **Why & source** on a tile for the paths behind its result, strongest first, with the kind of + every step; the relationships it belongs to, each labeled by kind, citation status and horizon; + why it drives nothing, if it is a dead end; and the stored baseline with its source. +- Under a moved tile, one line says how its direction holds up when the two editorial settings + change. See [Sensitivity](#sensitivity). +- **Show** filters the tiles by category. Hidden tiles still take part in the calculation. +- **Cited relationships only** reruns the same arithmetic on the relationships that carry a + citation. None do yet, so in this view no input reaches another tile, and each tile says that + this is a gap in citations, not evidence of no effect. The setting is not part of a Share link: + links carry inputs only and always open with every relationship. +- **Save as scenario A** keeps the current results in this tab. Change inputs and each tile shows + scenario A beside the current position. Reset and history navigation change the inputs, not A; + **Clear A** removes it. Nothing is stored in the address bar, the browser or anywhere else. - **Reset** returns every tile to 50. **Share** shows a link that reproduces your inputs, and **Copy link** copies it. - Tiles pulse briefly, at most once a second, when their result changes. The checkbox in the header @@ -47,6 +60,22 @@ squares. Floating-point noise, such as effects that cancel to 1e-17 instead of 0 for display only; the model result itself is not changed. A real response smaller than one square still shows its signed score, in scientific notation if needed, rather than a false zero. +## Sensitivity + +The decay of 0.7 and the limit of three relationships are editorial choices. To show how much they +matter, the board reruns every scenario under nine nearby settings: decay 0.5, 0.7 and 0.9, with +paths of up to 2, 3 and 4 relationships. The default is one of the nine. Each moved tile then says +one of three things: + +- **Same direction under all 9 tested settings**, with the range of positions if they differ. +- **Moves under some tested settings only**: the effect needs a longer path than some settings + allow. Poverty's response to the federal funds rate is an example: it needs four relationships. +- **Direction depends on the settings**: routes of different lengths pull opposite ways, and the + settings decide which wins. + +This is an explanation of the model, not a second prediction. The same engine produces every +number; [src/model/analysis.ts](src/model/analysis.ts) only changes its two parameters. + ## What moves what The relationships are one-way. An input moves the indicators downstream of it and never the ones diff --git a/TESTING.md b/TESTING.md index b4d2ca4..600a5dc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -59,9 +59,13 @@ Requires Google Chrome or Chromium. Builds the production bundle and drives the the accessible meter value, and squares. - Multiple inputs together, source links, Share links, reload, Reset, browser history, keyboard, pointer and touch input, and reduced motion. +- The category filter, cited-relationships-only mode and scenario A at 1440, 390 and 320 pixel + widths: hidden tiles, no modeled edge in cited-only mode, the comparison text, no horizontal + overflow, and an unchanged address bar. - A deliberately broken tile, which must make the suite fail. - An axe-core scan for WCAG 2.2 A and AA rules in light and dark mode, at neutral and with a - lever raised, with every "Why & source" panel open. A button with no accessible name is added + lever raised, with every "Why & source" panel open, and once more with the filter, cited-only + mode and scenario A all active. A button with no accessible name is added at the end as a control and must be reported. The page's build identifier must match the source being tested. diff --git a/scripts/block-e2e.mjs b/scripts/block-e2e.mjs index 3a8b0da..dcc6b9f 100644 --- a/scripts/block-e2e.mjs +++ b/scripts/block-e2e.mjs @@ -222,6 +222,68 @@ async function accessibilityScan() { await cdp.send("Emulation.setEmulatedMedia", { features: [] }); report.assertions.push("axe negative control detected"); } +async function analysisTools() { + // Category filter, cited-only mode and scenario A change the view, never the address bar. + for (const [width, height] of [ + [1440, 1000], + [390, 844], + [320, 740], + ]) { + await session.viewport(width, height); + await session.navigate("/?l=fed_rate:100"); + await waitForScenario([["fed_rate", 1]], `tools ${width}`); + const query = (await snapshot()).query; + const view = async () => + await cdp.evaluate(`(() => ({ + hidden: [...document.querySelectorAll('[data-indicator]')].filter((t) => t.hidden).map((t) => t.dataset.indicator), + mortgage: document.querySelector('[data-indicator="mortgage_rate"]').dataset.state, + compare: document.querySelector('[data-indicator="mortgage_rate"] .bb-compare'), + compareText: document.querySelector('[data-indicator="mortgage_rate"] .bb-compare').textContent, + compareHidden: document.querySelector('[data-indicator="mortgage_rate"] .bb-compare').hidden, + scroll: document.documentElement.scrollWidth, + query: location.search, + }))()`); + await cdp.click('input[name="bb-category"][value="fiscal"]'); + let state = await view(); + requireCheck( + state.hidden.length === 17 && !state.hidden.includes("federal_debt"), + `category filter ${width}: ${JSON.stringify(state.hidden)}`, + ); + requireCheck(state.scroll <= width + 1, `category filter ${width}: horizontal overflow`); + requireCheck(state.query === query, `category filter ${width} changed the address`); + await cdp.click('input[name="bb-category"][value="all"]'); + await cdp.click('[data-action="evidence-only"]'); + state = await view(); + requireCheck(state.mortgage === "idle", `evidence-only ${width} used a modeled edge`); + requireCheck(state.query === query, `evidence-only ${width} changed the address`); + await cdp.click('[data-action="evidence-only"]'); + requireCheck((await view()).mortgage === "up", `evidence-only ${width} did not switch back`); + await cdp.click('[data-action="save-a"]'); + await cdp.click("#block-input-fed_rate"); + await cdp.key("End"); + await cdp.key("Home"); + state = await view(); + requireCheck( + !state.compareHidden && state.compareText === "Scenario A 87.5, now 12.5 (-75).", + `scenario A ${width}: ${state.compareText}`, + ); + requireCheck(state.scroll <= width + 1, `scenario A ${width}: horizontal overflow`); + await cdp.click('[data-action="clear-a"]'); + requireCheck((await view()).compareHidden, `scenario A ${width} did not clear`); + } + await session.viewport(1440, 1000); + await session.navigate("/?l=fed_rate:100"); + await cdp.evaluate("document.querySelectorAll('.bb-details').forEach(d=>{d.open=true})"); + await cdp.click('[data-action="save-a"]'); + await cdp.click('input[name="bb-category"][value="economic"]'); + await cdp.click('[data-action="evidence-only"]'); + const violations = await axeViolations(); + requireCheck( + violations.length === 0, + `axe with view tools active: ${JSON.stringify(violations)}`, + ); + report.assertions.push("category filter, cited-only mode and scenario A at 1440, 390 and 320"); +} async function run() { for (const [width, height] of [ [1440, 1000], @@ -624,6 +686,7 @@ async function run() { sources.every((source) => source.width >= 24 && source.height >= 24), "source link pointer targets are below 24px", ); + await analysisTools(); await accessibilityScan(); const failures = session.failures; requireCheck( diff --git a/src/model/analysis.test.ts b/src/model/analysis.test.ts new file mode 100644 index 0000000..5cdd261 --- /dev/null +++ b/src/model/analysis.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for analysis.ts, written before it exists. Every expected number is worked by hand. + */ +import { describe, expect, it } from "vitest"; +import graphJson from "../data/graph.json"; +import type { PropagationEdge, PropagationGraph } from "../lib/propagation"; +import { parseGraph } from "../lib/validate"; +import { compareDeltas, evidenceGraph, SENSITIVITY_SETTINGS, sensitivity } from "./analysis"; +import { combineEffects } from "./effects"; + +function edge(from: string, to: string, direction: number, strength: number): PropagationEdge { + return { id: `${from}__${to}`, from, to, direction, strength }; +} + +describe("evidenceGraph", () => { + const graph = { + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + edges: [ + { ...edge("a", "b", 1, 0.5), confidence: "empirical" as const }, + { ...edge("b", "c", 1, 0.5), confidence: "modeled" as const }, + ], + }; + + it("keeps every node and only the empirical edges", () => { + const kept = evidenceGraph(graph); + expect(kept.nodes).toEqual(graph.nodes); + expect(kept.edges.map((e) => e.id)).toEqual(["a__b"]); + }); + + it("never lets a modeled edge carry an effect", () => { + const effects = combineEffects(evidenceGraph(graph), new Map([["a", 1]])); + expect(effects.map((e) => e.nodeId)).toEqual(["a", "b"]); + }); + + it("does not change the graph it was given", () => { + evidenceGraph(graph); + expect(graph.edges).toHaveLength(2); + }); + + it("leaves the real graph with no relationship today, because no edge is cited yet", () => { + const real = parseGraph(graphJson); + expect(evidenceGraph(real).edges).toEqual([]); + expect(evidenceGraph(real).nodes).toHaveLength(real.nodes.length); + }); +}); + +describe("SENSITIVITY_SETTINGS", () => { + it("is the nine combinations of decay 0.5, 0.7, 0.9 and 2, 3, 4 relationships, default included", () => { + expect(SENSITIVITY_SETTINGS).toHaveLength(9); + const keys = SENSITIVITY_SETTINGS.map((p) => `${String(p.maxHops)}:${String(p.decay)}`); + expect(new Set(keys).size).toBe(9); + for (const hops of [2, 3, 4]) + for (const decay of [0.5, 0.7, 0.9]) + expect(keys).toContain(`${String(hops)}:${String(decay)}`); + }); +}); + +describe("sensitivity", () => { + /* + * a -> b +0.5, b -> c +1, c -> d +1, a -> e -0.25, c -> e +1; f has no edges. Lever a = 1. + * a: own 1 in every setting robust, 1 to 1 + * b: 0.5 in every setting robust, 0.5 to 0.5 + * c: 0.5 * decay, reached from 2 relationships robust, 0.25 to 0.45 + * d: 0.5 * decay^2, needs 3 relationships; 0 at 2 partial, 0 to 0.405 + * e: -0.25 at 2 relationships; -0.25 + 0.5 * decay^2 at 3 or 4: + * decay 0.5 -> -0.125, 0.7 -> -0.005, 0.9 -> +0.155 direction, -0.25 to 0.155 + * f: never reached none, 0 to 0 + */ + const graph: PropagationGraph = { + nodes: ["a", "b", "c", "d", "e", "f"].map((id) => ({ id })), + edges: [ + edge("a", "b", 1, 0.5), + edge("b", "c", 1, 1), + edge("c", "d", 1, 1), + edge("a", "e", -1, 0.25), + edge("c", "e", 1, 1), + ], + }; + const result = sensitivity(graph, new Map([["a", 1]])); + + it.each([ + ["a", "robust", 1, 1], + ["b", "robust", 0.5, 0.5], + ["c", "robust", 0.25, 0.45], + ["d", "partial", 0, 0.405], + ["e", "direction", -0.25, 0.155], + ["f", "none", 0, 0], + ])("%s is %s, from %s to %s", (id, robustness, min, max) => { + const entry = result.get(id); + expect(entry?.robustness).toBe(robustness); + expect(entry?.min).toBeCloseTo(min, 12); + expect(entry?.max).toBeCloseTo(max, 12); + }); + + it("lists every node of the graph, in node id order", () => { + expect([...result.keys()]).toEqual(["a", "b", "c", "d", "e", "f"]); + }); + + it("is sign symmetric: a negated lever negates and swaps the range", () => { + const negated = sensitivity(graph, new Map([["a", -1]])); + for (const [id, entry] of result) { + const other = negated.get(id); + expect(other?.robustness).toBe(entry.robustness); + expect(other?.min).toBeCloseTo(-entry.max, 12); + expect(other?.max).toBeCloseTo(-entry.min, 12); + } + }); + + it("uses clamped scores, so a total beyond 1 counts as 1", () => { + const g: PropagationGraph = { + nodes: [{ id: "x" }, { id: "y" }], + edges: [edge("x", "y", 1, 1)], + }; + const both = sensitivity( + g, + new Map([ + ["x", 1], + ["y", 1], + ]), + ); + expect(both.get("y")).toEqual({ nodeId: "y", robustness: "robust", min: 1, max: 1 }); + }); + + it("treats floating-point cancellation noise as zero", () => { + // y gets 0.1 + 0.2 from two routes and -0.3 from its own lever: 5.55e-17, not a direction. + const g: PropagationGraph = { + nodes: [{ id: "p" }, { id: "q" }, { id: "y" }], + edges: [edge("p", "y", 1, 0.25), edge("q", "y", 1, 0.25)], + }; + const noisy = sensitivity( + g, + new Map([ + ["p", 0.4], + ["q", 0.8], + ["y", -0.3], + ]), + [{ maxHops: 1, decay: 1 }], + ); + expect(noisy.get("y")?.robustness).toBe("none"); + expect(noisy.get("y")?.min).toBe(0); + }); + + it("gives the same answer on every call and for any lever order", () => { + const a = sensitivity( + graph, + new Map([ + ["a", 0.5], + ["c", -0.25], + ]), + ); + const b = sensitivity( + graph, + new Map([ + ["c", -0.25], + ["a", 0.5], + ]), + ); + expect([...b]).toEqual([...a]); + }); + + it("reports the real graph's fed_rate effect on poverty_rate as partial: it needs 4 relationships", () => { + const real = parseGraph(graphJson); + const entry = sensitivity(real, new Map([["fed_rate", 1]])).get("poverty_rate"); + expect(entry?.robustness).toBe("partial"); + // fed -> inflation -> real earnings -> median income -> poverty at 4 relationships: + // -0.5 * -0.75 * 0.5 * -0.5 = -0.09375, times decay^3: 0.125 * -0.09375 at 0.5 ... 0.729 at 0.9. + expect(entry?.min).toBeCloseTo(-0.09375 * 0.729, 12); + expect(entry?.max).toBe(0); + }); +}); + +describe("compareDeltas", () => { + it("reports each node's score in A and now, the change, and how many changed", () => { + const result = compareDeltas( + ["x", "y", "z"], + new Map([ + ["x", 0.5], + ["y", 0.25], + ]), + new Map([ + ["x", 0.5], + ["z", -0.5], + ]), + ); + expect(result.entries).toEqual([ + { nodeId: "x", a: 0.5, b: 0.5, change: 0 }, + { nodeId: "y", a: 0.25, b: 0, change: -0.25 }, + { nodeId: "z", a: 0, b: -0.5, change: -0.5 }, + ]); + expect(result.changed).toBe(2); + }); + + it("ignores a change smaller than floating-point noise", () => { + const result = compareDeltas(["x"], new Map([["x", 0.3]]), new Map([["x", 0.1 + 0.2]])); + expect(result.entries[0]?.change).toBe(0); + expect(result.changed).toBe(0); + }); +}); diff --git a/src/model/analysis.ts b/src/model/analysis.ts new file mode 100644 index 0000000..adcff5b --- /dev/null +++ b/src/model/analysis.ts @@ -0,0 +1,107 @@ +/** + * Explanatory analysis on top of combineEffects. Nothing here is a second model: every number comes + * from the production engine, run on a filtered graph or with other stated parameters. + * + * evidenceGraph keeps only edges whose confidence is "empirical", so an evidence-only view can never + * use a modeled edge. + * + * sensitivity reruns a scenario under SENSITIVITY_SETTINGS, nine nearby choices of the two editorial + * parameters (decay 0.5, 0.7 and 0.9; paths of up to 2, 3 and 4 relationships; the default 0.7 and 3 + * is one of them), and classifies each node's clamped score: + * + * none zero under every setting + * robust the same sign under every setting + * partial one sign where it moves, but zero under some settings, usually the shorter path limit + * direction up under some settings and down under others + * + * A score within NOISE of zero counts as zero, so floating-point cancellation is never a direction. + */ +import type { PropagationEdge, PropagationGraph, PropagationParams } from "../lib/propagation"; +import { combineEffects } from "./effects"; + +export const SENSITIVITY_SETTINGS: readonly PropagationParams[] = [2, 3, 4].flatMap((maxHops) => + [0.5, 0.7, 0.9].map((decay) => ({ maxHops, decay })), +); + +/** Scores closer to zero than this are floating-point noise. */ +const NOISE = 1e-12; + +export type Robustness = "none" | "robust" | "partial" | "direction"; + +export interface NodeSensitivity { + readonly nodeId: string; + readonly robustness: Robustness; + /** The lowest and highest clamped score over the settings. */ + readonly min: number; + readonly max: number; +} + +function clean(value: number): number { + return Math.abs(value) < NOISE ? 0 : value; +} + +/** The same nodes, with only the edges that carry a citation. The input is not changed. */ +export function evidenceGraph< + N, + E extends PropagationEdge & { readonly confidence: string }, +>(graph: { + readonly nodes: readonly N[]; + readonly edges: readonly E[]; +}): { nodes: N[]; edges: E[] } { + return { + nodes: [...graph.nodes], + edges: graph.edges.filter((edge) => edge.confidence === "empirical"), + }; +} + +export function sensitivity( + graph: PropagationGraph, + levers: ReadonlyMap, + settings: readonly PropagationParams[] = SENSITIVITY_SETTINGS, +): Map { + const scores = new Map( + [...graph.nodes].sort((a, b) => (a.id < b.id ? -1 : 1)).map((node) => [node.id, []]), + ); + for (const params of settings) { + const deltas = new Map( + combineEffects(graph, levers, params).map((effect) => [effect.nodeId, effect.delta]), + ); + for (const [id, list] of scores) list.push(clean(deltas.get(id) ?? 0)); + } + const result = new Map(); + for (const [nodeId, list] of scores) { + const up = list.some((score) => score > 0); + const down = list.some((score) => score < 0); + const zero = list.some((score) => score === 0); + const robustness: Robustness = + up && down ? "direction" : !up && !down ? "none" : zero ? "partial" : "robust"; + result.set(nodeId, { + nodeId, + robustness, + min: Math.min(...list) + 0, + max: Math.max(...list) + 0, + }); + } + return result; +} + +export interface ComparedNode { + readonly nodeId: string; + readonly a: number; + readonly b: number; + readonly change: number; +} + +/** Scenario A against B (usually the current inputs), node by node, in the order given. */ +export function compareDeltas( + nodeIds: readonly string[], + a: ReadonlyMap, + b: ReadonlyMap, +): { entries: ComparedNode[]; changed: number } { + const entries = nodeIds.map((nodeId) => { + const before = a.get(nodeId) ?? 0; + const after = b.get(nodeId) ?? 0; + return { nodeId, a: before, b: after, change: clean(after - before) }; + }); + return { entries, changed: entries.filter((entry) => entry.change !== 0).length }; +} diff --git a/src/ui/blocks/BlockApp.test.tsx b/src/ui/blocks/BlockApp.test.tsx index 40da40d..a1e9d9f 100644 --- a/src/ui/blocks/BlockApp.test.tsx +++ b/src/ui/blocks/BlockApp.test.tsx @@ -104,7 +104,7 @@ describe("active Block Board", () => { ).toHaveLength(20); expect(screen.getAllByRole("meter")).toHaveLength(20); const earnings = screen.getByRole("meter", { - name: "Real hourly earnings combined modeled response", + name: "Real hourly earnings normalized model response", }); expect(earnings.getAttribute("aria-valuenow")).toBe("50"); change("productivity", "100"); @@ -420,3 +420,114 @@ describe("share link copy", () => { expect(document.activeElement).toBe(shareField()); }); }); + +describe("model transparency and analysis", () => { + function choose(label: string): void { + fireEvent.click(screen.getByRole("radio", { name: label })); + } + function evidenceOnly(): HTMLInputElement { + const box = screen.getByRole("checkbox", { name: "Cited relationships only" }); + if (!(box instanceof HTMLInputElement)) throw new Error("Missing evidence toggle"); + return box; + } + + it("shows the real-world baseline apart from the normalized model response", () => { + render(); + const baseline = (id: string) => tile(id).querySelector(".bb-baseline")?.textContent; + expect(baseline("inflation")).toBe("Real-world baseline: 3.4 % YoY (2026-08)"); + expect(baseline("net_interest")).toBe("Real-world projection: 3.3 % of GDP (FY2026)"); + expect(baseline("debt_growth_rate")).toBe("Real-world baseline: not stored yet"); + expect(baseline("worker_bargaining_power")).toBe( + "Real-world value: none (abstract 0 to 100 lever)", + ); + expect(tile("inflation").querySelector(".bb-micro")?.textContent).toBe("MODEL RESPONSE"); + }); + + it("filters tiles by category without changing the calculation or the address", () => { + window.history.replaceState(null, "", "/?l=fed_rate:100"); + render(); + const before = tile("median_household_income").dataset.position; + const search = window.location.search; + choose("fiscal"); + expect(tile("federal_debt").hidden).toBe(false); + expect(tile("inflation").hidden).toBe(true); + expect(tile("median_household_income").dataset.position).toBe(before); + expect(screen.getByText(/Showing 3 of 20 indicators/)).toBeTruthy(); + expect(window.location.search).toBe(search); + choose("All indicators"); + expect(tile("inflation").hidden).toBe(false); + expect(screen.queryByText(/Showing \d+ of 20 indicators/)).toBeNull(); + }); + + it("uses only cited relationships in evidence-only mode and says why nothing moves", () => { + window.history.replaceState(null, "", "/?l=fed_rate:100"); + render(); + const search = window.location.search; + expect(tile("mortgage_rate").dataset.state).toBe("up"); + fireEvent.click(evidenceOnly()); + expect(tile("mortgage_rate").dataset.state).toBe("idle"); + expect(tile("fed_rate").dataset.position).toBe("100"); + expect(tile("mortgage_rate").querySelector(".bb-why")?.textContent).toBe( + "No cited relationship reaches this tile. That is a gap in citations, not evidence of no effect.", + ); + expect(screen.getByText(/0 of 22 relationships have a citation/)).toBeTruthy(); + expect(tile("fed_rate").querySelector(".bb-feeds")?.textContent).toBe( + "Drives nothing through cited relationships; its links are modeled.", + ); + expect(window.location.search).toBe(search); + fireEvent.click(evidenceOnly()); + expect(tile("mortgage_rate").dataset.state).toBe("up"); + }); + + it("says how each moved tile holds up across the tested settings", () => { + window.history.replaceState(null, "", "/?l=fed_rate:100"); + render(); + const robust = (id: string) => tile(id).querySelector(".bb-robustness")?.textContent; + expect(robust("mortgage_rate")).toBe("Same direction under all 9 tested settings."); + // fed -> inflation -> real earnings -> median income needs 3 relationships: zero at 2. + expect(robust("median_household_income")).toMatch(/^Moves under some tested settings only/); + expect(robust("poverty_rate")).toBe(""); + }); + + it("compares with a saved scenario A held in memory only", () => { + window.history.replaceState(null, "", "/?l=fed_rate:100"); + render(); + const compare = (id: string) => tile(id).querySelector(".bb-compare"); + expect(compare("mortgage_rate")?.hidden).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Save as scenario A" })); + change("fed_rate", "50"); + expect(compare("mortgage_rate")?.textContent).toBe("Scenario A 87.5, now 50 (-37.5)."); + expect(screen.getByText(/Comparing with scenario A: \d+ of 20 tiles differ/)).toBeTruthy(); + expect(window.location.search).not.toContain("A"); + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + expect(compare("mortgage_rate")?.hidden).toBe(false); + fireEvent.click(screen.getByRole("button", { name: "Clear A" })); + expect(compare("mortgage_rate")?.hidden).toBe(true); + expect(screen.queryByRole("button", { name: "Clear A" })).toBeNull(); + }); + + it("lists paths strongest first with the kind of every step", () => { + window.history.replaceState(null, "", "/?l=fed_rate:100"); + render(); + const rows = [...tile("savings_rate").querySelectorAll(".bb-paths li")]; + // fed direct 0.25, then via inflation and earnings 0.0459375, then via mortgage and debt 0.02296875. + expect(rows.map((row) => row.firstElementChild?.textContent)).toEqual([ + "Federal funds rate > Personal saving rate: +0.25", + "Federal funds rate > Inflation > Real hourly earnings > Personal saving rate: +0.0459", + "Federal funds rate > 30-year mortgage rate > Household debt > Personal saving rate: +0.023", + ]); + expect(rows[1]?.querySelector(".bb-steps")?.textContent).toBe( + "Each step: down (Causal | modeled | long run); down (Accounting | modeled | short run); up (Causal | modeled | short run)", + ); + }); + + it("labels relationships and states why a dead end drives nothing", () => { + render(); + const badges = [...tile("fed_rate").querySelectorAll(".bb-badge")].map((b) => b.textContent); + expect(badges).toContain("Causal | modeled | short run"); + expect(tile("savings_rate").querySelector(".bb-terminal")?.textContent).toMatch( + /^Drives nothing: /, + ); + expect(tile("hate_crimes").textContent).toContain("Isolated on purpose."); + }); +}); diff --git a/src/ui/blocks/BlockApp.tsx b/src/ui/blocks/BlockApp.tsx index 5e31847..4db606c 100644 --- a/src/ui/blocks/BlockApp.tsx +++ b/src/ui/blocks/BlockApp.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import graphJson from "../../data/graph.json"; import { parseGraph } from "../../lib/validate"; +import { evidenceGraph, SENSITIVITY_SETTINGS, sensitivity } from "../../model/analysis"; import { combineEffects } from "../../model/effects"; import { dataStamp } from "../../scenario/stamp"; import { decodeScenario } from "../../scenario/url"; @@ -18,11 +19,14 @@ import "./board.css"; const GRAPH = parseGraph(graphJson); const STAMP = dataStamp(GRAPH); +const EVIDENCE = evidenceGraph(GRAPH); const LABELS = new Map(GRAPH.nodes.map((node) => [node.id, node.label])); const SERVICES: BoardServices = { build: BLOCK_BUILD, stamp: STAMP, - calculate: (levers) => combineEffects(GRAPH, levers), + calculate: (levers, evidenceOnly) => combineEffects(evidenceOnly ? EVIDENCE : GRAPH, levers), + sensitivity: (levers, evidenceOnly) => sensitivity(evidenceOnly ? EVIDENCE : GRAPH, levers), + sensitivitySettings: SENSITIVITY_SETTINGS.length, baseline: formatBaseline, location: (levers) => scenarioLocation(window.location.pathname, window.location.hash, levers, STAMP), diff --git a/src/ui/blocks/board.css b/src/ui/blocks/board.css index c2a9c31..1168c3e 100644 --- a/src/ui/blocks/board.css +++ b/src/ui/blocks/board.css @@ -239,7 +239,7 @@ } .bb-board { display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; align-items: stretch; } @@ -417,6 +417,7 @@ } .bb-paths { color: var(--bb-text-3); + padding-left: 18px; } .bb-source { display: inline-flex; @@ -555,6 +556,96 @@ .bb-pulse { animation: bb-response-pulse 700ms ease-in-out 1; } +.bb-tools { + max-width: 1440px; + margin: 0 auto; + padding: 10px 0 12px; + border-bottom: 1px solid var(--bb-rule); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 20px; + color: var(--bb-text-2); + font-size: 12px; +} +.bb-filter { + border: 0; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 12px; +} +.bb-filter legend { + float: left; + padding: 0; + margin-right: 4px; + font-weight: 700; + line-height: 44px; +} +.bb-choice { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 44px; + text-transform: capitalize; + cursor: pointer; +} +.bb-choice input { + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--bb-text-3); +} +.bb-compare-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.bb-tool-notes { + flex-basis: 100%; +} +.bb-tool-notes p { + margin: 0; +} +.bb-tool-notes p:empty { + display: none; +} +.bb-baseline { + margin: -8px 0 12px; + font-size: 12px; + font-weight: 600; + color: var(--bb-text-2); + overflow-wrap: anywhere; +} +.bb-robustness, +.bb-compare { + font-size: 11px; + color: var(--bb-text-2); + line-height: 1.5; + margin: 0 0 6px; + overflow-wrap: anywhere; +} +.bb-robustness:empty { + display: none; +} +.bb-badge { + display: inline-block; + margin-right: 4px; + padding: 0 6px; + border: 1px solid var(--bb-border-control); + border-radius: 4px; + font-weight: 700; + color: var(--bb-text-2); +} +.bb-paths li span { + display: block; +} +.bb-steps { + color: var(--bb-text-3); + overflow-wrap: anywhere; +} @media (max-width: 620px) { .bb-app { padding: 20px 14px; diff --git a/src/ui/blocks/board.ts b/src/ui/blocks/board.ts index e7a2f7e..0027219 100644 --- a/src/ui/blocks/board.ts +++ b/src/ui/blocks/board.ts @@ -1,13 +1,19 @@ -import type { Graph, GraphNode } from "../../lib/schema"; +import { CATEGORIES } from "../../lib/schema"; +import type { Category, Graph, GraphNode } from "../../lib/schema"; import { DEFAULT_PARAMS } from "../../lib/propagation"; import { createUrlSync } from "./url-sync"; +import { compareDeltas } from "../../model/analysis"; +import type { NodeSensitivity } from "../../model/analysis"; import type { ScenarioEffect } from "../../model/effects"; import { blockPosition, + compareText, fillColor, inputLever, levelText, movedText, + relationshipBadge, + sensitivityText, signed, summaryText, } from "./view"; @@ -17,7 +23,17 @@ export interface LoadedBoard { readonly warnings: readonly string[]; } export interface BoardServices { - readonly calculate: (levers: ReadonlyMap) => readonly ScenarioEffect[]; + /** evidenceOnly runs the same engine on the cited relationships alone. */ + readonly calculate: ( + levers: ReadonlyMap, + evidenceOnly: boolean, + ) => readonly ScenarioEffect[]; + /** The same scenario under the tested settings; see src/model/analysis.ts. */ + readonly sensitivity: ( + levers: ReadonlyMap, + evidenceOnly: boolean, + ) => ReadonlyMap; + readonly sensitivitySettings: number; readonly load: (search: string) => LoadedBoard; readonly location: (levers: ReadonlyMap) => string; readonly baseline: (node: GraphNode) => string; @@ -57,6 +73,12 @@ interface Tile { readonly input: HTMLInputElement; readonly inputValue: HTMLElement; readonly why: HTMLElement; + readonly robustness: HTMLElement; + readonly compare: HTMLElement; + /** The "Directly drives" note, and its text for every relationship and for cited ones only. */ + readonly feeds: HTMLElement | null; + readonly feedsAll: string; + readonly feedsCited: string; readonly numbers: HTMLElement; readonly paths: HTMLElement; readonly clear: HTMLButtonElement; @@ -75,6 +97,12 @@ export function mountBlockBoard( let warnings = [...initial.warnings]; let lastAction = "Not adjusted. Move an input to begin."; let destroyed = false; + let evidenceOnly = false; + let shownCategory: Category | "all" = "all"; + /** Scenario A: display scores saved in memory only, never in the address bar. */ + let saved: { deltas: Map; evidenceOnly: boolean } | null = null; + let currentDeltas = new Map(); + const citedCount = graph.edges.filter((edge) => edge.confidence === "empirical").length; const labels = new Map(graph.nodes.map((node) => [node.id, node.label])); const edges = new Map(graph.edges.map((edge) => [edge.id, edge])); const container = element("div", "bb-app"); @@ -183,6 +211,58 @@ export function mountBlockBoard( ), scale, ); + const tools = element("section", "bb-tools"); + tools.setAttribute("aria-label", "View options"); + const filter = element("fieldset", "bb-filter"); + filter.append(element("legend", "", "Show")); + for (const option of ["all", ...CATEGORIES] as const) { + const label = element("label", "bb-choice"); + const radio = element("input"); + radio.type = "radio"; + radio.name = "bb-category"; + radio.value = option; + radio.checked = option === "all"; + radio.addEventListener("change", () => { + shownCategory = option; + update(); + }); + label.append(radio, document.createTextNode(option === "all" ? "All indicators" : option)); + filter.append(label); + } + const evidenceLabel = element("label", "bb-choice"); + const evidence = element("input"); + evidence.type = "checkbox"; + evidence.dataset.action = "evidence-only"; + evidence.addEventListener("change", () => { + evidenceOnly = evidence.checked; + lastAction = evidenceOnly + ? "Showing cited relationships only." + : "Showing every reviewed relationship."; + update(); + }); + evidenceLabel.append(evidence, document.createTextNode("Cited relationships only")); + const saveA = button("Save as scenario A", () => { + saved = { deltas: currentDeltas, evidenceOnly }; + lastAction = "Saved the current results as scenario A."; + update(); + }); + saveA.dataset.action = "save-a"; + const clearA = button("Clear A", () => { + saved = null; + lastAction = "Cleared scenario A."; + update(); + saveA.focus(); + }); + clearA.dataset.action = "clear-a"; + const compareGroup = element("div", "bb-compare-actions"); + compareGroup.append(saveA, clearA); + const toolNotes = element("div", "bb-tool-notes"); + const filterNote = element("p", ""); + const evidenceNote = element("p", ""); + const compareNote = element("p", ""); + toolNotes.append(filterNote, evidenceNote, compareNote); + tools.append(filter, evidenceLabel, compareGroup, toolNotes); + const info = element( "p", "bb-disclaimer", @@ -237,11 +317,22 @@ export function mountBlockBoard( const title = element("h2", "", node.label); title.id = `bb-title-${node.id}`; tileRoot.append(element("p", "bb-category", node.category), title); + tileRoot.append( + element( + "p", + "bb-baseline", + node.valueType === "index" + ? "Real-world value: none (abstract 0 to 100 lever)" + : node.baseline === null + ? "Real-world baseline: not stored yet" + : `Real-world ${node.valueType === "projected" ? "projection" : "baseline"}: ${services.baseline(node)}${node.asOf === null ? "" : ` (${node.asOf})`}`, + ), + ); const readout = element("div", "bb-readout"); const grid = element("div", "bb-grid"); grid.setAttribute("role", "img"); grid.title = - "Blocks show the combined result. Click a block to set your manual input, or use the slider below."; + "Blocks show the normalized model response, not the real-world value. Click a block to set your manual input, or use the slider below."; const squares: HTMLElement[] = []; for (let row = 9; row >= 0; row--) { for (let col = 1; col <= 10; col++) { @@ -263,7 +354,7 @@ export function mountBlockBoard( const status = element("p", "bb-state"); const change = element("p", "bb-change"); result.append( - element("p", "bb-micro", "COMBINED RESULT"), + element("p", "bb-micro", "MODEL RESPONSE"), value, element("span", "bb-denominator", " / 100"), status, @@ -271,11 +362,15 @@ export function mountBlockBoard( ); readout.append(grid, result); tileRoot.append(readout); - const responseCaption = element("p", "bb-response-caption", "Combined response (automatic)"); + const responseCaption = element( + "p", + "bb-response-caption", + "Model response, normalized 0 to 100 (automatic)", + ); const response = element("div", "bb-response-track"); response.dataset.autoResponse = node.id; response.setAttribute("role", "meter"); - response.setAttribute("aria-label", `${node.label} combined modeled response`); + response.setAttribute("aria-label", `${node.label} normalized model response`); response.setAttribute("aria-valuemin", "0"); response.setAttribute("aria-valuemax", "100"); const responseFill = element("span", "bb-response-fill"); @@ -316,6 +411,9 @@ export function mountBlockBoard( ); controlNote.id = `bb-control-note-${node.id}`; const why = element("p", "bb-why"); + const robustness = element("p", "bb-robustness"); + const compare = element("p", "bb-compare"); + compare.hidden = true; const clear = button( "Neutral", () => { @@ -325,7 +423,7 @@ export function mountBlockBoard( ); clear.setAttribute("aria-label", `Neutral: clear input for ${node.label}`); inputRow.append(clear); - tileRoot.append(inputRow, input, ticks, controlNote, why); + tileRoot.append(inputRow, input, ticks, controlNote, why, robustness, compare); // Static connectivity note: what this indicator can move directly, before any input is set. const drives = [ ...new Set( @@ -334,9 +432,23 @@ export function mountBlockBoard( .map((edge) => labels.get(edge.to) ?? edge.to), ), ]; + const citedDrives = [ + ...new Set( + graph.edges + .filter((edge) => edge.from === node.id && edge.confidence === "empirical") + .map((edge) => labels.get(edge.to) ?? edge.to), + ), + ]; tileRoot.dataset.drives = String(drives.length); + let feeds: HTMLElement | null = null; + const feedsAll = `Directly drives: ${drives.join(", ")}.`; + const feedsCited = + citedDrives.length > 0 + ? `Directly drives through cited relationships: ${citedDrives.join(", ")}.` + : "Drives nothing through cited relationships; its links are modeled."; if (drives.length > 0) { - tileRoot.append(element("p", "bb-feeds", `Directly drives: ${drives.join(", ")}.`)); + feeds = element("p", "bb-feeds", feedsAll); + tileRoot.append(feeds); } else if (graph.edges.some((edge) => edge.to === node.id)) { tileRoot.append( element( @@ -350,7 +462,8 @@ export function mountBlockBoard( const details = element("details", "bb-details"); details.append(element("summary", "", "Why & source")); const numbers = element("p", "bb-explanation"); - const paths = element("div", "bb-paths"); + const paths = element("ol", "bb-paths"); + paths.setAttribute("aria-label", `Paths into ${node.label}, strongest first`); details.append(numbers, paths); const metadata = element( "p", @@ -369,19 +482,20 @@ export function mountBlockBoard( details.append(element("p", "bb-source-detail", node.sourceDetail)); const connected = graph.edges.filter((edge) => edge.from === node.id || edge.to === node.id); if (connected.length === 0) - details.append( - element("p", "", "No modeled connections. This indicator is intentionally isolated."), - ); + details.append(element("p", "", `No modeled connections. ${node.terminal ?? ""}`.trim())); else { + if (node.terminal !== null) + details.append(element("p", "bb-terminal", `Drives nothing: ${node.terminal}`)); const list = element("ul"); for (const edge of connected) { - list.append( - element( - "li", - "", - `${labels.get(edge.from) ?? edge.from} > ${labels.get(edge.to) ?? edge.to}: ${edge.direction > 0 ? "positive" : "negative"}, weight ${String(edge.strength)}, ${edge.confidence}. ${edge.claim}`, + const item = element("li"); + item.append( + element("span", "bb-badge", relationshipBadge(edge)), + document.createTextNode( + ` ${labels.get(edge.from) ?? edge.from} > ${labels.get(edge.to) ?? edge.to}: ${edge.direction > 0 ? "positive" : "negative"}, weight ${String(edge.strength)}. ${edge.claim}`, ), ); + list.append(item); } details.append(list); } @@ -404,6 +518,11 @@ export function mountBlockBoard( input, inputValue, why, + robustness, + compare, + feeds, + feedsAll, + feedsCited, numbers, paths, clear, @@ -473,7 +592,7 @@ export function mountBlockBoard( const summary = element("section", "bb-summary"); summary.setAttribute("aria-label", "Board summary"); summary.append(info, counts); - container.append(header, shareBox, legend, summary, message, notice, board, footer); + container.append(header, shareBox, legend, tools, summary, message, notice, board, footer); root.replaceChildren(container); const linkNotice = element("p", "bb-notice"); @@ -512,8 +631,16 @@ export function mountBlockBoard( function update(): void { if (destroyed) return; - const effects = new Map(services.calculate(levers).map((effect) => [effect.nodeId, effect])); + const effects = new Map( + services.calculate(levers, evidenceOnly).map((effect) => [effect.nodeId, effect]), + ); + const robust = services.sensitivity(levers, evidenceOnly); + const active = evidenceOnly + ? graph.edges.filter((edge) => edge.confidence === "empirical") + : graph.edges; + const deltas = new Map(); let responding = 0; + let shown = 0; for (const tile of tiles) { const effect = effects.get(tile.node.id); const own = levers.get(tile.node.id) ?? 0; @@ -525,6 +652,9 @@ export function mountBlockBoard( delta === 0 && !(effect?.contributions.some((item) => item.value !== 0) ?? false); if (delta !== 0) responding++; + deltas.set(tile.node.id, delta); + tile.root.hidden = shownCategory !== "all" && tile.node.category !== shownCategory; + if (!tile.root.hidden) shown++; tile.root.dataset.delta = String(rawDelta); tile.root.dataset.displayDelta = String(delta); tile.root.dataset.own = String(own); @@ -582,21 +712,42 @@ export function mountBlockBoard( ? `Affected by ${contributors.map((id) => labels.get(id) ?? id).join(", ")}.` : own !== 0 ? "Your manual input." - : graph.edges.some((edge) => edge.to === tile.node.id) + : active.some((edge) => edge.to === tile.node.id) ? `No input reaches this tile within ${String(DEFAULT_PARAMS.maxHops)} relationships.` - : "No incoming relationships; only your own input moves this tile."; + : evidenceOnly && graph.edges.some((edge) => edge.to === tile.node.id) + ? "No cited relationship reaches this tile. That is a gap in citations, not evidence of no effect." + : "No incoming relationships; only your own input moves this tile."; if (!graph.edges.some((edge) => edge.from === tile.node.id || edge.to === tile.node.id)) tile.why.textContent = "No modeled connections."; tile.numbers.textContent = `Manual ${signed(own)}; incoming ${signed(effect?.propagated ?? 0)}; total ${signed(effect?.total ?? 0)}; clamped score ${signed(delta)}. Display position = 50 + 50 x score. Whole blocks are rounded symmetrically; calculations keep full precision.`; + if (tile.feeds !== null) + tile.feeds.textContent = evidenceOnly ? tile.feedsCited : tile.feedsAll; + const entry = robust.get(tile.node.id); + tile.robustness.textContent = + entry === undefined || idle ? "" : sensitivityText(entry, services.sensitivitySettings); + tile.compare.hidden = saved === null; + tile.compare.textContent = + saved === null ? "" : compareText(saved.deltas.get(tile.node.id) ?? 0, delta); tile.paths.replaceChildren(); - for (const item of effect?.contributions ?? []) { - if (item.value === 0) continue; + // Strongest first; the sort is stable, so ties keep the deterministic traversal order. + const routes = [...(effect?.contributions ?? [])] + .filter((item) => item.value !== 0) + .sort((a, b) => Math.abs(b.value) - Math.abs(a.value)); + for (const item of routes) { const route = [labels.get(item.sourceId) ?? item.sourceId]; + const steps: string[] = []; for (const id of item.edgeIds) { const edge = edges.get(id); - if (edge !== undefined) route.push(labels.get(edge.to) ?? edge.to); + if (edge === undefined) continue; + route.push(labels.get(edge.to) ?? edge.to); + steps.push(`${edge.direction > 0 ? "up" : "down"} (${relationshipBadge(edge)})`); } - tile.paths.append(element("p", "", `${route.join(" > ")}: ${signed(item.value)}`)); + const row = element("li"); + row.append( + element("span", "", `${route.join(" > ")}: ${signed(item.value)}`), + element("span", "bb-steps", `Each step: ${steps.join("; ")}`), + ); + tile.paths.append(row); } if ( delta !== tile.delta && @@ -610,6 +761,21 @@ export function mountBlockBoard( } tile.delta = delta; } + currentDeltas = deltas; + filterNote.textContent = + shownCategory === "all" + ? "" + : `Showing ${String(shown)} of ${String(graph.nodes.length)} indicators. Hidden tiles still take part in the calculation.`; + evidenceNote.textContent = !evidenceOnly + ? "" + : `Cited relationships only: ${String(citedCount)} of ${String(graph.edges.length)} relationships have a citation.${citedCount === 0 ? " None do yet, so no input reaches another tile in this view." : ""} Share links carry inputs only and open with every relationship.`; + if (saved === null) compareNote.textContent = ""; + else { + const { changed } = compareDeltas([...deltas.keys()], saved.deltas, deltas); + compareNote.textContent = `Comparing with scenario A${saved.evidenceOnly === evidenceOnly ? "" : ", saved in the other relationship view"}: ${String(changed)} of ${String(graph.nodes.length)} tiles differ. A is kept in this tab only.`; + } + clearA.hidden = saved === null; + board.dataset.evidenceOnly = String(evidenceOnly); reset.disabled = levers.size === 0 && warnings.length === 0 && window.location.search === ""; counts.textContent = summaryText(graph.nodes.length, levers.size, responding); message.textContent = `${lastAction} ${movedText(responding)}`; diff --git a/src/ui/blocks/view.test.ts b/src/ui/blocks/view.test.ts index 21a23d4..27f9473 100644 --- a/src/ui/blocks/view.test.ts +++ b/src/ui/blocks/view.test.ts @@ -7,7 +7,11 @@ import { movedText, signed, summaryText, + compareText, + relationshipBadge, + sensitivityText, } from "./view"; +import type { NodeSensitivity } from "../../model/analysis"; describe("Block Board display coordinates", () => { it("maps neutral and signed endpoints without changing the model score", () => { @@ -91,3 +95,41 @@ describe("Block Board display coordinates", () => { expect(movedText(5)).toBe("5 tiles moved."); }); }); + +describe("analysis display text", () => { + it("describes each robustness class, with a range only when the settings disagree", () => { + const at = (robustness: NodeSensitivity["robustness"], min: number, max: number) => + sensitivityText({ nodeId: "x", robustness, min, max }, 9); + expect(at("robust", 0.5, 0.5)).toBe("Same direction under all 9 tested settings."); + expect(at("robust", 0.08, 0.16)).toBe( + "Same direction under all 9 tested settings; position 54 to 58.", + ); + expect(at("partial", -0.25, 0)).toBe( + "Moves under some tested settings only, usually the longer path limits; position 37.5 to 50.", + ); + expect(at("direction", -0.25, 0.155)).toBe( + "Direction depends on the settings; position 37.5 to 57.75.", + ); + expect(at("none", 0, 0)).toBe(""); + }); + + it("compares a tile with scenario A on the display scale", () => { + expect(compareText(0.25, 0.1)).toBe("Scenario A 62.5, now 55 (-7.5)."); + expect(compareText(0, 0.5)).toBe("Scenario A 50, now 75 (+25)."); + expect(compareText(0.2, 0.2)).toBe("Scenario A 60, now 60 (no change)."); + expect(compareText(0.3, 0.1 + 0.2)).toBe("Scenario A 65, now 65 (no change)."); + expect(compareText(0, 1e-9)).toBe("Scenario A 50, now 50 (+5.00e-8)."); + }); + + it("labels a relationship by kind, citation status and horizon", () => { + expect(relationshipBadge({ kind: "accounting", confidence: "modeled", horizon: "short" })).toBe( + "Accounting | modeled | short run", + ); + expect(relationshipBadge({ kind: "causal", confidence: "empirical", horizon: "long" })).toBe( + "Causal | cited | long run", + ); + expect( + relationshipBadge({ kind: "association", confidence: "modeled", horizon: "medium" }), + ).toBe("Association | modeled | medium run"); + }); +}); diff --git a/src/ui/blocks/view.ts b/src/ui/blocks/view.ts index 8d35baa..5ecfc05 100644 --- a/src/ui/blocks/view.ts +++ b/src/ui/blocks/view.ts @@ -1,3 +1,5 @@ +import type { NodeSensitivity } from "../../model/analysis"; + /** Display coordinates only. Never feed calculated block counts back into the model. */ export interface BlockPosition { readonly position: number; @@ -89,3 +91,39 @@ export function summaryText(indicators: number, inputs: number, moved: number): export function movedText(moved: number): string { return `${count(moved, "tile", "tiles")} moved.`; } + +/** One line on how a tile's direction holds up across the tested settings. Empty when it never moves. */ +export function sensitivityText(entry: NodeSensitivity, settings: number): string { + const low = levelText(50 + 50 * entry.min); + const high = levelText(50 + 50 * entry.max); + const range = low === high ? "" : `; position ${low} to ${high}`; + if (entry.robustness === "robust") + return `Same direction under all ${String(settings)} tested settings${range}.`; + if (entry.robustness === "partial") + return `Moves under some tested settings only, usually the longer path limits${range}.`; + if (entry.robustness === "direction") return `Direction depends on the settings${range}.`; + return ""; +} + +/** A tile's position in saved scenario A against the current one, on the 0 to 100 display scale. */ +export function compareText(a: number, b: number): string { + const before = 50 + 50 * a; + const after = 50 + 50 * b; + // Same tolerance as compareDeltas, on the 0 to 100 scale, so a tile shown as "no change" is never + // counted as different. + const change = Math.abs(b - a) < 1e-12 ? 0 : after - before; + const note = change === 0 ? "no change" : signed(change); + return `Scenario A ${levelText(before)}, now ${levelText(after)} (${note}).`; +} + +const KIND_LABELS = { accounting: "Accounting", causal: "Causal", association: "Association" }; + +/** What a relationship asserts, whether a citation backs it, and how long it takes. */ +export function relationshipBadge(edge: { + readonly kind: keyof typeof KIND_LABELS; + readonly confidence: "empirical" | "modeled"; + readonly horizon: "short" | "medium" | "long"; +}): string { + const cited = edge.confidence === "empirical" ? "cited" : "modeled"; + return `${KIND_LABELS[edge.kind]} | ${cited} | ${edge.horizon} run`; +} From 704b82bb76667fcd1ea280bed4a7e533f7b84017 Mon Sep 17 00:00:00 2001 From: Thor Thor <37605399+codethor0@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:06:31 +0000 Subject: [PATCH 3/3] feat: add an offline baseline freshness report to the weekly run Self-maintenance release, part 3. - Every measured or projected node records its publication cadence (daily, weekly, monthly, quarterly, annual, irregular); the validator requires it on those nodes and forbids it on abstract levers. - src/lib/freshness.ts flags a baseline when more time has passed since the end of its period than a release usually takes at that cadence. Pure and fully covered. - npm run check:freshness prints the table for today or --today, reads no network, and exits non-zero only with --strict. - The weekly scheduled CI run writes the table to the job summary and never fails on it, so main stays green while stale data is visible. - CONTRIBUTING.md documents how to refresh a baseline. As of 2026-09-27 it flags mortgage_rate, savings_rate and federal_debt. --- .github/workflows/ci.yml | 5 +++ CONTRIBUTING.md | 35 ++++++++++----- README.md | 4 ++ TESTING.md | 4 ++ package.json | 1 + scripts/check-freshness.mjs | 73 +++++++++++++++++++++++++++++++ src/data/graph.json | 20 +++++++++ src/lib/freshness.test.ts | 73 +++++++++++++++++++++++++++++++ src/lib/freshness.ts | 67 ++++++++++++++++++++++++++++ src/lib/schema.ts | 10 +++++ src/lib/validate.test.ts | 30 +++++++++++++ src/lib/validate.ts | 18 ++++++++ src/repo/ci-workflow.test.ts | 7 +++ src/repo/freshness-report.test.ts | 42 ++++++++++++++++++ src/ui/present.test.ts | 1 + 15 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 scripts/check-freshness.mjs create mode 100644 src/lib/freshness.test.ts create mode 100644 src/lib/freshness.ts create mode 100644 src/repo/freshness-report.test.ts 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,