Skip to content

Add Belgium's target, precalibration and holdout reference documents - #1442

Merged
MaxGhenis merged 12 commits into
mainfrom
be/reference-documents-b3
Nov 2, 2026
Merged

MaxGhenis merged 12 commits into
mainfrom
be/reference-documents-b3

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Plan step B3 of the Belgian transport port, in ~/reviews/belgium-2026-10-12/transport-port/PLAN.md.

A lane built this. Sol's first review asked for changes, and Sol's second agreed at 989ce9b with six non-blocking findings, which 40601e4 fixes. Three Opus delta rounds then found gaps in the artifact-free selector checks, which 2ec5084, 5739c51 and d0cb3f6 tighten. They changed no committed document. Every row stays proposed_pending_d1902. Belgium keeps refusing to activate, and nothing here publishes a number.

What changes

tools/generate_be_target_references.py writes Belgium's reference documents from Chronicle's Belgian consumer artifact, which be/chronicle_feed.json pins: 87,112 facts, facts_sha256 e01a4630…, manifest_sha256 78397926…, source commit 2c7e8357f (Chronicle #401's merge).

New under be/: chronicle_feed.json, precal_references.json, holdout_references.json, reference_aging.json, reference_intervals.json, reference_profiles.json, reference_inventory.json. The generated target_references.json replaces B1's placeholder groundwork. be/country_package.json lists the seven new resources, and catalogs.yaml adds the two eur columns. B1's document moves to a test fixture, so the shared schema-2 tests still run on Belgian geography.

This PR changes no file under build/transport/, spec_engine/ or frame/. It edits these shared tests:

  • test_legacy_selection_path_changed_mutant_frozen_surface_params_and_keys[be] read B1's spf_finances_pit_total row, which this PR removes, so it now reads an active demography row.
  • test_lexical_float_opt_in_is_limited_to_factor_consumers holds a closed set of lexical_floats callers, and the new generator joins it, as tools/generate_nz_reference_aging.py did. resolve_reference_factor needs the opt-in for RSZ's float operands. The 24 FPB pair operands are JSON integers, so the tokens don't touch them.
  • test_country_spec.py deletes test_target_profile_declares_tiers_and_income_basis, because the schema-1 profile drops those tiers, and points the schema-2 vintage, placeholder and catalog-unit tests at B1's fixture.
  • test_spec_engine_country_bundles.py lists the seven new resources and the two new eur columns.

Rows

Set Family Rows Active
Calibration Demography: Statbel 1 January 2026, region × sex × age band 18 18
Calibration Fiscal income: A.1 stand-in, 8 bands × count and amount 16 0
Calibration Fiscal income: B.4 joint and individual 2 0
Calibration Income tax: B.1 19 0
Calibration ONSS, ONEM, FPB benefit spending and mixed income 5 0
Precalibration Geography support: Statbel commune population 565 565
Holdout Eurostat, NBB and FPB aggregates 7 0

Every parked row names its reason. All three B.1 checks fail on the documents (tax definition, income ranking, policy year), so B.1 stays inactive.

Invariants

  • Separation: no name or base cell appears in two reference sets, and no holdout fact or measure reaches calibration or precalibration, on any entity. Seven mutants cover this.
  • Resolved reads: the inventory binds each row and factor by selector digest to its resolved aggregate and semantic keys. With no artifact, CI checks each digest, ties each pinned key and each row's set to its entry, and checks the keys' disjointness. It also holds every row and operand selector to one shape: the committed fields only, no null or empty-string field, every declared dimension pinned with at most one non-empty roster, no null, empty-string or nested pin member, and an aggregate key or, on a base selector, a roster. Factor selectors stay tied to their entries by key. These checks catch hand edits. The feed audit stays the proof, and separation rests on it for roster selectors, because a roster's members bind to no listed key without the artifact. A band or rank-group set counts as reading its published total, which catches a holdout on B.1's income total: A.1 and B.1 print one semantic key. Eleven more mutants and 22 spellings on the base and operand selectors cover this, and each clause of the shape check goes red with that clause removed.
  • Vintage: every subnational row maps its vintage to the layer's typed authority.
  • One factor context: a non-diagnostic factor may live only in reference_profiles.factor_context_resource, and every money.normalize@1 node, in the graph or in entitlement rows, must select that resource.
  • Geography: no German or French row enters a Belgian selector.
  • Selection: each selector resolves to one fact or to its declared roster. Each pins an aggregate key and record set, or a record set with a roster, because A.1 and B.1 print the same semantic keys.
  • Bands (Hypothesis): each amount lands in exactly one band, any band list on class edges covers A.1 exactly, and lists with a lower or upper off an edge refuse.
  • Ratio pairs (Hypothesis): each pair shares one FPB row and its declared periods.
  • Ageing: each rolled row compiled through the shared compiler equals exact base × numerator ÷ denominator, as a differential test. A row declares ageing_target_equals exactly when its base value equals its pair's denominator, which the feed audit checks.
  • Determinism: --check regenerates the documents byte for byte, given the artifact.

CI has no artifact, so the 20 feed-audit tests skip there and run locally. CI's engine-free job stops at its first failure, so the whole list also ran locally with no stop at a828a22 and the artifact loaded: 24,076 passed and 228 skipped. The 13 files that run couldn't finish, because they need the repo's import mode or the orrery package, then gave 304 passed on their own. Two later merges took #1440 and #1447. At d0cb3f6, --check passes, and the five test files this PR touches give 407 passed and 2 skipped, the golden test among them (both need the NZ artifact): test_be_target_references.py, test_country_spec.py, test_ledger_artifact.py, test_spec_engine_country_bundles.py and test_transport_reference_selection.py. All of that covers Python 3.14 only.

Choices the card doesn't fix

~/reviews/belgium-2026-10-12/transport-port/jobs/b3-references/d1902-choices.md lists 17, including the band list, the ratio-pair series, FPB T24's unemployment line (a. at €5.602bn, not 4. at €6.51bn), the NUTS_2024 mapping and what the schema-1 profile drops. Those go with d1902, and none of them makes a row active that the card doesn't already allow.

Shared gaps

Sent to the NZ hub, which queued them as shared work after w4, with no shared change here:

  • A schema-2 target profile requires exact periods, which refuses rolled_forward_by_ratio, so Belgium uses NZ's schema-1 profile.
  • No shared operation ages interval cuts by an annual ratio pair, so the A.1 bands stay parked.
  • G8-r2 operands need dated period coverage, which most of Chronicle's Belgian facts lack.
  • rolled_forward_by_ratio refuses a negative base.
  • Its series check can't tell FPB rows of one table apart.
  • A commune-summed national row refuses, because the composed-geography path labels only UK areas, so the CREATE mass reference moves to W3.
  • The typed vintage alias set can't hold a NUTS label, and schema-1 profiles skip the subnational vintage rule. A package test carries the rule for Belgium.

Limits

  • Statbel's total taxes: no document says whether they include the regional additional tax.
  • FPB's September 2026 Economic Budget: it isn't in the suite, and it may revise the June annex.
  • Holdouts: they're separate at the fact level only. ONSS scales with wages, for example, D62 contains unemployment and GRAPA, and A.1's amount pair contains gross wages, the wage holdout's own series. The inventory records these overlaps.

🤖 Generated with Claude Code

MaxGhenis and others added 12 commits November 1, 2026 15:22
Lift the documents from Chronicle's Belgian bundle suite through a generator,
pin the consumer artifact, and keep Belgium inactive. Active rows are
demography and commune support; every other family stays parked with its
reason. A package test keeps calibration and holdout facts separate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Point the shared legacy-selection test at an active Belgian row, which
  fixes engine-free CI.
- Map each subnational vintage to its typed authority. Chronicle's
  NUTS_2024 can't be a typed alias, so the row metadata and the inventory
  carry the mapping and a package test enforces it.
- Replace the money tripwire: a non-diagnostic factor may live only in the
  reference profile's context, and every money node must select it.
- Bind each row and factor to its resolved feed keys in the inventory, so
  separation no longer depends on how a selector is spelled. Partition
  totals count as read, which catches a holdout on B.1's income total.
- Flag rows by value, not key, when a target equals FPB's projection: the
  D61, D62 and D5 holdouts do too.
- Record the FPB T24 line choice and cut claims the suite can't source.
- Refuse a band whose upper sits off a class edge, and restore B1's
  required families as a package test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The no-Python-entrypoints scan read 646 fact keys in `resolved_reads` as
`dotted.module:callable` strings. It admits fact keys only under named
fields, so each key now sits in an `aggregate_fact_key` or
`semantic_fact_key` object. The scan stays as strict as it was.

Add the generator to the closed set of `lexical_floats` callers. It reads
the feed's lexical tokens for its ageing pairs, as NZ's ageing tool does.

CI's `--maxfail=1` hid both failures; a no-stop run of the engine-free list
found them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inventory's digest and the package fingerprint move, and nothing else
in the golden does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sol agreed at 989ce9b with six non-blocking findings. This commit makes
the two guard fixes and the four text fixes, and merges #1447.

Guards:
- The holdout-measure check now keys on the measure alone. A holdout
  column reused on another entity passed before.
- A pin check ties each aggregate key a row or factor selector pins to
  its own resolved-read entry. The digest binds an entry to its selectors
  but not to the keys it lists, so a rewritten selector with a recomputed
  digest kept its old keys. Roster selectors name no key, and only the
  feed audit proves what they resolve to.
- Four new mutants cover these, and each goes red with its guard removed.

Text:
- The overlap notes say ONSS scales with wages, not "a fixed share", and
  drop the "line 3" label on gross wages, which sits on an unnumbered row
  under that line.
- grammar_gaps names the second tolerance change: the ONSS row moves from
  B1's standard_admin_release 10% to 5%.
- Correction to c700edd's message: the generator needs the
  lexical_floats opt-in for the RSZ factor operands that
  resolve_reference_factor reads, not for its FPB pairs, whose 24 operands
  are JSON integers. The set membership stands.

The inventory text moves, so the golden repins its fingerprint and the
inventory hash. No row or number changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Opus delta review at 40601e4 ran a holdout rewritten to the ONSS
calibration fact with a null aggregate key and a null measure. The resolver
treats a null field as no filter, so the row compiled, and every
artifact-free check passed. Other fact keys and mapping-form dimensions did
the same.

- Each selector now keeps to the committed fields, leaves none null or
  empty, and pins an aggregate key or, for a base selector, a roster.
- Each resolved-read entry must carry its own document's reference set, so
  relabelling an entry can't hide a holdout's keys.
- The operand mutant now rewrites the whole operand, so it models a read.

Ten spellings on the base and operand selectors and two relabelling mutants
cover this, and each goes red with its guard removed. No committed selector
trips the new checks, and no document, row or number changes.

Corrections to 40601e4's message: ba2590a merged #1447, not that commit;
it made three text fixes plus the PR-body fix for finding 6; and it also
rewrote the inventory's "Separation is checked" line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Opus delta review at 2ec5084 ran a calibration row rewritten to a
partial roster: the D62 holdout's selector with one of its seven dimensions
named as a one-member list. The resolver checks only the pins a selector
names, so the row read the holdout's fact, while the cell check built a
different cell and every artifact-free test passed. No committed selector
does this.

Each selector now pins exactly the dimensions it declares, with at most one
non-empty roster, and no null or empty member at any depth. Twelve more
spellings cover this, and every clause of the check now has a case that goes
red with that clause removed.

These shapes catch hand edits. The feed audit stays the proof, because a
roster's members bind to nothing without the artifact, and the PR body now
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test's operand kept the committed empty dimensions, so the dimensions
clause refused it and the index guard it names stayed untested. The operand
now declares the roster's dimension, and the test goes red with that guard
removed.

The docstring also says a roster's members bind to no listed key, since the
cell check does read them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review November 2, 2026 09:07
@MaxGhenis
MaxGhenis merged commit dc0785f into main Nov 2, 2026
10 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Landed as dc0785f (squash), pinned to the reviewed head d0cb3f6.

  • Checks: 10 of 10 passing on d0cb3f6, rechecked immediately before the merge. Mergeable and clean, with no CHANGES_REQUESTED review.
  • Independent review: GPT-6.1 Sol agreed at 989ce9b. Its six non-blocking findings are fixed in 40601e4. With no Codex lane open, an Opus 5.5 reviewer ran four delta rounds at 40601e4, 2ec5084, 5739c51 and d0cb3f6. Each round's requests are fixed, and the last round's agreement stands at d0cb3f6 after its one-word PR-body fix. Reports: ~/reviews/belgium-2026-10-12/transport-port/jobs/b3-references/review2 to review6.
  • Main: main moved to 0b1eecf (UK: make the promoted national line the default load (#823) #1432, UK registry and loader only), and git merge-tree showed no conflict.
  • Number-shaping choices: all 17 stay with d1902. Every row carries proposed_pending_d1902, and Belgium still refuses to activate.

@MaxGhenis
MaxGhenis deleted the be/reference-documents-b3 branch November 2, 2026 13:17
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
B3 shared-gap triage
Needs Max: yes

Main SHA: a6bcd1ab319302cadd924ab989d380b283854534. It contains dc0785f13, and every file:line below refers to it. Python 3.13.18, uv sync --all-packages --locked. B/ = packages/microcosm-build/src/microcosm/build/, T/ = B/transport/. Scratch scripts sit outside the checkout. Nothing in the repo changed, and nothing was pushed.

# Claim Verdict Shared or Belgian-only Other countries hit today Fix size Touches a file under edit in #1449–#1452
1 Schema-2 profile requires exact, which refuses rolled_forward_by_ratio AS DESIGNED Shared rule; no country blocked None declares schema 2. NZ (221 rolled rows) and UK (2) would hit it on moving to schema 2 Small (country_spec.py), only if Max rules for it No
2 No shared operation ages interval cuts by an annual ratio pair CONFIRMED Shared gap None. NZ ages its cuts with quarterly G8-r2 Medium (reference_factors.py, reference_profiles.py) No
3 G8-r2 needs dated period_coverage on every operand AS DESIGNED Belgian-only (Chronicle adapters) None. NZ factors carry coverage None shared No
4 rolled_forward_by_ratio refuses a negative base CONFIRMED Shared gap None. No other rolled row is signed One line (ledger_targets.py:1157) No
5 Series key can't tell FPB annex rows of one table apart CONFIRMED Belgian-only (feed's table-grain source_concept) None None shared. Belgian adapter change No
6 Composed national row refuses outside the UK CONFIRMED Shared gap (UK-only label catalog) None compose today. Only UK declares composed_from_level Small (ledger_targets.py) No
7 NUTS_2024 can't alias vintage:be_nuts1_2025; schema 1 skips the subnational vintage rule AS DESIGNED Shared rule NZ has the same alias pattern (regc_2025) None shared. Belgian sources.yaml option No
8 Holdout guard keys on (entity, measure) CONFIRMED, wider than claimed Shared gap None leak today. NZ and BE are exposed Small guard Yes (T/compose.py) for the composition home. A country_spec.py home avoids it

1. Schema-2 profile requires exact, which refuses rolled_forward_by_ratio: AS DESIGNED

Reproduction. scratch/c17/repro_c1.py copies B/be to a temp directory and calls load_country_spec:

  • Schema-1 profile, one rolled row set to exact: ValueError: … period_match_policy='exact' does not support value_operation 'rolled_forward_by_ratio'. The check is ledger_targets.py:403-414, and EXACT_PERIOD_VALUE_OPERATIONS (:125-135) leaves the roll-forward out.
  • Minimal schema-2 profile, row left at latest_not_after: ValueError: … must set period_match_policy='exact'; stale observations require an explicit Chronicle source_projection. The check is country_spec.py:1323-1328.

The two checks lock each other out. Three places say they should:

  • The _validate_target_profile docstring (country_spec.py:1037-1045) names "an exact-period posture".
  • The country_spec.py:1323-1328 error says projection belongs in Chronicle.
  • The _resolve_rolled_forward_reference_facts docstring (ledger_targets.py:3304-3311) says the base deliberately reads the latest period the selector reaches.

test_country_spec.py:764-770 pins Belgium's schema-1 workaround.

Who else hits it. No packaged spec declares schema 2: am, be, nz and us/fiscal_target_references.json are schema 1, and the UK has no target_profile. Rolled rows exist in NZ (221), the UK (2) and BE (22 targets plus 7 holdouts), all on latest_not_after. Every one of them would hit this rule if its country moved to schema 2.

Smallest fix, only if Max rules for it.

  • Change: country_spec.py:1323. Under schema 2, accept latest_not_after on a rolled_forward_by_ratio row whose base selector pins period_type and period_value. Belgium's bases already pin 2023.
  • Tests that go red: none. No test asserts the "stale observations" message, which is a coverage gap in its own right.
  • Tests needed:
    • A pinned rolled row is accepted.
    • An unpinned rolled row is refused.
    • identity with latest_not_after is still refused.

Risk. Without the pin, a schema-2 rolled row whose base has no period_value would pass. Its base would move silently to a newer Chronicle fact. Even with the pin, schema 2's claim that "projection happens only in Chronicle" stops being true, because Microcosm computes the ratio. That is a methodology question that spans countries.

2. No shared operation ages interval cuts by an annual ratio pair: CONFIRMED, shared

Reproduction. scratch/c23/repro.py runs on test_support/microcosm_build/reference_factors.py, and repro_profiles.py runs on _interval_inputs from test_reference_profiles.py:

2a annual observed pair OK
2b annual target unobserved + growth REFUSED: Factor selector must resolve exactly once; found 0.
2e money row is rolled_forward_by_ratio REFUSED: Money scaling requires an untransformed source reference.
2f money row has no singleton aggregate_fact_key REFUSED: Money scaling needs a singleton source fact key.

Mechanism.

  • Cuts are scaled only by scale_interval_document, through scale_money_and_intervals (reference_profiles.py:353).
  • The scaling factor must be a G8-r2 projected_period_average_ratio with purpose profile_input (reference_factors.py:25,184; reference_profiles.py:627).
  • G8-r2's target slot 0 must be an observation and is never projected (reference_factors.py:547-549). Growth fills only later sub-periods (:578-583). So with subperiods_per_year=1, the target year must be observed, and FPB's 2026 source_projection can't be the target.
  • Money companions must be identity rows with a singleton key (reference_profiles.py:645-665).
  • Each band needs count and amount companions (:247-292).

Belgium's A.1 rows are rolled rows with a 10-member base, so they fail every one of these rules. test_reference_profiles.py:471,761 pin the companion rules as intended. What is missing is an operation for annual series.

Who else hits it. NZ ages its cuts with quarterly QES plus growth (nz/reference_aging.json, subperiods_per_year: 4). The UK and US declare no intervals or aging. Only Belgium hits this gap today.

Smallest fix.

  1. Add a factor operation (for example declared_ratio_pair v1) to reference_factors.py. It resolves a numerator and a denominator through resolve_feed_operand (:445), lets the numerator be a source_projection, and returns the existing factor and receipt shape. It needs dispatch in _declaration (:183-188) and validate_factor_context.
  2. Allow interval_table_ids with no money or count rosters in reference_profiles.py (_explicit_names, :135-143; _validate_interval_companions).
  • Tests that may go red: any closed-operation or empty-roster refusal test.
  • Tests needed:
    • The ratio pair resolves and replays.
    • A tampered operand is refused.
    • An intervals-only scale works.

Risk.

  • The fix admits a factor built from a projected numerator. Today a projected value gets in only through G8-r2's growth operand, which carries sensitivity provenance.
  • Intervals-only scaling lets cuts age by one ratio while their band amounts age by another. For example, cuts ×1.12 with amounts ×1.05 would pass, and band membership and targets would drift apart without any refusal. Whether that is allowed is a ruling for Max that spans countries.

3. G8-r2 needs dated period_coverage on every operand: AS DESIGNED, Belgian-only

Reproduction (same script):

3a facts lack period_coverage REFUSED: Factor operand period coverage mismatch.
3b declaration omits period_coverage REFUSED: Factor operands require unit, source hash/vintage, role, assertion, provenance and coverage.
3c growth fact lacks coverage REFUSED: Factor operand period coverage mismatch.

Mechanism.

  • _validation (reference_factors.py:302-326) requires start_date and end_date on every operand.
  • _operand (:374-380) compares the fact's coverage with the declared coverage.
  • _schedule (:283-295) uses the coverage for contiguity.
  • The FX operand resolves through the same path (money_normalization.py:582 → reference_factors.py:461).

Pins. test_reference_factors.py:269,293-300 (test_operand_guards[coverage]) and the fixture docstring "period coverage, rather than calendar conventions, establishes contiguity" (test_support/microcosm_build/reference_factors.py:24-25). Belgium's RSZ factors already carry coverage and resolve.

Shared change needed: none. An FX rate can skip the feed through the cited decimal_text form (money_normalization.py:570-575).

Not recommended: inferring coverage from period.type/value. A March-year label 2024 that covers April 2023 to March 2024 would then pass with calendar-year dates. That is the ambiguity ledger_targets.py:5177-5200 works around for DfT.

4. rolled_forward_by_ratio refuses a negative base: CONFIRMED, shared

Reproduction. scratch/c45/repro.py reuses _rolled_facts/_rolled_reference from test_ledger_targets.py:5611:

C4a rolled, negative base, signed=True -> REFUSED ValueError … rolled_forward_by_ratio produced invalid value -1387333.576241469.
C4b identity, negative fact, signed=True -> ACCEPTED (-1388543.0, signed=True)
C4c identity, negative fact, signed=False -> REFUSED … negative value -1388543.0 requires signed=True

Mechanism. _rolled_forward_by_ratio_value (ledger_targets.py:1157) refuses value < 0 without ever reading reference.signed. Identity rows pass signed through (:1082), and TargetSpec enforces the opt-in.

Why it is not "as designed".

  • No changelog states a sign rule (changelog.d/1123-rolled-forward-by-ratio.added.md).
  • No test pins the refusal for this operation.
  • The same unconditional check appears in difference (:950), linear_combination (:971) and scaled_by_ratio (:1132). Inferred: it is a convention from before any signed derived row existed.

Who else hits it. Rolled rows exist in BE (29), NZ (221) and the UK (4), and none is signed. Belgium's B.1 deciles 01–02 are its only signed rows, and they are identity rows today.

Smallest fix.

  • Change: at ledger_targets.py:1157, refuse only value < 0 and not reference.signed. Keep the non-finite refusal.
  • Tests that go red: none. The focused roll tests pass on main: pytest -n 4 test_ledger_grouped_roll_forward.py test_ledger_targets.py -k roll gives 59 passed.
  • Tests needed:
    • A signed negative base compiles with signed=True.
    • An unsigned negative base still refuses.
    • A signed roster base with mixed-sign cells.

Risk. A row authored signed: true whose base resolves to a negative fact by mistake would pass. Identity rows already accept the same exposure (C4b), and the ratio bound (0.5, 2] (:1214-1225) still guards the operands. The sibling checks at :950/971/1132 stay as they are, since no claim needs them changed.

5. Series key treats every FPB annex row of one table as one series: CONFIRMED, Belgian-only

Reproduction. The test used a numerator …cy2025.labour_market.levels.total_population and a denominator …cy2024.labour_market.levels.working_age_population. Both rows share measure_id: level_thousand and source_concept: fpb.eo.t07.published_cell, and their dimensions are empty. Result: ACCEPTED, ratio 1.59, inside the bound. Belgium's own test_be_target_references.py:1694 covers the same case. It needs BE_CHRONICLE_ARTIFACT, so it skips here.

Mechanism. _rolled_forward_series_key (ledger_targets.py:3484-3505) leaves out record_set_id and source_measure_id on purpose. Its docstring (:3485-3491) gives the reason: a publisher issues the new estimate and the revised prior period as separate tables (ONS mid-2025 beside revised mid-2024). The key assumes the concept carries series identity, and Belgium's FPB facts carry that identity only in record_set_id.

Who else hits it. Nobody. Every NZ and UK rolled concept maps to one row. The US and AM have no rolled rows.

Smallest fix (Belgian-only). Have Belgium's FPB Chronicle adapter emit a row-grain source_concept (for example …t07.total_population) or a row dimension. Keep _pair_violations (test_be_target_references.py:441-492) as defence in depth. It costs a feed repin and regenerated documents.

Shared alternative, not recommended. Add a year-free record_set_id to the key. A scratch check finds it would refuse no packaged rolled row (BE 0/29, NZ 0/221, UK 0/4). But it contradicts the docstring's ONS case and rests on a heuristic period-token grammar.

6. Composed national row refuses outside the UK: CONFIRMED, shared

Reproduction. scratch/c6/repro.py compiles the shared composed-row helpers (test_ledger_targets.py:4307-4369) under different geographies:

uk E12000001 OK 1980.0 North East
be BE LedgerHierarchyMetadataError … composed geography 'country'/'BE' is not present in Microcosm's authoritative geography catalog.
nz NZ / us US: same refusal
uk K02000001 OK 1980.0 United Kingdom
be BE, no hierarchy seed: ValueError TargetSpec 'x@BE': … bad keys ['ledger_geography_name'].

Mechanism. _geography_fallback_label (ledger_targets.py:1802-1803) reads only UK_GEOGRAPHY_ID_TO_LABEL. With a hierarchy seed, _composed_row_geography refuses at :1747-1753. Without one, :5434-5441 writes an empty ledger_geography_name, and TargetSpec refuses it. So the label is not just cosmetic: either path stops compilation.

Refusing an unknown code is intended: see the docstring at :1720-1724, the test at :4391, and changelog.d/929-uk-council-tax-taxbase.changed.md. Inferred: the catalog is UK-only only because the UK was the first user.

Who else hits it. Only uk/target_references.json declares composed_from_level (82 rows). NZ's mass row binds a direct national fact (nz/mass_references.json). Belgium can do the same with FPB's national population fact (be/reference_inventory.json:1045).

Smallest fix.

  • Change: in ledger_targets.py, read a declared metadata["geography_name"] at :1747 and :5441, falling back to the UK catalog. Where the catalog has an entry, require the declared name to equal it.
  • Tests that go red: none. The uncatalogued test at :4391 declares no name.
  • Tests needed:
    • A BE row with a name compiles and carries the name.
    • A non-UK row without a name still refuses.
    • A UK row whose name differs from the catalog refuses.

Risk. A composed row with a wrong declared name (BE / "Netherlands") would pass. Only the label is affected: member grain (:1739-1745) and expected_member_count still bind the value.

Left out. I didn't check whether CREATE's mass stage accepts a composed reference as its single national mass row. That is the next refusal Belgium could hit.

7. NUTS_2024 alias and schema-1 vintage rule: AS DESIGNED

Alias set. _typed_geography_vintage_aliases (country_spec.py:947-1026) admits the record id, the country-shortened id and the authority value. Its docstring says "no year-shaped alias is inferred" (:952-958). For BE nuts1 that gives ['2025_nuts1', 'be_nuts1_2025', 'nuts1_2025'], from be/spec/sources.yaml:27-29. test_country_spec.py:863-882 asserts that NUTS_2024 is refused.

Schema 1 skips the rule. country_spec.py:1056-1057 returns before the subnational block at :1335-1360. scratch/c17/repro_c7.py changes the packaged BE spec's nuts1 rows to selector NUTS_1999 with metadata NUTS_2013, and the spec still loads. With the vintage removed entirely, it also loads. Belgium's test (test_be_target_references.py:788-836) is what catches both cases. pytest -n 4 test_be_target_references.py test_country_spec.py -k "vintage or schema2 or schema_one or profile" gives 68 passed.

Scratch guard. scratch/c17/guard_c7.py applies the schema-2 vintage rule to every packaged spec:

Spec Rows it would refuse
nz 285 (64 regc_2025, which also isn't in NZ's alias set; 221 with no vintage)
uk 550 (no typed layers)
am 6
be 18 (NUTS_2024)
us 0

So turning the rule on for schema 1 is not a small fix.

Smallest fix (Belgian-only). Keep geography_vintage_bindings and its test. Inferred alternative: if the nuts1 layer really is NUTS 2024, set the authority value in be/spec/sources.yaml:27-29 to NUTS_2024. Then test_country_spec.py:838-882 and test_be_target_references.py:803-804 go red and need updating.

Shared alternative, not recommended. A declared aliases: list on vintage records. With it, NUTS_2021 declared as an alias of a 2025 layer would pass.

8. Holdout guard: CONFIRMED, wider than claimed

Reproduction 1, composition. scratch/c8/repro_toy.py builds make_composed_fixture, whose holdout is (person, receives_main). It appends a calibration target_references row and calls compose_transport_graph:

same column (person, receives_main): ACCEPTED, 27 nodes
other entity (household, receives_main): ACCEPTED, 27 nodes

Reproduction 2, packaged copies. scratch/c8/repro_pkg.py copies the packaged spec, adds the row to target_references.json, and calls load_transport_spec(Path):

be same column (person, employment_income): ACCEPTED
be other entity (household, employment_income): ACCEPTED
nz same column (family, accommodation_supplement_positive_payment_indicator): ACCEPTED
nz other entity (household, …): ACCEPTED

Mechanism. The gap is wider than the claim. holdout_columns (T/compose.py:1811) guards only pre-calibration stage outputs and modules (:1915-1918). Nothing compares calibration or pre-calibration reference rows with holdout rows, on any key:

  • validate_transport_activation (T/compose.py:1153-1180) checks each reference document's activation separately.
  • The ancestry checks (T/compose.py:2431, 2492, 2644, 2676, 2706) refuse a calibration-ancestry node that selects holdout_references or names a holdout source. They don't refuse a row in target_references that measures the holdout's column.
  • load_transport_spec (:543-570) only loads.
  • country_spec._validate_target_references (:817) validates one document at a time.

Today only Belgium's package test enforces separation: _separation_violations, test_be_target_references.py:194-223, mutants at :576-577. It keys on the measure alone, across the calibration, pre-calibration and holdout sets. Serial run: -k "separated or holdout_measure", 3 passed.

Classification. Shared gap. NZ is exposed the same way.

The key. Measure on any entity, across every non-holdout reference document: calibration, pre-calibration and mass.

  • scratch/c8/scan.py runs both keys against every packaged reference document (am, be, nz, uk incl. local_target_references, us fiscal_target_references). Under either key, no spec is refused. Only BE and NZ have holdout_references.
  • No packaged spec uses one measure on more than one entity, in any document, so the stricter key costs nothing today. No packaged spec has a legitimate person total beside a household total of the same concept.
  • Caveat: under the measure key, a future holdout on a generic count measure (people, households, tax_units) would refuse every demographic row that uses it. No holdout does that today. If one is ever needed, it should get its own measure column, as NZ's indicator holdouts already do.

Smallest fix.

  • Guard: in validate_transport_activation, refuse any row in a reference resource other than holdout_references (the set at T/compose.py:1153-1163) whose measure is a holdout row's measure.
  • Optional: switch the stage guard at :1811 to the measure key too.
  • Sequencing: both live in T/compose.py, which is under edit in G8-w4a: release-input evidence producers and their checked gate #1449–G8-w4d: wire the release-evidence producers into the NZ graph and toy #1452, so the hub sequences them after those PRs land.
  • Interim: the same cross-document check fits in load_country_spec (country_spec.py:2324, where payloads holds every package document). It isn't under edit, and it covers packaged and local-directory specs. It does not cover in-memory specs handed straight to compose_transport_graph, as the toy fixture's are.
  • Tests that go red: none among packaged specs, from the scan. Inferred, not run: the toy fixtures in test_support/microcosm_build/transport_{composed,nz_toy,entitlement}.py, which need the same check before landing.
  • Tests needed: the two reproductions above as refusals (same column, other entity), a pre-calibration and a mass variant, and a stage-output-on-another-entity variant if :1811 changes.

Risk. The rule applies only to declared holdout_references documents. UK and US holdouts are row-level by design: the UK's rotated holdout folds (changelog.d/1301-uk-dense-opt-in-holdout-incumbent.changed.md) and the US ACS district blocks (changelog.d/us-acs-local-state-cd-sparse.added.md) hold out rows of measures that are calibrated elsewhere. Neither country has a holdout_references document, so the guard can't touch them. A "rule for every country" must not be read as banning row-level holdouts.


Left out

  • Belgium's B4 and later packages.
  • Any guard beyond what the eight claims need.
  • The receipt shape and validity window of the claim-2 operation.
  • Whether CREATE accepts a composed national mass row (claim 6, next step).
  • Chronicle's Belgian adapters themselves.
  • A full-suite run.

Rulings the hub owes

  1. Claim 8, holdout separation key and home. Adopt the measure key across all non-holdout reference documents. Pick an interim country_spec.py guard now, or a T/compose.py guard after G8-w4a: release-input evidence producers and their checked gate #1449–G8-w4d: wire the release-evidence producers into the NZ graph and toy #1452. Max: a holdout rule declared for every country spans methodology. Confirm it is scoped to fact-level holdout_references and leaves row-level rotated holdouts (UK, US) alone.
  2. Claim 2, annual ratio-pair operation. Decide whether to build it. Max: may cuts and their band amounts age by different ratios? The question spans countries.
  3. Claim 1, rolled rows under schema 2. Max: may Microcosm-computed roll-forwards coexist with schema 2's "projection only in Chronicle" posture? NZ (221 rows) and the UK use the operation. If yes, take the pinned-base fix in country_spec.py:1323. If no, Belgium stays on schema 1.
  4. Claim 4, signed roll-forward. Take the one-line fix at ledger_targets.py:1157. Hub only.
  5. Claim 6, composed-row labels. Take the declared-name fix in ledger_targets.py, and decide whether the catalog must equal the declared name where it has an entry. Hub only. Belgium can bind FPB's national fact meanwhile.
  6. Claim 7, subnational vintage rule under schema 1. Max: extending it would refuse 285 NZ, 550 UK and 6 AM rows today. Belgium keeps its package test meanwhile.
  7. Claims 3 and 5: no shared change. Route both to Belgium's Chronicle adapters (operand period_coverage; row-grain FPB source_concept).

Generated by Claude Code

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.

1 participant