fix(nowcast): hold the 336414/33641A see-saw the 2022 census invents (#862) - #874
Conversation
…862) #862 found that 336414 guided missiles carries the largest unexplained negative gross operating surplus in the value-added block - -2,957 at 2022 growing to -3,693 at 2024 - and that 86% of it is our own EC-2022 output conditioning rather than anything in compensation. The decisive evidence needs no threshold. The census and BEA agree almost exactly on the pair's combined 2022 output and disagree entirely about the split: 336414 33641A pair BEA unconditioned 22,001 20,849 42,850 census-conditioned 16,879 25,982 42,861 The total agrees to 0.03% while 5.1 billion USD moves between the two members. That is a reallocation, not a measurement of growth, and guided missiles is concentrated enough that one firm's classification between vehicles and propulsion units moves the published split with no economic change behind it. QCEW payroll is opposed in both halves: the census moves 336414 to 0.744x BEA and payroll to 1.226x; 33641A 1.209x against payroll's 0.766x. Both are now held in PENDING_REVIEW, and held as a PAIR - holding one imposes the other's move and changes the family split in a way neither source supports. Measured, that restores 336414's surplus from -2,957 to +311 at 2022 and from -3,693 to -22 at 2024, the band BEA's own unconditioned series sits in, and improves the economy-wide negative surplus from -20,952 to -17,995 at 2022.⚠️ No automatic screen is shipped, and that is the considered answer rather than the lazy one. Two constant-free rules were built and both fail: - Sign-flip on surplus - hold where conditioning drives surplus negative and BEA's series does not - fires on ZERO industries in every year. 336414 is negative under both arms; the conditioning changes its magnitude tenfold without crossing zero. - Ranking by census-payroll disagreement is dominated by payroll noise, not census error. The largest disagreement of 223 industries is 333991 handtools, where the census matches BEA to 4.9% and payroll runs +98.5%. 94 of 223 industries disagree in direction; the see-saw sits at 0.481 against a 99th percentile of 0.681 and does not stand out at all. An earlier draft of this used a gate on the size of the census move, which is exactly the arbitrary constant this project is trying to remove. It was not derivable from anything - 0.25 flags one industry, 0.20 three, 0.15 six, 0.10 sixteen - and worse, 0.25 SPLITS THE SEE-SAW, holding 336414 and imposing 33641A, the one outcome both sources agree is wrong. The gate is gone. The new module ships the evidence ranked and unfiltered for a human reviewing a census move, plus family_reallocation, a first cut at the generalisation this points at: which BEA groups does the census reallocate while leaving the group total alone. That is the shape of the 3364 defect and it needs no magnitude constant. 325120 industrial gases is opposed too and is deliberately NOT held: its surplus is comfortably positive either way, so there is a census move nothing corroborates but no evidence of harm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#867) It detected two staleness classes, both keyed on an upstream artifact's date. Neither can see the third: the artifact's own METHOD changing. Nothing upstream moves, so nothing flags, and re-running returns the previous parquet silently - the FBS cache key is the git hash, so an uncommitted edit does not change it and a committed one only moves at the next commit. That bit twice on 2026-09-07. A re-weighted export split returned the old numbers verbatim; the second attempt rebuilt 2017 only and put a fake discontinuity between 2017 and 2018 that reads exactly like a real seam. Both times stale_artifacts reported one unrelated problem and nothing else. The new `method` class compares the artifact's date_created against the newest of (a) the last commit touching its method files and (b) their working-tree mtimes. The mtime half is not redundant - it is the only thing that catches an uncommitted edit, which is the case that actually happened. Method files are the yaml, its !include tree, and the modules its !clean_function / !script_function tags name.⚠️ NOT every .py beside the yaml: that was the first version and it flagged 985 of 995 artifacts, because an unrelated commit anywhere in transform/nipa marked every NIPA_VA_* parquet stale. A check that says everything is stale targets nothing. Resolving the tags instead keeps the surface honest - measured on NIPA_VA_surplus_2017 it is three files, and the flag it raises is a true positive (BEA_NIPA.py changed 2026-09-04, the artifact was built 2026-08-28). Also adds --delete, which removes the parquet AND its metadata - leaving the parquet behind means the rebuild reloads it, which is the whole problem. It refuses anything outside OUTPUT_DIRS and prints every path first. --scope defaults to `transform`. Extracts and transforms cost very different things to rebuild: an FBS reruns from parquets already on disk, an FBA re-fetches from an API or GCS. Defaulting to the cheap half keeps it one command away and makes the expensive half deliberate. On this tree it finds 290 distinct stale artifacts where the old check found 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The method class I just added used max(last commit, working-tree mtime). The mtime half is what catches an uncommitted method edit - the case that actually bit twice - but on its own it also fires on `git checkout`, which rewrites the mtime of every file it touches without changing a byte. Caught it immediately: Census_USATrade_2024 was reported as "method changed at 2026-09-04 18:48" when the last commit touching Census_USATrade.py was days earlier and nothing was edited. The 09-04 timestamp was a branch switch. On a stack this deep that is most of the tree, and a staleness check that cries wolf after every checkout is a staleness check nobody runs. The mtime is now consulted only for files that actually differ from HEAD, read once from `git status --porcelain`. The uncommitted-edit catch is unchanged; the checkout noise is gone. Measured on this tree: 995 problems -> 233, and the composition is now meaningful. All 233 are FBAs whose extractor module was committed to after they were built - USDA_ERS_FIWS.py, Census_AWTS.py, Census_ARTS.py, BEA.py, Census_SAS.py, BEA_NIPA.py - with zero superseded and zero internal, the transform side having just been cleared and rebuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caught while clearing artifacts for a full rebuild. `--name Balanced` answered "No stale cached artifacts" about balanced SUTs built 2026-09-01 that sit on trade inputs rebuilt today with materially different numbers. The reason is that Step 5 and Step 6 products are written by bespoke savers rather than the FBS framework. Their metadata carries `builder`, `engine_result`, `protocol` and `step` - and NO primary_source_meta and NO method_url. So `superseded` and `internal` have no sources to compare and `method` has no method to resolve. Every class silently passed, on the most important outputs in the pipeline. Two changes: - `builder` is now resolved as a dotted module and used as the method surface. That alone flags all 38 Balanced_Detail_* artifacts, via nowcast_sut_assembly.py changing on 2026-09-04. - A new `unverifiable` class reports any artifact carrying neither source lineage nor a resolvable method, rather than passing it over. A silent pass on something that cannot be checked is worse than a noisy flag, because it reads as a clean bill of health.⚠️ The builder surface does not give these products real lineage. They are downstream of the trade FBS and the gross-output conditioning and know nothing about either, so a rebuild of an input still cannot flag them. Recording sources at save time is the actual fix and belongs with whoever owns save_balance and save_mut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#856 reconciles the value-added block to BEA's published value added by putting the per-industry residual into V00300 gross operating surplus. That is right for 400 of 402 industries and impossible for two. Found by rebuilding: seven of the eight years failed outright with ValueError: 1 cells are nonzero in the seed but marked structural zero, first at ('V00300', '814000') = 1967.07 rising to 5,104 by 2024. 2017 passed only because its residual is zero by construction, which is why this went unnoticed - the balanced products on disk for 2018-2024 were built 2026-09-01, four days BEFORE #856 landed, so the reconciliation had never been exercised on those years.⚠️ The mask was right and the routing was wrong. Private households earn no operating surplus, and that is definitional rather than a 2017 accident: BEA publishes V00300 of exactly zero for 814000 in all three benchmark years - 2007, 2012 and 2017 - with VAPRO equal to V00100 to the dollar each time. The sector's output IS the compensation it pays; there is no enterprise, no capital and no margin. 4200ID is the same shape one row along, a synthetic code carrying customs duties, so its value added is a tax on products (T00TOP 38,513, everything else zero). Exempting the mask cell instead would have booked billions of household operating surplus and let it into every downstream coefficient. value_added_residual_row() derives the absorber per industry from the published 2017 table: V00300 wherever it exists, otherwise the row that actually carries that industry's value added. Exactly two industries take the exception, and both are single-row. Measured: 814000 now reads V00100 27,382 at 2024 against a published VAPRO of 27,382, with no surplus - previously V00100 22,278 plus a V00300 of 5,104 that cannot exist. 2018 balances at T11 max |residual| 0.0 $M where it raised before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-862 Carries the four new commits from main up the stack. No conflicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The diff carries two changes the description does not mention. The "What is in this PR" list covers
Each of the three is a good change and I am not asking for any of them to be dropped. But the see-saw hold, the value-added routing fix and the staleness checker have nothing to do with each other, and only one of them is reviewable from the description. The Testing block has the same gap: it runs Please update the description and the Testing section to cover all four files, and reference #867 the same way #862 is referenced. The value-added routing fix in particular needs its own paragraph: it changes every industry's value-added block in every year, which is a much wider blast radius than holding two aerospace codes, and right now a reader would not know it is in here at all. |
|
The routing fix has no test, and its failure mode already cost seven of eight years.
Suggested test, one failure mode, no restating of the implementation: def test_only_two_industries_take_the_non_surplus_absorber() -> None:
"""814000 and 4200ID carry no gross operating surplus; everything else does.
Routing a residual to V00300 for these two puts value in a cell the
balance mask holds as a structural zero.
"""
absorber = value_added_residual_row()
exceptions = absorber[absorber != 'V00300']
assert exceptions.to_dict() == {'814000': 'V00100', '4200ID': 'T00TOP'}If the published table is ever revised such that a third industry qualifies, that fires and a human looks at it, which is the outcome you want. It is also cheap -- the function is |
|
Four things in this file, in descending order of how much they matter. 1. The But That is worth fixing because the module docstring is what a reader consults to decide whether a flag is real. It should either describe what 2. 3. 4. The design itself is sound -- the mtime half gated on |
bl-young
left a comment
There was a problem hiding this comment.
Approving this despite the comments above. I'd recommend they be addressed soon after we pull in this stack (if not before). But I would recommend updating the PR body at minimum.
Three findings from review, all correct.
**The value-added routing had no test.** Its failure mode is recorded
history rather than hypothesis - seven of eight years failed outright -
and the result was asserted in prose but pinned nowhere. Adds the test
that pins it: exactly two industries take the non-surplus absorber,
814000 to V00100 and 4200ID to T00TOP. A third industry newly qualifying
now fires instead of being picked silently by idxmax.
**The bare 0.5 is now VA_ROW_PRESENCE_ATOL**, with the reasoning stated:
BEA publishes the detail tables in millions and rounds to the unit, so
half a unit is where "carries no such row" stops being a rounding
artefact. Same bar as sut_use_to_mut_use.REPLAY_ATOL. Fair catch that a
PR arguing against arbitrary constants had introduced one.
**stale_artifacts, four things:**
- The module docstring claimed `unverifiable` catches the Step 5-7
products. It does not: method_files resolves their builder in its
first branch, so they land in `method`. Measured on this tree -
`--name Nowcast_Detail` reports 108 method changed, 0 unverifiable.
The docstring now says what the class actually catches and where the
Step 5-7 hole is really closed.
- `--delete` is now a dry run listing what would go; `--yes` acts. It
also never deletes `unverifiable` artifacts, because "cannot be
checked" is not "known stale" and a Step 5 or 6 product removed on
that basis costs hours to rebuild.
- `--delete --strict` deleted and exited 0, joined by elif. They are
independent now, so it deletes and still exits 1.
- _tracked_change_times needs full history. A shallow clone, a failed
subprocess and an expired timeout all returned {} silently, which
reads as "nothing is stale". All three now warn on stderr, and the
docstring says to fetch full history before gating CI on --strict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carries the review fixes from #874 up the stack. No conflicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All three addressed. Thank you — the first and third were both cases where the document and the code disagreed, which is the worst kind to leave standing. 1. The description covered two of four files. Rewritten into three numbered sections, and it now closes #867 the same way it closes #862. The value-added routing has its own section saying plainly that it has the widest blast radius in the PR — every industry, every year, against two aerospace codes for the see-saw. The Testing block runs all four files now, including the staleness behaviour that had no check at all. 2. The routing fix had no test. Added yours, essentially verbatim — it passes and pins 3.
On your closing point in 3.1 — that routing them to |
Closes #862. Stacked on #873, at the top of the trade/margins stack.
The evidence needs no threshold
336414and33641Aare one see-saw, and the decisive fact is that the census and BEA agree almost exactly on the pair's combined output and disagree entirely about the split:33641433641AThe total agrees to 0.03% while 5.1 billion USD moves between the two members. That is a reallocation, not a measurement of growth — and guided missiles is concentrated enough that one firm's classification between vehicles and propulsion units moves the published split with no economic change behind it.
QCEW payroll is opposed in both halves: the census moves
336414to 0.744× BEA and payroll to 1.226×;33641A1.209× against payroll's 0.766×.What holding the pair buys
336414surplus, 2022336414surplus, 2024That is #862's headline, and the band BEA's own unconditioned series sits in.
❌ They are held as a pair. Holding
336414alone imposes33641A's move and changes the family's split in a way neither source supports — which is #862's own open decision 1, answered.#862's decision 2 asks whether the screen should look at more than the coverage wedge. I built two constant-free rules and both fail:
❌ Sign-flip on gross operating surplus — hold where the conditioning drives surplus negative and BEA's own series does not. Fires on zero industries in every year.
336414is negative under both arms; the conditioning changes its magnitude roughly tenfold without crossing zero. There is no accounting-boundary rule available here.❌ Rank by census–payroll disagreement — dominated by payroll noise rather than census error. The largest disagreement of all 223 industries is
333991handtools, where the census matches BEA to 4.9% and payroll runs +98.5%. 94 of 223 industries disagree in direction; the see-saw sits at 0.481 against a 99th percentile of 0.681 and does not stand out at all.336414, imposes33641A— the one outcome both sources agree is wrong. The gate is gone; the decision lives inPENDING_REVIEW, a list of named industries with measured reasons.What is in this PR
1. The see-saw hold (#862)
ec_go_adjustment.PENDING_REVIEWgains336414and33641A, documented as one see-saw held together and why.analysis/nowcasting/ec_payroll_corroboration.py— the three growth series ranked and unfiltered, for a human reviewing a census move.--checkasserts all nine figures above.family_reallocation()— a first cut at the generalisation this points at: which BEA groups does the census reallocate while leaving the group total alone. That is the shape of the3364defect and needs no magnitude constant.2.⚠️ The value-added residual now routes to a row the industry actually has —
nowcast.pyThis has the widest blast radius in the PR: it changes every industry's value-added block in every year, where the see-saw hold changes two aerospace codes. It was previously invisible in this description.
_reconcile_to_published_vaprorouted every industry's reconciliation residual toV00300gross operating surplus. Two industries have no surplus row at all. BEA publishesV00300of exactly zero for814000in 2007, 2012 and 2017, withVAPROequal to compensation to the dollar — private households earn no operating surplus, definitionally, because the sector's output is the compensation it pays.4200IDis the same shape one row along: a synthetic code carrying customs duties.This was a hard blocker, not a rounding matter. The routing put 1,967 $M (2018) rising to 5,104 (2024) into a cell the balance mask holds as a Tier 0 structural zero, and seven of the eight years failed outright.
value_added_residual_row()now derives the absorber per industry from the published 2017 table; exactly two industries take the exception.Pinned by a new test rather than by prose — review's catch, and a fair one, since the result was asserted in the docstring and held nowhere. The derivation's bare
0.5is nowVA_ROW_PRESENCE_ATOLwith its reasoning stated (BEA publishes in millions and rounds to the unit; same bar assut_use_to_mut_use.REPLAY_ATOL). A third industry newly qualifying now fires the test instead of being picked silently byidxmax.3. The staleness checker (#867) —
stale_artifacts.pybuilderis resolved as a dotted module and used as the method surface, plus a newunverifiableclass for artifacts carrying neither lineage nor a resolvable method, and--delete/--scopeflags.Review found four problems in it, all fixed here:
unverifiablecatches the Step 5-7 products. It does not —method_filesresolves theirbuilderin its first branch, so they land inmethod. Measured on this tree:--name Nowcast_Detailreports 108 method changed, 0 unverifiable. The docstring now says what the class actually catches (mostly flowsa's FBAs) and where the Step 5-7 hole is really closed.--deletehad no dry run. It is now a dry run listing what would go, with--yesto act, and it never deletesunverifiableartifacts — "cannot be checked" is not "known stale", and a Step 5 or 6 product removed on that basis costs hours to rebuild.--delete --strictdeleted and exited 0, the two joined byelif. They are independent now: it deletes and still exits 1._tracked_change_timesneeds full history. A shallow clone (fetch-depth: 1, the Actions default), a failed subprocess and an expired timeout all returned{}silently, which reads as "nothing is stale". All three warn on stderr now, and the docstring says to fetch full history before gating CI on--strict.Left on the table, deliberately
325120industrial gases is opposed too — census 1.237× BEA, payroll 0.902× — and is not held: its surplus is comfortably positive either way (4,273 million at 2022). A census move nothing corroborates, but no evidence of harm. Acting on it would be acting on the screen rather than on the symptom.Testing
Covering all four files, not just the two the original block ran:
🤖 Generated with Claude Code