diff --git a/docs/agent-source-package-harness.md b/docs/agent-source-package-harness.md index c8cec774..58d4f9cb 100644 --- a/docs/agent-source-package-harness.md +++ b/docs/agent-source-package-harness.md @@ -864,6 +864,19 @@ year's column, sheet or rows. A year the file does not cover fails with its own error, such as `No source artifact for year 2027` or a selected-row mismatch. +That check compares one build with another, so it cannot see a literal label +that is simply the wrong year. `tests/test_chronicle_source_table_year.py` +covers that case for every pinned package whose `artifact.source_table` names +exactly one tax year ("…, Tax Year 2020"). The package must build at least +one fact at its artifact year, and every fact's `source_table` must name that +same year. Each tax-year fact must have that year as its period. Any +`tax_year_` in its source vintage or legal vintage must name that year, +and it must be the only `ty` token in the fact's record id and record +set id (both tokens are matched in lowercase). A title that names a range or +abbreviates a year ("tax year 2023-24") is not read as one tax year and is +not checked. State the table title as the publisher printed it, with its +year, so the test has something to check. + Agents may add new package directories and YAML specs. They should not modify `chronicle.core`, `chronicle.database`, or `chronicle.suite` unless the package cannot be expressed in the current contract and the failure is documented in the build diff --git a/tests/test_chronicle_source_table_year.py b/tests/test_chronicle_source_table_year.py new file mode 100644 index 00000000..46167ac5 --- /dev/null +++ b/tests/test_chronicle_source_table_year.py @@ -0,0 +1,332 @@ +"""A fact's period is the tax year its source table says it is for. + +IRS SOI titles each table with the tax year it covers ("..., Tax Year 2020"), +and a package states that title as ``artifact.source_table``. The artifact-year +restamp guard (``test_chronicle_artifact_year_restamp.py``) refuses a pinned +package whose labels follow ``--year``. It compares one build with another, so +it cannot see a package whose labels are simply the wrong year. These tests +compare the labels with what the publisher printed. + +Invariant under test: when a package's source table title names exactly one +tax year, every tax-year fact the package builds at its artifact year has that +year as its period, in its source vintage and legal vintage when they name a +``tax_year_``, and as the only ``ty`` token of its record id and +record set id. + +Origin: until PolicyEngine/chronicle#292, ``soi-w2-statistics-2020`` rendered +its labels from ``--year``, and the microcosm US feed built it at ``--year +2023``. Five cells of IRS SOI Table 4.B for Tax Year 2020 went out as ty2023 +facts, and microcosm aged the Box 7 tips total one year to 2024 where it needed +four. +""" + +from __future__ import annotations + +import itertools +import json +import re +from pathlib import Path + +import openpyxl +import pytest + +from chronicle.source_package import ( + SOURCE_PACKAGE_FILENAME, + artifact_year_restamp_issues, + discover_source_package_dirs, + load_source_package, +) +from chronicle.store import fact_to_mapping +from chronicle.yaml_io import safe_load + +REPO_ROOT = Path(__file__).resolve().parents[1] + +_YEAR = r"(? int | None: + """The one tax year a source table title names, else ``None``. + + ``None`` covers a title that names no tax year and a title that names more + than one: neither says which year a cell of the table is for. + """ + years = { + int(year) + for span in _TAX_YEAR_SPAN.findall(title) + for year in re.findall(_YEAR, span) + } + return years.pop() if len(years) == 1 else None + + +# --- the title reader -------------------------------------------------------- + +W2_TITLE = ( + "Table 4.B. Summary of Items for Taxpayers with Form W-2, by Return and " + "Earner Type, Tax Year 2020" +) + + +@pytest.mark.parametrize( + ("title", "expected"), + [ + (W2_TITLE, 2020), + ("Table 4.B, Tax Year 2020 (revised January 2026)", 2020), + ("Table 1, Tax Year 2020, and Table 2, Tax Year 2020", 2020), + ("table 1.4, tax year\n2023", 2023), + ("Individual returns, Tax Years 2019 and 2020", None), + ("Individual returns, Tax Years 2019-2020", None), + ("Individual returns, tax years 2019, 2020 and 2021", None), + ("Individual returns, Tax Year 2020 through 2022", None), + ("Table 1, Tax Year 2019, and Table 2, Tax Year 2020", None), + ("SPI Tables 3.1 to 3.11, tax year 2023-24", None), + ("SPI Tables 3.1 to 3.11, tax year 2023/24", None), + ("Individual returns, Tax Years 2019 and 2020/21", None), + ("Individual returns, Tax Year 2020 and 2021/22", None), + ("Individual returns, Tax Years 2020; 2021", None), + ("Individual returns, Tax Year 2020 to 21", None), + ("Individual returns, Tax Years 2020; 2020", 2020), + ("Tax Year 2020, 1040 Returns", 2020), + ("Tax Year 2020 - 1st Release", 2020), + ("Tax Year 2020 (Filing Year 2021)", 2020), + ("Tax Year 2020: All Returns", 2020), + ("Individual returns, Tax Year 2020 or 2021", None), + ("Individual returns, Tax Years 2020 vs. 2021", None), + ("Individual returns, Tax Years 2020 thru 2021", None), + ("Individual returns, Tax Years 2020 − 2021", None), + ("Tax Year 20201", None), + ("Estimates of Federal Tax Expenditures for Fiscal Years 2024-2028", None), + ("Employer-Sponsored Insurance: Calendar Years 1987-2024", None), + ("2020 Form W-2 statistics", None), + ("", None), + ], +) +def test_title_tax_year_reads_publisher_titles(title, expected): + assert title_tax_year(title) == expected + + +PREFIXES = ("", "Table 4.B. Items for Taxpayers with Form W-2, ", "2020 release: ") +SUFFIXES = ("", ".", " (revised)", " [1]", ", all returns") +CONNECTORS = ( + " and ", + "-", + "–", + "−", + " to ", + " through ", + " thru ", + " or ", + " vs. ", + ", ", + " & ", + "; ", + "/", +) +YEARS = (1999, 2019, 2020, 2023, 2099) +INCOMPLETE_TAILS = ("-21", "–21", "/21", " to 21", " and 21", "; 21", ", 21") + + +def test_title_tax_year_over_every_synthetic_title(): + """One year reads back; two different years never read as one.""" + for prefix, suffix, year in itertools.product(PREFIXES, SUFFIXES, YEARS): + assert title_tax_year(f"{prefix}Tax Year {year}{suffix}") == year + assert title_tax_year(f"{prefix}tax year {year}{suffix}") == year + # No "Tax Year" marker: a year elsewhere in the title is not a tax year. + assert title_tax_year(f"{prefix}Calendar Year {year}{suffix}") is None + for prefix, suffix, connector, first, second in itertools.product( + PREFIXES, SUFFIXES, CONNECTORS, YEARS, YEARS + ): + title = f"{prefix}Tax Years {first}{connector}{second}{suffix}" + assert title_tax_year(title) == (first if first == second else None), title + # A span that ends in a year it cannot read ("2023-24", "2020 and 2021/22") + # names no single tax year, however many full years come before it. + for prefix, connector, tail, first, second in itertools.product( + PREFIXES, CONNECTORS, INCOMPLETE_TAILS, YEARS, YEARS + ): + assert title_tax_year(f"{prefix}Tax Year {first}{tail}") is None + title = f"{prefix}Tax Years {first}{connector}{second}{tail}" + assert title_tax_year(title) is None, title + + +# --- the real package tree --------------------------------------------------- + + +def _artifact_declaration(path: Path) -> dict: + """A package's decoded ``artifact`` mapping. + + Decoding, not text search, decides which packages qualify, so a quoted key + or an escaped title cannot hide one. Only the part before the top-level + ``record_sets`` key is parsed, where ``artifact`` sits; parsing every + package in full takes about 50 seconds. The whole file is parsed when the + head has no ``artifact``. + """ + text = path.read_text(encoding="utf-8") + match = _TOP_LEVEL_RECORD_SETS.search(text) + head = safe_load(text[: match.start()]) if match else None + if not isinstance(head, dict) or "artifact" not in head: + head = safe_load(text) + artifact = head.get("artifact") if isinstance(head, dict) else None + return artifact if isinstance(artifact, dict) else {} + + +def _single_tax_year_packages() -> dict[str, int]: + """Pinned packages whose source table title names exactly one tax year.""" + packages = {} + for sub in discover_source_package_dirs(REPO_ROOT / "packages"): + artifact = _artifact_declaration( + REPO_ROOT / "packages" / sub / SOURCE_PACKAGE_FILENAME + ) + if artifact.get("artifact_year") is None: + continue + year = title_tax_year(str(artifact.get("source_table") or "")) + if year is not None: + packages[str(sub)] = year + return dict(sorted(packages.items())) + + +@pytest.mark.parametrize( + "header", + [ + "artifact:\n artifact_year: 2020\n source_table: Tax Year 2020\n", + '"artifact":\n "artifact_year": 2020\n source_table: "Tax\\u0020Year 2020"\n', + "package_id: x\nartifact: {artifact_year: 2020, source_table: 'Tax Year 2020'}\n", + ], +) +def test_artifact_declaration_reads_any_yaml_spelling(tmp_path, header): + for tail in ("record_sets:\n- record_set_id: x\n", '"record_sets": []\n', ""): + path = tmp_path / "source_package.yaml" + path.write_text(header + tail, encoding="utf-8") + artifact = _artifact_declaration(path) + assert artifact["artifact_year"] == 2020 + assert title_tax_year(artifact["source_table"]) == 2020 + path.write_text("record_sets: []\n" + header, encoding="utf-8") + assert _artifact_declaration(path)["artifact_year"] == 2020 + + +SINGLE_TAX_YEAR_PACKAGES = _single_tax_year_packages() + + +def test_real_tree_has_a_single_tax_year_title_to_check(): + # Guards the parametrization below against silently checking nothing. + assert SINGLE_TAX_YEAR_PACKAGES.get("irs_soi/w2_statistics_2020") == 2020 + + +@pytest.mark.parametrize("package_path", sorted(SINGLE_TAX_YEAR_PACKAGES)) +def test_facts_carry_the_tax_year_their_source_table_names(package_path): + package = load_source_package(REPO_ROOT / "packages" / package_path) + table_year = SINGLE_TAX_YEAR_PACKAGES[package_path] + facts = [ + fact_to_mapping(fact) + for fact in package.build_facts(package.artifact.artifact_year) + ] + assert facts, package_path + for fact in facts: + where = (package_path, fact["source_record_id"]) + assert title_tax_year(fact["source"]["source_table"]) == table_year, where + if fact["period"]["type"] != "tax_year": + continue + assert fact["period"]["value"] == table_year, where + for vintage in ( + fact["source"].get("vintage"), + fact["measure"].get("legal_vintage"), + ): + stamped = _TAX_YEAR_VINTAGE.findall(str(vintage or "")) + assert {int(year) for year in stamped} <= {table_year}, (where, vintage) + for identifier in (fact["source_record_id"], fact["layout"]["record_set_id"]): + stamped = {int(year) for year in _TAX_YEAR_ID_TOKEN.findall(identifier)} + assert stamped == {table_year}, (where, identifier) + + +# --- IRS SOI Table 4.B (Form W-2 items) -------------------------------------- + +W2_PACKAGE = "irs_soi/w2_statistics_2020" +W2_WORKBOOK = ( + REPO_ROOT / "db" / "data" / "irs_soi" / "w2_statistics" / "20in04w2all.xlsx" +) +# (record-set slug, row, measure id, column, scale) for every cell the package +# reads from sheet "Table 4.B"; money amounts are in thousands of dollars. +W2_CELLS = ( + ("form_w2_social_security_tips", 13, "return_count", "B", 1), + ("form_w2_social_security_tips", 13, "taxpayer_count", "C", 1), + ("form_w2_social_security_tips", 13, "amount", "D", 1000), + ("form_w2_401k_elective_deferrals", 22, "amount", "D", 1000), + ("form_w2_designated_roth_401k_contributions", 41, "amount", "D", 1000), +) +# What IRS printed, read from the workbook on 2026-10-10. +W2_PUBLISHED = { + "irs_soi.ty2020.form_w2_social_security_tips.box_7_social_security_tips.return_count": 6_038_613, + "irs_soi.ty2020.form_w2_social_security_tips.box_7_social_security_tips.taxpayer_count": 6_105_713, + "irs_soi.ty2020.form_w2_social_security_tips.box_7_social_security_tips.amount": 26_786_522_000, + "irs_soi.ty2020.form_w2_401k_elective_deferrals.box_12_d_401k_elective_deferrals.amount": 277_859_181_000, + "irs_soi.ty2020.form_w2_designated_roth_401k_contributions.box_12_aa_designated_roth_401k_contributions.amount": 32_302_509_000, +} + + +def _w2_facts(year: int) -> list[dict]: + package = load_source_package(REPO_ROOT / "packages" / W2_PACKAGE) + return [fact_to_mapping(fact) for fact in package.build_facts(year)] + + +def test_w2_package_title_is_the_workbooks_own_title_cell(): + """The declared title is the bytes' title, so the year check reads IRS.""" + sheet = openpyxl.load_workbook(W2_WORKBOOK, read_only=True)["Table 4.B"] + printed = sheet["A1"].value + package = load_source_package(REPO_ROOT / "packages" / W2_PACKAGE) + assert printed == package.artifact.source_table == W2_TITLE + assert title_tax_year(printed) == 2020 == package.artifact.artifact_year + + +def test_w2_facts_are_the_published_tax_year_2020_cells(): + """Differential: the package's facts against a direct read of the sheet.""" + sheet = openpyxl.load_workbook(W2_WORKBOOK, read_only=True)["Table 4.B"] + facts = {fact["source_record_id"]: fact for fact in _w2_facts(2020)} + assert {key: fact["value"] for key, fact in facts.items()} == W2_PUBLISHED + read_directly = {} + for record_set, row, measure, column, scale in W2_CELLS: + (key,) = ( + key + for key in facts + if f".{record_set}." in key and key.endswith(f".{measure}") + ) + read_directly[key] = sheet[f"{column}{row}"].value * scale + assert read_directly == W2_PUBLISHED + for fact in facts.values(): + assert fact["period"] == {"type": "tax_year", "value": 2020} + assert fact["source"]["vintage"] == "tax_year_2020" + assert fact["source"]["source_table"] == W2_TITLE + assert fact["source"]["source_file"] == "20in04w2all.xlsx" + assert fact["measure"].get("legal_vintage") in (None, "tax_year_2020") + # The package declares a legal vintage on the tips amount only. + tips_amount = facts[ + "irs_soi.ty2020.form_w2_social_security_tips.box_7_social_security_tips.amount" + ] + assert tips_amount["measure"]["legal_vintage"] == "tax_year_2020" + + +@pytest.mark.parametrize("year", [2019, 2021, 2022, 2023, 2024, 2025]) +def test_w2_package_builds_the_same_tax_year_2020_facts_at_every_year(year): + """``--year`` never relabels the table: 2023 is the year microcosm built.""" + package = load_source_package(REPO_ROOT / "packages" / W2_PACKAGE) + assert artifact_year_restamp_issues(package, year) == [] + + def serialized(facts): + return [json.dumps(fact, sort_keys=True, default=str) for fact in facts] + + assert serialized(_w2_facts(year)) == serialized(_w2_facts(2020))