Skip to content

Extract the NIPA tables the value-added block is built from (#536) - #691

Merged
WesIngwersen merged 9 commits into
nowcastfrom
nipa_va_tables_536
Aug 27, 2026
Merged

Extract the NIPA tables the value-added block is built from (#536)#691
WesIngwersen merged 9 commits into
nowcastfrom
nipa_va_tables_536

Conversation

@WesIngwersen

@WesIngwersen WesIngwersen commented Aug 22, 2026

Copy link
Copy Markdown
Member

Closes #536, and reshapes the Step 2 plan ahead of #538.

Draft — the plan revisions want review before the FBS methods get built on them.

Scope: three rows, and one of them is a conversion question

Step 2 builds three rows. SUT_VALUE_ADDED_CODES and the use_va_detail_sut diagnostic already target V00100, T00OTOP, V00300 — the VABAS components.

T00TOP/T00SUB are not a sourcing question. They are already built by commodity in Step 4d (#690), so the industry row is a transformation of money that exists, not a second estimate. What was open is whether the transformation works — now measured for 2017 rather than assumed (tax_axis_conversion.py).

Using the benchmark market-share matrix from the Supply table, D[c,i] = V[c,i] / T007[c]:

row correlation abs. error
T00TOP (TOP + MDTY) 0.202 114.6% of the row
T00TOP (TOP only) 0.211 109.5%
T00SUB 0.676 79.8%

⚠️ The reason is structural, not noise. 55.7% of published T00TOP sits in wholesale and retail industries, because a tax on a product is remitted by whoever sells it while market shares place it with whoever makes it:

industry estimate published
424700 13 88,362 petroleum wholesalers
324110 92,893 397 petroleum refineries
441000 2,301 45,947 motor vehicle dealers
336111+336112 24,141 26 automobile manufacturing

The whole petroleum tax moves from wholesalers to refineries; the whole vehicle tax from dealers to assemblers. The operator is not noisy, it is aimed at the wrong stage of the chain.

⚠️ Measured in the benchmark year, against the very table the mix comes from — so this is not the "2017 ratios drift" objection the 2026-08-17 decision was argued from. It is stronger: a conversion that fails in 2017 cannot be rescued by being applied nearer to 2017.

That decision therefore stands — the industry distribution stays free for Step 5 under economy-wide soft targets — but free is not unseeded:

  • 4200ID takes MDTY exactly: 38,513 published against a Supply MDTY of 38,507. Customs duties are a lookup, 5.1% of the row, every year. Independently confirms the identity already listed in the Step 5 target set.
  • T00SUB's residual is two named structures, not a smearS00203 transit subsidies (19,471 vs 1,964) and S00102 (6,339 vs 102) are paid to an operator rather than attaching to a product, so no product-side operator can place them. T30800 already carries them.
  • The remaining split should ride the margin structure, not the Make matrix: a commodity's wholesale and retail margins say which trade industries handle it, which is the point-of-sale signal the tax follows. Those margins are built (nowcast_trade_margins, Build the trade margin column, wholesale and retail (#613) #684), so that operator is testable exactly as this one was. Open follow-up.

#536 — the tables

31 tables added to BEA_NIPA.yaml, all annual and complete for 2012–2024, at ~950 extra FBA rows per year: controls (1.10, 1.1.5, 1.3.5, 1.14), compensation (6.2D, 6.3D, 6.10D, 6.11D, 3.10.5, 7.18), gross operating surplus (6.12D–6.22D, 7.5, 7.7, 7.9, 3.8, 7.3.5, 7.4.5, 7.11), and the QCEW-gap set below.

test_bea_nipa_value_added_tables.py (17 tests) is the acceptance check: not "the tables extract" but "the numbers the block is built from come back correct through the FBA". Every expectation is a published 2017 figure, so a silent BEA vintage change fails here rather than inside Step 2.

Three findings that changed the plan:

  • ⚠️ Take the paid line, not the table's root. 6.2D and 6.3D each state their total twice — line 1 received by residents, line 2 paid by domestic industries and government. On line 2 the identity closes to 0 (8,485,016 + 604,656 + 1,345,306 = 10,434,978) and lands on the SUT's V00100 within 3. Reading line 1 is what left the ~10,600 the compensation plan carried as an open item; it is the rest-of-world adjustment A4187C, stated in 6.2D's own lines 97–99.
  • ⚠️ 6.11D is three panels under one code, and only the first is by industry: lines 1–20 industry, 22–36 type of fund, 37–45 benefits paid (2,370,770, a different concept). Its industry grain is 17, not the 36 the plan claimed, and selecting the whole table double-counts. T61600D, T71100 and T11400 also restate a code; select by line in all four.
  • Table 3.8 gives the government-enterprise surplus an industry axis it was thought not to have, federal and state-and-local summing to 1.10's A108RC exactly.

The QCEW-to-NIPA wage gap

QCEW covers 93.8% of NIPA wages paid. The plan treated the other 6.2% as a residual to spread pro rata. NIPA states it.

7.18 closes it exactly: BLS 7,968,336 + misreporting 106,273 + not-UI-covered 399,801 + timing 0 = 8,474,410. And it is non-uniform before any industry table is opened — government's uncovered rate is 11.3% against private's 3.5%.

6.4D turns the assumption into a measurement. QCEW publishes employment on the same axis, so QCEW/NIPA is a coverage ratio per industry. 2017, private employment, thousands:

NIPA 6.4D private line 6.4D QCEW QCEW/NIPA
Farms 819 818 99.9%
Construction 7,127 6,919 97.1%
Manufacturing 12,440 12,407 99.7%
Retail trade 15,989 15,854 99.2%
Health care and social assistance 19,576 19,322 98.7%
Educational services 3,662 2,824 77.1%
Other services, except government 7,042 4,435 63.0%

Coverage is 97–100% across most of the economy and collapses in exactly two places — religious and grantmaking organisations are largely UI-exempt. So QCEW growth is trustworthy broadly and the exceptions are a named short list, not a flat 6.2% haircut.

⚠️ Construction at 97.1% is a warning, not a reassurance. The count agrees because both sources count the same workers; the trade-versus-structure-type disagreement that actually matters is invisible to a coverage ratio.

Three lookups located: RfHhInstComp W151RC = 18,684, the SUT's 814000 to the dollar — the sector QCEW covers worst is the one NIPA hands over directly. U32500 splits general government compensation into wages and supplements, which 3.10.5 only totals. T60600D wages-per-FTE is the plausibility check Phase 5 lacked: a share can be positive, sum to one, and still imply an implausible wage.

Two parser defects the new tables exposed

Scale and unit were assumed, not read. A flat * 1000000 with Class/Unit hardcoded to Money/USD is right only while every declared table is in millions of dollars. 6.4D/6.5D are thousands of persons and 6.6D is a ratio, so 6.4D's 150,654 thousand employees would have been published as 1.5e11 USD. Both now come from each series' MetricName/DefaultScale, with a map that raises on an unrecognised metric rather than defaulting to dollars. Every BEA dollar table has DefaultScale = -6, so all 1,812 dollar rows are bit-identical across the change. 1.14's three chained-dollar lines move to Class: Other, so a Class: Money selection cannot add real dollars to nominal.

Table ids were matched by substring. Asking for U70205 also returned U70205S's 44 physical-quantity and price-index series under their own id. Nothing downstream selected them so it never showed — the metric guard above is what surfaced it. Matched exactly now.

Plan reshape, ahead of #538

Step 2 is settled as three FBS methods, one per Use row — not one method, not plain Python modules. BEA_Detail_Use_SUT already melts the Use SUT through VAPRO, so the 2017 benchmark VA block by industry is an attribution source today; no new extractor. Three rather than one because V00100 carries a hard group-level constraint in Step 5 while V00300/T00OTOP are seeds only, and V00300's ~20 activity sets should not destabilise the one row Step 5 constrains.

⚠️ The one step with no FBS primitive is the anchor-and-move itself: multiplication does not preserve the group total and there is no renormalise step. Recorded as the design seam — Phases 1–2 build a cached FBS_outside_flowsa moved-share source, Phase 3 is a single proportional attribution against it, and the old Phase 3.4 exact rescale then holds by construction.

Also corrected in the plan: the #567/#568 crosswalk gate is closed (three CSV lines now, not a decision); and the construction claim was too strong — the crosswalk carries 236 NAICS 23* rows, so the pipeline will not fail, it will produce an even split and call it an answer.

QCEW cache

Generating the BLS_QCEW FBA is slow (~9M rows/year, county grain). The cached per-year parquets work with no code change: copy one into extract/output_data/ and getFlowByActivity finds it on the ordinary local path — esupy matches on name and ignores the v2.0.4 tag. Verified for 2017. Two things flagged rather than deferred: the cache is 2017–2023, so the nowcast's 2024 has no QCEW; and Employment_common.yaml cannot be reused — every _bls_selection_fields_* block hardcodes Class: Employment.

Verification

  • NIPA_final_dom_uses_2017 rebuilds bit-identical — all 17 columns, same row count — across every change here.
  • 2012/2019/2020/2024 extract cleanly.
  • 17 tests in the new file; tax_axis_conversion --check green.
  • black / ruff / pytest green. The 4 mypy errors in utils/config/settings.py are pre-existing and Windows-only (resource module); confirmed present on a clean tree.

🤖 Generated with Claude Code

WesIngwersen and others added 3 commits August 22, 2026 15:16
Adds 24 tables to BEA_NIPA.yaml, all annual and complete for 2012-2024, at
roughly 950 extra FBA rows per year: the controls (1.10, 1.1.5, 1.3.5, 1.14),
compensation (6.2D, 6.3D, 6.10D, 6.11D, 3.10.5, 7.18) and gross operating
surplus (6.12D-6.22D, 7.5, 7.7, 7.9, 3.8, 7.3.5, 7.4.5, 7.11).

T00TOP and T00SUB are deliberately absent. Their commodity-axis counterparts
shipped in Step 4d (#690), and their industry split is an output of Step 5's
balance rather than an input to it, so sourcing them here would estimate the
same money twice on two axes.

Three findings, all pinned by the new test rather than left in prose:

- Take the *paid* line, not the table's root. 6.2D and 6.3D each state their
  total twice - line 1 received by residents, line 2 paid by domestic
  industries and government - and value added wants line 2. On it, wages plus
  both supplements close to 0 against compensation and land on the Use SUT's
  V00100 within 3. Reading line 1 is what left the ~10,600 the compensation
  plan carried as an open item; it is the rest-of-world adjustment A4187C,
  stated in 6.2D's own lines 97-99.

- 6.11D is three panels under one code, and only the first is by industry:
  lines 1-20 industry, 22-36 type of fund, 37-45 benefits paid - a different
  concept at 2,370,770. Its industry grain is 17, not the 36 the plan claimed,
  and selecting the whole table double-counts. T61600D, T71100 and T11400 also
  restate a code; select by line in all four.

- Table 3.8 gives the government-enterprise surplus an industry axis it was
  thought not to have, federal and state-and-local summing to 1.10's A108RC
  exactly.

The config half of the test needs no data and guards the substring hazard in
bea_nipa_parse, which selects a table's series with str.contains: two declared
ids where one contains the other would contaminate silently.

NIPA_final_dom_uses_2017 rebuilds bit-identical - all 17 columns, same row
count - so the added rows reach no existing method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ache

The plan's Phases 1-5 were written as a pure-Python pipeline. With Step 2
settled as three FBS methods, most of it survives as configuration rather than
code: BEA_Detail_Use_SUT already melts the Use SUT through VAPRO, so the 2017
benchmark V00100 by industry is an attribution source today, and Phase 1 is a
selection_fields clause rather than an extraction.

The one step with no FBS primitive is the anchor-and-move itself - multiplication
does not preserve the group total and there is no renormalise step, which is
what the old Phase 3.4 needed. Recorded as the design seam: Phases 1-2 build a
cached FBS_outside_flowsa source holding the moved shares, Phase 3 is a single
proportional attribution against it, and the exact rescale then holds by
construction instead of being a follow-up step.

QCEW is cached locally as per-year FBA parquets because generating it is slow
(~9M rows a year, county grain). Dropping one into extract/output_data lets
getFlowByActivity find it through the ordinary local path with no code change -
esupy matches on name and ignores the v2.0.4 tag. Verified for 2017. Four things
measured off that cache rather than assumed:

- QCEW national NAICS-6 payroll is 93.8% of NIPA wages paid. That number is what
  settles "allocator, never control", and the missing 6.2% concentrates in the
  sectors Phase 4 already carves out.
- 1,027 of the 1,048 NAICS-6 codes in the BEA detail crosswalk are present, so
  the gap is a bounded list of 21 rather than a structural hole.
- Ownership is on the flow, not a separate axis. It still does not separate
  government enterprises from general government, which is the distinction the
  SUT needs - so government stays on NIPA, and the plan's claim is narrowed to
  what is actually true.
- The cache is 2017-2023, so the nowcast's 2024 has no QCEW and needs a stated
  fallback. Better found here than in Phase 3.

Employment_common.yaml cannot be reused as-is: every _bls_selection_fields_*
block hardcodes Class: Employment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QCEW covers 93.8% of NIPA wages paid. The plan treated the other 6.2% as an
unexplained residual to spread pro rata. NIPA states it.

7.18 closes it exactly: BLS published 7,968,336 + misreporting 106,273 + not
UI-covered 399,801 + timing 0 = 8,474,410, wages received. And the gap is
already non-uniform before any industry table is opened - government's
uncovered rate is 11.3% of government wages against private's 3.5%.

6.4D is the table that turns the assumption into a measurement: QCEW publishes
employment on the same axis, so QCEW/NIPA is a coverage ratio per industry.
Measured on 2017 private employment it is 97-100% almost everywhere and
collapses in exactly two places - educational services 77.1%, other services
except government 63.0%, where religious and grantmaking organisations are
largely UI-exempt. So QCEW growth is trustworthy broadly and the exceptions are
a named short list, which is a better method than a flat haircut.

Construction at 97.1% is a warning rather than a reassurance: the count agrees
because both sources count the same workers, and the trade-versus-structure-type
disagreement that actually matters is invisible to a coverage ratio.

Three lookups located: RfHhInstComp W151RC is 18,684, the SUT's 814000 to the
dollar - the sector QCEW covers worst is the one NIPA hands over directly, and
the plan had described this line without finding it. U32500 splits general
government compensation into wages and supplements, which 3.10.5 only totals.
6.6D wages per FTE is the plausibility check Phase 5 lacked: a share can be
positive, sum to one, and still imply an implausible wage.

Adding those tables exposed two defects in bea_nipa_parse:

- Scale and unit were assumed, not read. A flat `* 1000000` with Class and Unit
  hardcoded to Money/USD is right only while every declared table is in millions
  of dollars. 6.4D/6.5D are thousands of persons and 6.6D is a ratio, so 6.4D's
  150,654 thousand employees would have been published as 1.5e11 USD. Both now
  come from each series' own MetricName/DefaultScale, with an explicit map that
  raises on an unrecognised metric rather than defaulting to dollars. Every
  dollar table BEA publishes has DefaultScale -6, so all 1,812 dollar rows are
  bit-identical across the change. 1.14's three chained-dollar lines move to
  Class Other, so a Class: Money selection cannot add real dollars to nominal.

- Table ids were matched by substring. Asking for U70205 also returned U70205S's
  44 physical-quantity and price-index series under their own id. Nothing
  downstream selected them so it never showed, and it was the metric guard above
  that surfaced it. Matched exactly now.

NIPA_final_dom_uses_2017 still rebuilds bit-identical, and 2012/2019/2020/2024
extract cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@WesIngwersen

Copy link
Copy Markdown
Member Author

Update: the QCEW-to-NIPA wage gap, and two parser defects it exposed

Pushed 64833a5c. Seven more tables, in answer to "is there anything that fills the 6.2% gap between QCEW and NIPA compensation?" — yes, and it changes the method rather than just documenting the shortfall.

7.18 closes the gap exactly

code 2017, $M
BLS published wages BA06RC 7,968,336
+ misreporting on employment tax returns BA07RC 106,273
+ not, or not fully, UI-covered W873RC 399,801
+ timing (accrual) Y663RC 0
= NIPA wages, received A034RC 8,474,410

And it is already known to be non-uniform before any industry table is opened: government's uncovered rate is 11.3% against private's 3.5%.

6.4D turns the assumption into a measurement

QCEW publishes employment on the same axis, so QCEW/NIPA is a coverage ratio per industry. 2017, private employment, thousands:

NIPA 6.4D private line 6.4D QCEW QCEW/NIPA
Farms 819 818 99.9%
Construction 7,127 6,919 97.1%
Manufacturing 12,440 12,407 99.7%
Retail trade 15,989 15,854 99.2%
Health care and social assistance 19,576 19,322 98.7%
Educational services 3,662 2,824 77.1%
Other services, except government 7,042 4,435 63.0%

Coverage is 97–100% across most of the economy and collapses in exactly two places. So QCEW growth can be trusted broadly and the exceptions are a named short list — a different method from a flat 6.2% haircut.

⚠️ Construction at 97.1% is a warning, not a reassurance. The count agrees because both sources count the same workers; the trade-versus-structure-type disagreement that actually matters is invisible to a coverage ratio. Phase 4's treatment stands.

Three lookups located

  • RfHhInstComp W151RC = 18,684 — the SUT's 814000 to the dollar. The sector QCEW covers worst is the one NIPA hands over directly; the plan described this line without finding it.
  • U32500 splits general government compensation into wages (1,233,594) and supplements (535,640), which 3.10.5 only totals.
  • T60600D wages per FTE is the plausibility check Phase 5 lacked — a share can be positive, sum to one, and still imply an implausible wage.

Two parser defects the new tables exposed

Scale and unit were assumed, not read. A flat * 1000000 with Class/Unit hardcoded to Money/USD is right only while every declared table is in millions of dollars. 6.4D/6.5D are thousands of persons and 6.6D is a ratio, so 6.4D's 150,654 thousand employees would have been published as 1.5e11 USD. Both now come from each series' MetricName/DefaultScale, with a map that raises on an unrecognised metric rather than defaulting to dollars. Every dollar table BEA publishes has DefaultScale = -6, so all 1,812 dollar rows are bit-identical across the change. 1.14's three chained-dollar lines move to Class: Other, so a Class: Money selection cannot add real dollars to nominal.

Table ids were matched by substring. Asking for U70205 also returned U70205S's 44 physical-quantity and price-index series under their own id. Nothing downstream selected them so it never showed — the metric guard above is what surfaced it. Matched exactly now.

Verification

NIPA_final_dom_uses_2017 still rebuilds bit-identical; 2012/2019/2020/2024 extract cleanly; 17 tests in the new file; black/ruff/pytest green.

@WesIngwersen
WesIngwersen marked this pull request as draft August 22, 2026 20:20
WesIngwersen and others added 3 commits August 22, 2026 16:34
Corrects an overstatement in the previous commit. T00TOP/T00SUB were described
as "not sourced here ... building them would estimate the same money twice".
That was wrong. They are already built by commodity in Step 4d, so the industry
row is a transformation of money that exists, not a second estimate - and the
plan's own coordination note said exactly that: "build once, derive the other
through the Make/Supply structure".

So the open question was never whether to derive it, but whether the derivation
works. Now measured for 2017, where the published answer exists.

It does not. Using the benchmark market-share matrix from the Supply table,
D[c,i] = V[c,i] / T007[c]:

  T00TOP (TOP + MDTY)   correlation 0.202   |error| 114.6% of the row
  T00TOP (TOP only)     correlation 0.211   |error| 109.5%
  T00SUB                correlation 0.676   |error|  79.8%

The reason is structural rather than noise. 55.7% of the published T00TOP row
sits in wholesale and retail industries, because a tax on a product is remitted
by whoever sells it while market shares place it with whoever makes it. The
pairs are stark: petroleum wholesalers 424700 are 88,362 published against 13
estimated while refineries 324110 are 397 against 92,893; motor vehicle dealers
441000 are 45,947 against 2,301 while assemblers 336111+336112 are 26 against
24,141. The whole tax moves one stage up the chain.

This is measured in the benchmark year, against the table the mix comes from,
so it is not the "2017 ratios drift" objection the 2026-08-17 decision was
argued from - it is stronger. A conversion that fails in 2017 cannot be rescued
by being applied nearer to 2017.

T00SUB fails differently and more usefully: its residual is two named structures,
not a smear. S00203 other state and local enterprises is 19,471 published against
1,964 (transit operating subsidies) and S00102 is 6,339 against 102 - subsidies
paid to an operator rather than attaching to a product, which no product-side
operator can place. T30800 already carries them.

One piece converts exactly: published T00TOP on 4200ID is 38,513 against a Supply
MDTY of 38,507, so customs duties are a lookup rather than an allocation - 5.1%
of the row, free, every year.

The decision stands: the industry distribution stays free for Step 5 under
economy-wide soft targets. But free is not unseeded, and the better operator is
the margin structure rather than the Make matrix - a commodity's wholesale and
retail margins say which trade industries handle it, which is the point-of-sale
signal the tax follows. Those margins are built, so that operator is testable
the same way. Left as the open follow-up.

Findings carried as a --check flag rather than unit tests, per the convention
for analysis modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ADME

Three passages still read as though T00TOP/T00SUB were simply out of Step 2's
scope, which was the overstatement the previous commit corrected in the Step 2
section but not elsewhere:

- the framework status table said "Not sourced", where the truth is that they
  are built by commodity and the industry row is a conversion Step 5 solves;
- the board-provenance note said they "left Step 2's scope entirely";
- the data-gap section already said "Step 2 still supplies a seed" and was
  right all along - it now also carries what the seed can and cannot contain,
  since the market-share operator was measured and fails at r=0.20.

Adds tax_axis_conversion.py to the analysis README index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ersion

Only within wholesale, and only for one code.

The previous commit left "ride the margin structure instead of the Make matrix"
as an untested proposal. Testing it turned up something better: Step 4c already
computes the operator this needs. top_by_level splits TOP per commodity into
producer-level (325,829) and trade-level (391,096) from an identity with nothing
modelled in it - excise sits in Producers' Value, sales tax inside the margin
columns. That is exactly the producer-versus-seller distinction market shares get
wrong, and its trade-level total lands within +2.2% of published wholesale-plus-
retail T00TOP.

  market share on all TOP + MDTY                corr 0.204   |err| 114.6%
  + level split, trade-level by trade output    corr 0.743   |err|  41.9%
  + motor fuel routed to 424700 by name         corr 0.946   |err|  29.9%

On the question itself, the answer differs by block:

- Non-trade industries are 44.3% of the row and need no matrix at all. Once the
  producer-level portion is separated, plain market shares give corr 0.987.
- Within retail, no. Output shares - which for a trade industry are very nearly
  margin shares - give 0.744. Retail product tax is general sales tax:
  broad-based, HHI 0.137, an effective 7.3 of 9 industries carrying it.
- Within wholesale, yes, and output shares are worse than useless: corr -0.192.
  424700 petroleum wholesalers takes 51.3% of wholesale product tax on 3.4% of
  wholesale output, because wholesale tax is motor fuel excise rather than a
  broad-based tax. HHI 0.321, an effective 3.1 of 10.

But wholesale does not need a commodity-by-trade-industry matrix either. It needs
one named routing: NAMED_TAX_LINES already carries motor fuel as 324110 and
trade_level_share already says that tax is 99.8% trade-level, so sending it to
424700 takes wholesale from -0.192 to 0.973 and the whole row to 0.946. With
petroleum pulled out by name the other nine wholesale industries score 0.825 on
output shares - they behave like retail.

So the general commodity-by-trade-industry margin matrix that the PRO:PUR
producer-price work will eventually need is not required here. Still a seed and
not a target: 29.9% absolute error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@WesIngwersen

Copy link
Copy Markdown
Member Author

Do we need to differentiate trade industries within wholesale and within retail?

Pushed cb991b3b. Measured — and the answer is only within wholesale, and only for one code.

Testing the proposed margin operator turned up something better: Step 4c already computes what this needs. top_by_level splits TOP per commodity into producer-level (325,829) and trade-level (391,096) from an identity with nothing modelled in it — excise sits in Producers' Value, sales tax inside the margin columns. That is the producer-versus-seller distinction market shares get wrong, and its trade-level total lands within +2.2% of published wholesale-plus-retail T00TOP (391,096 vs 382,491).

operator corr abs. error
market share on all TOP + MDTY 0.204 114.6%
+ level split, trade-level by trade output 0.743 41.9%
+ motor fuel routed to 424700 by name 0.946 29.9%

By block

  • Non-trade industries — no matrix at all. 44.3% of the row, and once the producer-level portion is separated, plain market shares give corr 0.987. Solved.
  • Within retail — no. Output shares (for a trade industry, very nearly margin shares) give 0.744. Retail product tax is general sales tax: broad-based, HHI 0.137, an effective 7.3 of 9 industries carrying it.
  • Within wholesale — yes, and output shares are worse than useless: corr −0.192. 424700 petroleum wholesalers takes 51.3% of wholesale product tax on 3.4% of wholesale output — a 15× concentration — because wholesale tax is motor fuel excise, not a broad-based tax. HHI 0.321, an effective 3.1 of 10.

Published T00TOP inside each group, share vs that industry's share of group output:

wholesale T00TOP share output share
424700 88,362 51.3% 3.4%
424A00 35,487 20.6% 18.3%
423A00 16,113 9.4% 14.7%
424200 2,783 1.6% 11.5%
retail T00TOP share output share
441000 45,947 21.8% 18.9%
452000 37,990 18.1% 12.0%
4B0000 24,926 11.9% 11.3%
445000 22,253 10.6% 13.9%

Retail tracks output; wholesale does not, and one code is why.

The useful part: wholesale needs a named routing, not a matrix

NAMED_TAX_LINES already carries motor fuel as 324110, and trade_level_share already says that commodity's tax is 99.8% trade-level. Sending that 98,842 to 424700 lands against a published 88,362 and takes wholesale from −0.192 to 0.973, the whole row to 0.946. With petroleum pulled out by name, the other nine wholesale industries score 0.825 on output shares — they behave like retail.

So the general commodity × trade-industry margin matrix that the PRO:PUR producer-price work will eventually need is not required here. The tax conversion is served by the level split, which exists, plus a handful of named-line routings, which are already enumerated.

Still a seed and not a target — 29.9% absolute error. tax_axis_conversion --check now asserts the seed's argument too, including that 424700 still dominates wholesale, so a later vintage that spreads the fuel tax differently fails loudly rather than quietly degrading the seed.

WesIngwersen and others added 2 commits August 22, 2026 19:19
Construction was the right last block to check and the wrong one to worry about.
It is block-shaped like the trade industries and could have been a second
petroleum; it is the opposite. Commodity TOP is 1,907 against a published T00TOP
of 1,857 and plain market shares give corr 1.000, |error| 31 = 1.7%.

The reason is the Make matrix. BEA defines the construction industries by type
of structure, so the block is 94.5% in-block and 100.0% diagonal - there is no
producer-versus-seller distinction when whoever builds the structure sells it.
top_by_level agrees: 100% of construction TOP is producer-level, so the routings
that rescue wholesale are inert here and all three operators give one number.
MDTY and SUB are zero on both axes, so there is no duties or subsidy question in
the block at all. Tax sits on 3 of 12 codes, none a NAMED_TAX_LINES entry.

The residual 1.7% is a leak, and market shares get its direction right and its
size about half: 5.5% of construction output is own-account or secondary work
(531HST 20,279, state and local government 19,991), and published moves 50 off
the block where market shares move 30.4.

That answers the larger question of whether the remaining sectors each need this
treatment. They do not - the error is 20 industries, not 402:

  top 5 industries carry 35.1% of the error, top 20 carry 80.3%
  17 of those 20 are wholesale or retail

  wholesale   22.8% of the row   41.9% of the error
  retail      27.8%              33.7%
  non-trade   44.3%              24.4%
  4200ID       5.1%               0.0%

What is left is the within-trade allocation already characterised plus five
named non-trade structures: 721000 accommodation (-6,592), 517210 wireless
(-4,310), 221100 electric power (-3,934), and government enterprises handed tax
they do not carry (S00202 +3,439 against a published zero, GSLGE +1,698, both of
which belong to the Step 7 reallocation). So build against the seed and repair
the named twenty later - Step 5 balances these cells under soft targets anyway,
and seed accuracy below the block level is not what the build is waiting on.

Adds construction_codes, construction_scores and error_concentration, wires both
into the report, and adds five assertions to --check: construction stays usable,
its error stays under 10%, its Make block stays diagonal, it stays MDTY/SUB-free,
and the top-20 error share stays above 60% - that last one fails if the residual
ever spreads and a broader sweep becomes necessary after all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BEA books zero taxes on production to all ten government industry codes -
T00OTOP and T00TOP are both zero on every one of them in 2017, the single
exception being 538 of T00TOP on S00203. The columns are real (V00100 and VABAS
are populated), so the zero is an accounting rule and not a gap: a tax levied by
government and remitted by a government producer nets out.

The market-share leg was violating that rule by 10,513 against a published 538,
because government genuinely does produce taxed commodities and market shares
hand it a share of the tax on them:

  S00202  S&L electric utilities        3,439  against a published 0
  S00203  other S&L enterprises         2,428  against 538
  GSLGE   S&L educational services      1,698  against 0
  GSLGO   S&L other services              940  against 0
  S00101  federal electric utilities      854  against 0

market_share_matrix gains an exclusion that drops those columns and
renormalises each commodity over the producers that remain, so the tax stays
with its own commodity rather than being deleted or smeared economy-wide. That
is the fourth operator in the progression:

  + motor fuel routed to 424700 by name        corr 0.946   |err| 29.9%
  + government columns zeroed, renormalised    corr 0.948   |err| 27.9%

It moves 15,692 of error, and only two thirds of that is the excess itself. The
rest lands where it belongs:

  221100 electric power    |err| 3,934 -> 443
  721000 accommodation     |err| 6,592 -> 5,088
  622000 hospitals         |err|   763 -> 296

Read 221100 against S00202: the previous operator was under-attributing private
electric power by almost exactly the amount it had misplaced onto the government
utility, so the money was misplaced in a recoverable direction. The non-trade
block goes from corr 0.987 / 24.4% of the error to 0.992 / 18.8%, and no
government code appears in the top-20 error ranking any more.

Only the producer-level leg needs the exclusion - the trade-level leg lands on
wholesale and retail, where no government code sits, and duties land on 4200ID.
No commodity is stranded: no commodity produced entirely by government carries
any product tax in 2017, so the fallback that keeps such a row unrenormalised
never fires, and the seed total is unchanged to the dollar.

This one was worth fixing before the build rather than after. The other
residuals are misallocations Step 5's balance can pull back; these columns are
redistributed into private industries by the Step 7 government-enterprise
reallocation, so a wrong seed here propagates into work that would have to be
unpicked.

Adds government_industries and government_scores, and four assertions to
--check: the published rule still holds, the seed puts nothing there, the
exclusion still reduces the row error, and renormalisation still moves money
between industries without creating or destroying it. _frames is now cached -
the scoring functions call it a dozen times over and --check had grown to over
two minutes; it is 9s now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
df = df.query('Table_and_Line.str.contains(@table)').reset_index(drop=True)
df['TableId'] = df['Table_and_Line'].str.split(':', expand=True)[0]
df['Line'] = df['Table_and_Line'].str.split(':', expand=True)[1].astype('int')
# Match the table id exactly. This used to be a second `str.contains`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend avoiding comments that refer to what the code used to do

# Conflicts:
#	bedrock/analysis/nowcasting/plan.md
@WesIngwersen
WesIngwersen merged commit 0c24513 into nowcast Aug 27, 2026
3 of 4 checks passed
@WesIngwersen
WesIngwersen deleted the nipa_va_tables_536 branch August 27, 2026 10:44
WesIngwersen added a commit that referenced this pull request Aug 27, 2026
nowcast's squash commit 0c24513 is byte-for-byte identical to 8d5c466, the
nipa_va_tables_536 tip this branch already merged in df1ef62, so the conflicts
were the squash re-presenting content the branch already carries. Nothing to
take from nowcast; the resulting tree is unchanged.
WesIngwersen added a commit that referenced this pull request Aug 27, 2026
…#737)

Two tests in test_nowcast_product_taxes.py have been red on nowcast since #691
merged, but #691 did not cause them: a dispatched CI run on 7827a9e, nowcast
immediately before #691, fails with the same two tests and a bit-identical
-26278.087320966762.

The cause is #734 x #733. #733's last green run (26acafc) did not contain #734
-- `git merge-base --is-ancestor a57b0ca 26acafc` is false -- so the -26,153
band was fit on pre-#734 data. #734 then remapped the Census NAICS 2022
activities onto the goods Crosswalk, which moves the Trade FBS, purchaser_base
and so the residual's annual shares. Each PR was green alone; neither was ever
built with the other.

Re-fit tobacco to -26,278, 0.48% off the old centre against a 0.38% band. The
2017 anchors -- the control totals, NAMED_TOTAL_2017, RESIDUAL_TOTAL_2017 and
the 391,162 trade-level split -- all still pass, so the level is intact and it
is the shares that moved. The divergence also grew rather than fell, which the
test's own docstring names as the safe direction.

test_the_two_levels_add_back_to_the_column was a separate, latent bug.
producer_level is defined as TOP - trade_level, so .equals asserted that
(a - b) + b == a bit-exactly, which floating point does not guarantee; it had
been passing on luck. Compared with rel=1e-12 instead.

Grouped the four fitted bands into two dict comparisons so a future source
change reports every band that moved in one run rather than only the first.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants