Refuse pinned-artifact builds that only relabel another year (#117) - #292
Conversation
…r-selecting
These packages pin artifact_year, so every --year reads the same file, but
their labels rendered {year} from --year. A --year 2023 build therefore
stamped TY2022 and TY2020 IRS tables as ty2023 (chronicle#117 item 1) and
stamped CY2024 BEA regional values as cy2023.
- soi-w2-statistics-2020: period, record ids, vintage and legal_vintage are
literal 2020.
- soi-congressional-district-2022, soi-state-2022 and both IRA 2022
packages: the same labels are literal 2022.
- The three Historic Table 2 packages: legal_vintage is literal tax_year_2022.
- ici-fact-book-table-30: vintage is literal calendar_year_2026, which is what
its artifact-year build emits.
- bea-regional-state-personal-income-components-2024 now selects
SAINC5N__ALL_AREAS_1998_2025.csv's own year column with column_by_year
(1998 = I ... 2023 = AH, 2024 = AI, 2025 = AJ), and source_column_id
follows the year. --year 2023 now reads the 2023 column: US personal income
is 23,577,208,000 thousand, not the 2024 cell's 24,897,613,000.
Every artifact-year build is byte-identical to origin/main (facts and
consumer rows for all ten packages, BEA regional at 2024 included).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
artifact_year pins which file a package reads, while each {year} label
renders from --year. Add a structural guard so a pinned build can no
longer stamp one year's publisher data as another year.
artifact_year_restamp_issues(package, year) compares the build at Y with
the build at the artifact year A. A record set whose selection is the same
at Y and A while any other rendered field differs is a restamp. Selection
means the artifact's parser, sheet, archive member and rendered
selected_rows, plus the record set's sheet, row and column addresses, value
scaling and guard cells. The other fields include period, record ids,
vintage, source_table, legal_vintage, filters, constraints and notes.
- SourcePackage.build_source_rows, build_source_cells and
build_source_record_set_specs raise ArtifactYearRestampError(ValueError).
Every other build path goes through them.
- validate_source_package reports error code artifact_year_restamp.
- A default build-bundle treats the code as "no data for this year": it
skips the package and gives the reason in skipped_sources. An explicit
--source fails the bundle (source_suite_build_failed, exit 1).
Year-selecting pinned packages still build off-year. That covers
selected_rows on {year}, column_by_year, sheet_name_by_year and guards that
follow the year. The check compiles specs only and never parses the
artifact. Over all 219 pinned packages at A-1, A+1, 2023 and every microcosm
scope year (585 pairs), it flags the same 22 pairs in 10 packages as the
design prototype on origin/main's package YAML, and 0 pairs after the
package fix.
Tests: synthetic refuse/allow/literal cases, validate and CLI exit codes,
default-bundle skip and explicit-source failure, an exhaustive property
test over 1,280 synthetic cases (Hypothesis is not a dependency), a
real-tree guard check, and a real-bytes differential: origin/main's IRA
and BEA regional YAML restamp without the guard and are refused with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
column_by_year binds each year to a column letter of
SAINC5N__ALL_AREAS_1998_2025.csv. Check the binding at build time: each
measure now expects row 1 of its year's column to name that year
(expected_column_header_by_year). A refreshed file with shifted columns then
fails loudly instead of relabelling one year's values as another year's.
A delimited file's header row keeps the text '2024', while package YAML
renders a digit-only string to the integer 2024 (_render_value). So no
year header guard could match a CSV header. _resolve_guard_cell now matches
a text cell against an integer expectation when the text is exactly that
integer's digits, and nothing looser ('02024', '2024.0' and booleans still
fail). Only builds this used to reject can change; a build that passed
before resolves the same cells.
Effect on BEA regional: each fact's source_cell_keys gains the row-1 header
cell of its column, and layout.record_set_spec_hash changes. Values,
periods, record ids, source_row_keys, aggregate_fact_key and
semantic_fact_key are unchanged at 2023, 2024 and 2025, and every year
1998-2025 builds with the guard passing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Coordination from the TY2023 SOI packaging session:
🤖 Generated with Claude Code |
The --year 2023 default bundle now emits the congressional-district, state and IRA facts at TY2022 and the W-2 facts at TY2020. tax_year:2023 drops by 26,893, tax_year:2022 gains 26,888 and tax_year:2020 gains 5. At TY2022 the CD state-total and US rows share semantic keys with the Historic Table 2 rows for the same cells, so semantic duplicates go from 467 to 2,022 (CI run 36244118301). Every other count is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #292 found three synthetic restamps the record-set-level selection fingerprint admitted: a fixed selected_rows entry beside a {year} one, a fixed-column measure beside a column_by_year measure in one record set, and a {year} virtual sheet name on a delimited file. A record set now restamps when any of its (row, measure) cells is read alike at both years (same row, measure, guard cells, spreadsheet sheet and selected_rows entries) while anything in the record set differs, since each fact carries a hash of the whole compiled record set. A delimited, JSON, HTML or PDF artifact's sheet name is a label, not a selection. Over the real tree the verdicts are unchanged: 0 of 585 pinned (package, year) pairs flagged on this branch, and the same 22 pairs in the same 10 packages on origin/main's package YAML. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Final review of #292 found two more synthetic admits, neither used by any package: - A row's column overrides its measure's (row.column or measure.column), so a fixed row column beside a column_by_year measure is read alike. The guard now compares each (row, measure) cell resolved as the compiler resolves it: column, header row and header with row precedence, both value scales, divisor, rounding, cell type, guards and selected-row entries. - The artifact-level sheet_name picks a sheet only for xlsx_table_full_rows, is a virtual label for delimited and JSON parsers, and is never read by the used-range spreadsheet parsers, whose record sets name the sheet. The guard now treats it that way instead of letting an unused {year} sheet name disguise a fixed sheet as year-selecting. Real tree unchanged: 0 of 585 pairs flagged here, the same 22 in 10 packages on origin/main's YAML. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merge audit (2026-09-28 open-PR sweep): |
Summary
artifact_yearpins which file a source package reads, but every{year}label still rendered from--year. So a pinned package built at another year relabelled one year's publisher data as that year. The default bundle and the microcosm US feed both did this:soi-congressional-district-2022stamped22incd.csv(TY2022) asty2023: 26,880 facts. This is item 1 of CD record sets stamp 22incd.csv as ty2023 — a tax-year vintage error; add Table 1.4 taxable-interest facts (populace#489) #117.soi-state-2022, both IRA 2022 packages andsoi-w2-statistics-2020stamped TY2022 and TY2020 workbooks asty2023. The W-2 case is the one microcosm#1029's reviewer traced.bea-regional-state-personal-income-components-2024read the 2024 column (AI) at every--year, so its "cy2023" facts were CY2024 values. For example, US personal income at cy2023 was 24,897,613,000 thousand, which is the 2024 cell. The file's 2023 cell is 23,577,208,000 thousand.historic_table_2and its two state packages stampedlegal_vintage: tax_year_2023on TY2022 facts.ici-fact-book-table-30stampedsource.vintage: calendar_year_2023on its 2026 edition.This PR does three things:
Fixes item 1 of #117 (the congressional-district stamp). Items 2 and 3 of #117, the Table 1.4 interest facts and HT2 TY2023, stay open. Context: microcosm#1029.
What changed
Harness:
chronicle/source_package.py,chronicle/bundle.py(commit 2)artifact_year_restamp_issues(package, year) -> list[SourcePackageIssue]compares the build at Y with the build at the artifact year A, one (row, measure) cell at a time. A cell is read alike when all of these render the same at Y and A:row.column or measure.column, the column-header row and header (the row's wins), both value scales, the divisor, rounding and cell type;selected_rowsentry it reads.The artifact
sheet_namecounts as selection only forxlsx_table_full_rows. For delimited and JSON parsers it is a virtual sheet name, so it counts as a label. The used-range spreadsheet parsers never read it; their record set'ssheet_namedoes the selecting.If any cell of a record set is read alike and anything else in that record set differs, or an artifact label (
vintage,source_table, a delimited file's virtual sheet name) differs, the record set gets anartifact_year_restampissue.layout.record_set_spec_hash, a hash of the whole compiled record set. A fixed cell beside a year-selected one is relabelled by its neighbour's move.{year},{filing_year}or*_by_yearreturns early.ArtifactYearRestampError(ValueError)is raised bySourcePackage.build_source_rows,build_source_cellsandbuild_source_record_set_specs.build_source_regions,build_source_record_specs,build_source_recordsandbuild_factsall go throughbuild_source_record_set_specs, so they raise too.validate_source_packagereports the issues as errors with codeartifact_year_restamp. It still fills in the counts.bundle._source_unavailable_for_yeartreatsartifact_year_restampas "no data for this year".skipped_sources: "source package pins another year's artifact and would only relabel it as Y (artifact_year_restamp)".--sourcestill reachesbuild_source_suite, which raises. The bundle then recordssource_suite_build_failed, the report is invalid, andbuild-bundleexits 1.The rule is structural and names no package. Year-selecting pinned packages keep building off-year:
selected_rowswith a{year}Period (BEA NIPA, Census projections),column_by_year(CBO, CMS NHE, Z.1, JCT),sheet_name_by_year, and header guards that follow the year.Package data (commit 1)
irs_soi/w2_statistics_2020:{year}becomes2020inperiod,record_set_id,source_record_id_prefix,artifact.vintageandlegal_vintage.irs_soi/congressional_district_2022,state_2022,ira_roth_contributions_2022andira_traditional_contributions_2022: the same fields get2022.irs_soi/historic_table_2,historic_table_2_state_agi_2022andhistoric_table_2_state_broad_2022:legal_vintage: tax_year_2022.ici/fact_book_table_30:vintage: calendar_year_2026, the value its artifact-year build emits today.bea/regional_personal_income_stateis now year-selecting.column_by_year(one YAML anchor) for every year column thatSAINC5N__ALL_AREAS_1998_2025.csvhas: 1998 =Ithrough 2025 =AJ, so 2023 =AHand 2024 =AI. All 416 selected rows are numeric in all 28 year columns.source_column_id: '2024'becomes'{year}'on the 6 measures that had it. The twovalueones are unchanged.Commit 3: BEA regional year-header guard (
chronicle/sources/specs.py)column_by_yearbinds each year to a column letter. Commit 3 checks that binding at build time: every BEA measure now expects row 1 of its year's column to name the year (expected_column_header_row: 1,expected_column_header_by_year, one anchor). If a refreshed file has shifted columns, the build then fails instead of relabelling one year's values as another's.For this to work,
_resolve_guard_cellneeded one change. A CSV header row keeps the text'2024', but package YAML renders every digit-only string to the integer 2024 (_render_value), and the guard compared with!=. So no year-header guard could ever match a delimited file._guard_value_matchesnow accepts a text cell that is exactly the expected integer's digits, and nothing looser: in the new text branch'02024','2024.0'and booleans do not match. The existingraw == expectedcomparison is unchanged, so a rawTruestill equals an expected1, as before. Only builds this used to reject can change; a build that passed before resolves the same cells.Trade-off: commit 3 breaks byte-identity for BEA regional at 2024. Each fact's
source_cell_keysgains exactly one key, the row-1 header cell of its column (AI1 at 2024), andlayout.record_set_spec_hashchanges, because the spec hash coversexpected_column_header*. At 2023, 2024 and 2025 these fields do not change: value, period, record ids,source_row_keys,aggregate_fact_keyandsemantic_fact_key. Every year from 1998 to 2025 builds with the guard passing. If byte-identity at 2024 matters more than the build-time binding check, drop commit 3 (aa3e4de). Commits 1 and 2 stand on their own; with them alone BEA at 2024 is byte-identical to origin/main.Docs
docs/agent-source-package-harness.mdhas a new "Pinned artifacts and year labels" subsection under the declarative authoring contract. It says thatartifact_yearpins the file, so labels must be literal unless--yearalso changes what is read. It also describes the guard, the validate code and the bundle behaviour. Chronicle has no changelog (changelog.d/andCHANGELOG*are absent).Why
Chronicle records each fact's publisher reference period (
AGENTS.md).docs/pe-uk-source-checklist.mdsays that "artifact_yearidentifies the publication vintage; it does not redefine the fact period." Aligning a fact to another period is the consumer's job; microcosm already ages dollar targets from the fact period. With{year}labels on a pinned file, though, the period itself was wrong.In the microcosm US feed pinned at
c5e5bf8, 27,309 of 39,158 rows were restamps: CD 26,880, BEA regional 416, W-2 5, state 4, IRA 2 + 2. For example, the W-2 tips rows labelled ty2023 carry the TY2020 file's sha256, cells and values. A dollar target aged from ty2023 therefore skips three years of wage growth. The design notes and the adversarial audit behind this PR are in the review folder that goes with microcosm#1029.Invariants
These are checked over the whole enumerated synthetic space, the review's seven counterexample patterns and the real tree (219 pinned packages at every year the default bundle or the microcosm scope uses). They are not proved for every possible YAML:
(value, source_cell_keys, source_row_keys)equals a fact of the A build while any other field differs.column_by_year,selected_rowson{year},sheet_name_by_year) are never refused, and a year their file lacks fails with its own error.artifact_year_restamp_issues(package, A) == [], and unpinned packages are never checked.The selected cells must match together with
source_row_keys: a year selected throughselected_rowslands on the same virtual cell at every year, and only itssource_row_keysdiffer.Before/after evidence
All numbers below come from scripts in the review folder (
scratch-E/), run on this branch and on a detached origin/main worktree.cmp, 20 files). With literal labels, every IRS and ICI build at 2023 is byte-identical to its own artifact-year build.source_row_keysare unchanged. Every year 1998-2025 builds, and US personal income equals the CSV row for each year.layout.record_set_spec_hashandsource_cell_keyschange, the latter by one added header-cell key per fact.Tests
New module
tests/test_chronicle_artifact_year_restamp.py(53 tests, about 8 s locally):{year}period raisesArtifactYearRestampErrorfrom all seven build entry points. The message names the package, both years and each moved label.legal_vintage, artifactvintage,source_table, a filter plus its constraint) is enough to refuse it.validate_source_packagereportsartifact_year_restampwith unchanged counts, and the harnessvalidate-packagecommand exits 1.column_by_year,selected_rowson{year}andsheet_name_by_yearread that year's value (140 rather than 110), with different lineage. A year the file lacks fails with its ownValueError.column_by_yearmeasure, and an unused{year}artifact sheet name on a used-range spreadsheet.selected_rowsentry beside a{year}one, and a fixed-column measure beside acolumn_by_yearone, are refused.{year}virtual sheet name on a delimited file is refused.artifact_year_restampin the reason and the validation errors, and still builds a literal-label package. An explicit--sourcegivessource_suite_build_failed, an invalid report andbuild-bundleexit 1.pyproject.tomlanduv.lock), so this test enumerates the whole space instead of sampling it: 2^6 templated-label subsets x 4 selection mechanisms x 5 build years = 1,280 cases. It asserts invariants 1-3 for each case against the unguarded build.{year}/{filing_year}or has a*_by_yearkey (13 packages after this change).slowmarker convention, so none was added.ira_roth_contributions_2022andbea/regional_personal_income_stateedited back to origin/main's labels or fixed column. Without the guard, every artifact-year fact comes back relabelled; with it, the build is refused.tests/test_chronicle_source_package.py:test_bea_regional_state_personal_income_components_read_the_requested_yearchecks 2023 values against the CSV'sAHcells, the 2025 US value, and that 1997 and 2026 fail.tests/test_chronicle_source_cells.py(commit 3): a guard expecting 2024 matches the header text'2024'but not'2023','02024','2024.0'orTrue.Default-bundle snapshot, re-pinned from CI run 36244118301. In it, the full test surface had 1 failure (this snapshot) and 1,267 passes. Changes from origin/main:
tax_year:202363,342 → 36,449 andtax_year:202241,270 → 68,158: the 26,888 CD, state and IRA facts move off their ty2023 restamp.tax_year:202011 → 16: the 5 W-2 facts.semantic_duplicate_key_count467 → 2,022. At TY2022 the CD file's state-total and US rows share semantic keys with the Historic Table 2 rows for the same cells. That is 1,560 new duplicate keys among the changed packages' own builds, for a bundle-wide net of +1,555. These are honest duplicates: two IRS publications of one cell, which consumers already select between.Chronicle Governance
ledger-source-ingestorfor the package data (packages/**),chronicle/sources/specs.pyand the tests.chronicle/source_package.pyandchronicle/bundle.py, anddocs/agent-source-package-harness.md, are outside every approved role'sallowed_paths. CODEOWNERS routes them to @PolicyEngine/core-developers for review.validate_source_packagereportsartifact_year_restampin the new tests.tests/test_chronicle_artifact_year_restamp.py,tests/test_chronicle_source_cells.pyand the new BEA test intests/test_chronicle_source_package.pypass (61 passed);ruff check chronicle testsis clean.ledger-source-fidelity: PASS. BEA column letters and guard columns match the CSV header; the 2023 and 2024 US values are checked; the IRS and ICI edits only write the year the pinned file covers. From an independent Opus 5.5 review on a Subfleet lane.ledger-contract: PASS. There is no schema change; record ids and periods now name the true year; BEA gains one lineage key per fact.ledger-boundary: PASS. Chronicle refuses to emit an unreported period and does no aging or alignment.This PR changes the source-data boundary in one direction only. Chronicle now refuses to emit a fact at a period its publisher did not report. It does not align or age anything; that stays with consumers.
Follow-ups (not in this PR)
chronicle_feed_scope.jsonwhen it re-pins to this commit. Its 2023 run no longer produces ty2023 rows for CD, state, IRA and W-2, soselect_rowswill stop with "scoped pairs produced no row".23in54us.xlsx,23in55cmcsv.csv) and IRA Tables 5/6 (23in05ira.xlsx,23in06ira.xlsx); packaging them would give real ty2023 facts. No TY2023 W-2 or CD table exists yet.artifact_year or year. Its fact builds should go through the guardedSourcePackagebuild methods.🤖 Generated with Claude Code