diff --git a/.github/workflows/axiom-parity-tool.yml b/.github/workflows/axiom-parity-tool.yml new file mode 100644 index 0000000..d10cf3b --- /dev/null +++ b/.github/workflows/axiom-parity-tool.yml @@ -0,0 +1,30 @@ +name: axiom-parity tool + +on: + pull_request: + paths: + - axiom-parity/** + - .github/workflows/axiom-parity*.yml + push: + branches: [main] + paths: + - axiom-parity/** + +jobs: + test: + name: Lint and test axiom-parity + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: axiom-parity + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + - name: Check formatting + run: uvx ruff format --check . + - name: Lint + run: uvx ruff check . + - name: Test + run: uv run --group dev pytest diff --git a/.github/workflows/axiom-parity.yml b/.github/workflows/axiom-parity.yml new file mode 100644 index 0000000..f79f557 --- /dev/null +++ b/.github/workflows/axiom-parity.yml @@ -0,0 +1,120 @@ +# Reusable check: does a policy PR carry a well-formed, verifiable `axiom:` line? +# +# Call it from a country-model repo: +# +# on: +# pull_request: +# types: [opened, edited, synchronize, reopened, ready_for_review] +# jobs: +# axiom-parity: +# uses: PolicyEngine/.github/.github/workflows/axiom-parity.yml@main +# with: +# mode: warn # warn-only rollout; switch to enforce later +# permissions: +# contents: read +# pull-requests: write # for the finder comment; read-only on fork PRs +# +# What it does is documented in CONTRIBUTING.md ("Mirror policy changes in +# Axiom") and axiom-parity/README.md. + +name: Axiom parity + +on: + workflow_call: + inputs: + mode: + description: "warn (never fails the job) or enforce (fails on a missing or unverifiable axiom line)" + type: string + default: warn + package: + description: "Policy package directory; default is the repo name with - replaced by _" + type: string + default: "" + rulespec: + description: "Default rulespec repo; default TheAxiomFoundation/rulespec-" + type: string + default: "" + comment: + description: "Keep a finder comment on the PR with suggested axiom lines" + type: boolean + default: true + tooling-ref: + description: "Ref of PolicyEngine/.github whose axiom-parity package runs" + type: string + default: main + +jobs: + axiom-parity: + name: Axiom parity + runs-on: ubuntu-latest + timeout-minutes: 10 + # pull_request only: its merge ref is what the diff below reads. pull_request_target + # checks out the base branch, so the check would inspect the wrong diff. + if: github.event_name == 'pull_request' + steps: + # The PR and the tool go in separate directories, so nothing in the PR + # can stand in for the tool's code. + - name: Check out the PR merge commit + uses: actions/checkout@v6 + with: + path: pr + fetch-depth: 2 + persist-credentials: false + + - name: Check out the axiom-parity tool + uses: actions/checkout@v6 + with: + repository: PolicyEngine/.github + ref: ${{ inputs.tooling-ref }} + path: tool + sparse-checkout: axiom-parity + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + + - name: Check the axiom line + id: check + env: + GITHUB_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + MODE: ${{ inputs.mode }} + PACKAGE: ${{ inputs.package }} + RULESPEC: ${{ inputs.rulespec }} + run: | + set +e + args=(--repo "$REPO" --pr "$PR" --local-repo pr --base HEAD^1 --head HEAD + --mode "$MODE" --summary "$GITHUB_STEP_SUMMARY" --json "$RUNNER_TEMP/axiom-parity.json" --annotations) + [ -n "$PACKAGE" ] && args+=(--package "$PACKAGE") + [ -n "$RULESPEC" ] && args+=(--rulespec "$RULESPEC") + uv run --quiet --project tool/axiom-parity axiom-parity check "${args[@]}" + status=$? + echo "status=$status" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Suggest axiom lines + if: always() && steps.check.outcome == 'success' + continue-on-error: true + env: + GITHUB_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + PACKAGE: ${{ inputs.package }} + COMMENT: ${{ inputs.comment && github.event.pull_request.head.repo.full_name == github.repository }} + run: | + args=(--repo "$REPO" --pr "$PR" --title "$TITLE" --local-repo pr --base HEAD^1 --head HEAD + --check-json "$RUNNER_TEMP/axiom-parity.json" --summary "$GITHUB_STEP_SUMMARY") + [ -n "$PACKAGE" ] && args+=(--package "$PACKAGE") + [ "$COMMENT" = "true" ] && args+=(--comment) + uv run --quiet --project tool/axiom-parity axiom-parity suggest "${args[@]}" + + - name: Verdict + env: + STATUS: ${{ steps.check.outputs.status }} + run: | + if [ "$STATUS" != "0" ]; then + echo "::error title=Axiom parity::the axiom line is missing or does not verify; see the job summary" + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a64a35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +.venv/ +.pytest_cache/ +.hypothesis/ +*.egg-info/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8640998..cfb1b0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,20 +63,44 @@ Fragment `` drives the [SemVer](http://semver.org/) bump: PolicyEngine rules are also encoded as Axiom RuleSpec, in `TheAxiomFoundation/rulespec-` (for example [rulespec-us](https://github.com/TheAxiomFoundation/rulespec-us) and [rulespec-uk](https://github.com/TheAxiomFoundation/rulespec-uk)). Some country-model PRs change policy: a new program, a parameter or threshold update, an eligibility rule, a bug fix. Those PRs must also leave the same provision correct in the matching rulespec repo. Say how in one line of the PR description: ```text -axiom: encoded-correct # an existing Axiom module already handles the case; cite the companion test that shows it -axiom: encoded # the provision was encoded or repaired alongside this PR -axiom: queued # the signed encoder is blocked; a dispatch-ready encoding issue, labelled pe-parity -axiom: n/a: # infrastructure, data, UI, microsimulation-only or emulator-mapping changes +axiom: encoded-correct () # an existing Axiom module already handles the case +axiom: encoded # the provision was encoded or repaired alongside this PR +axiom: queued # the signed encoder is blocked; a dispatch-ready pe-parity issue +axiom: n/a: : # the change touches no provision of law ``` +A PR that touches several provisions can make several claims, separated by `;` or on separate `axiom:` lines. For example: + +```text +axiom: uk:statutes/ukpga/2004/12/190 encoded-correct (`190.test.yaml::earnings_above_basic_amount`); TheAxiomFoundation/rulespec-uk#367 queued +``` + +The n/a categories are: + +- `infra`: CI, build, caching or simulation plumbing; +- `data`: datasets, calibration or uprating of survey data; +- `ui`; +- `microsim-only`: model structure, inputs, aggregates or behavioural responses; +- `emulator-mapping`; +- `docs-tests`; +- `metadata`: labels, references or units; +- `not-law`: contributed reforms, proposals, or announcements not yet legislated. + +"Axiom doesn't encode this yet" is not an n/a reason. That is what `queued` is for. An n/a claim must be the PR's only claim; if part of a PR needs Axiom work, claim that part and leave the rest to the description. + +An `encoded-correct` claim that names several modules needs a companion test for each one. + Use `queued` only when the signed encoder is blocked; record the blocker in the issue. Each billed encoder run requires separate approval. A `queued` issue must be ready for the encoder to run without further research: -- the module path and corpus citation; +- the module path and corpus citation (a citation path such as `us/statute/26/32/d`, not a corpus release file); - the operative law, quoted verbatim; - the required outputs; +- a `review_finding` that can be pasted into the encoder's input as is; - companion tests whose expected values come from the same external source as the PolicyEngine tests (statute, official form or worksheet, official calculator). -[rulespec-us#1416](https://github.com/TheAxiomFoundation/rulespec-us/issues/1416) is an example. RuleSpec modules come from the signed encoder and are never written by hand. If you are an external contributor and can't do this, write `axiom: needed` and a maintainer will follow up. +Put each element under its own heading: `## Law (verbatim)`, `## Required outputs`, `## review_finding (paste as-is)` and `## Companion tests`. [rulespec-us#1416](https://github.com/TheAxiomFoundation/rulespec-us/issues/1416) is an example. RuleSpec modules come from the signed encoder and are never written by hand. If you are an external contributor and can't do this, write `axiom: needed` and a maintainer will follow up. + +The [Axiom parity workflow](.github/workflows/axiom-parity.yml) checks the line on every PR that changes parameters, variables or reforms. It confirms that each cited issue, PR, module and companion test exists and that queued issues are dispatch-ready. It also comments with suggested lines, found by mapping the changed files' `reference` URLs to Axiom modules and open issues, and drafts a dispatch-ready issue when nothing covers a provision. Run it locally with `uvx --from "git+https://github.com/PolicyEngine/.github#subdirectory=axiom-parity" axiom-parity suggest --help`. ## Peer review diff --git a/axiom-parity/README.md b/axiom-parity/README.md new file mode 100644 index 0000000..8c011f9 --- /dev/null +++ b/axiom-parity/README.md @@ -0,0 +1,126 @@ +# axiom-parity + +Tooling for PolicyEngine's rule that every policy change in a country model is mirrored in Axiom RuleSpec ([CONTRIBUTING, Mirror policy changes in Axiom](../CONTRIBUTING.md#mirror-policy-changes-in-axiom)). Its commands: + +- **`check`** reads a PR's `axiom:` line and verifies what it cites against GitHub. The reusable workflow [`axiom-parity.yml`](../.github/workflows/axiom-parity.yml) runs it on every country-model PR. +- **`suggest`** suggests the line. It maps the changed parameters' and variables' `reference` URLs to corpus citations, looks up RuleSpec modules and open `pe-parity` issues, and drafts a dispatch-ready issue when nothing covers a citation. With `--comment` it keeps one comment on the PR. It only ever updates a comment that the workflow's own account (`github-actions[bot]`, or `--comment-author`) wrote and that opens with the finder's marker. +- **`lint-issue`** and **`backlog`** say whether `pe-parity` issues are dispatch-ready, one issue at a time or across the whole backlog. +- **`corpus-pin`** and **`drain-plan`** plan a drain of that backlog through the signed encoder ([docs/drain.md](docs/drain.md)). They plan; they dispatch nothing. + +## What `check` enforces + +A PR needs a line only when it changes a file under `/parameters/`, `/variables/` or `/reforms/` (Markdown and READMEs there don't count). Each claim on the line is checked: + +| Claim | Passes when | +|---|---| +| `axiom: queued` | The issue exists in a `TheAxiomFoundation/rulespec-*` repo, has the `pe-parity` label, is open (or closed as completed), and is dispatch-ready (see below). | +| `axiom: encoded` | The PR exists and is open or merged. A warning is raised if it carries no encoder manifest under `.axiom/`. | +| `axiom: encoded-correct ()` | Every module named exists on rulespec `main`, and so does any `#rule` cited. For each module, a cited companion test case or file exists and outputs a rule of that module. | +| `axiom: n/a: : ` | It is the PR's only claim, and the category is allowed. For `metadata` and `docs-tests`, no changed policy file may change a parameter value or formula; every changed file is checked. | +| `axiom: needed` | Never passes. It is a placeholder for a maintainer to replace. | + +A line can hold several claims separated by `;` or `|`, or there can be several `axiom:` lines. Every claim must pass. Notes in parentheses after a status are fine. A line ending in a colon continues in the bullet list below it. + +A status is claimed only in the canonical shape: right after the reference or legal citation it is claimed for (`rulespec-us#1416 queued`, `` `us:statutes/26/24/d` encoded-correct ``, `26 USC 129(a)(2) queued`), or opening its clause with the references after it (`queued: rulespec-uk#415`). +- Only separators, closing marks and the words is, are, was, were, now, already, also, both, all, each and fully may stand between the reference and the status. +- Nothing before the status in its clause may deny or condition it (not, never, no, will, may, until, if, pending and the like). +- So "rulespec-us#1416 is not [yet] queued", "rulespec-us#1416 isn’t queued" and "Not in Axiom yet, rulespec-us#1416 queued" claim nothing. A status that stands in prose with a reference after it ("The rates are already encoded-correct: …") is noted in the summary, not claimed. + +**What the check does not do.** It verifies the claims a PR makes. It cannot tell that every provision the PR changes has a claim: a PR that changes two provisions and claims one passes. The finder comment lists every citation the changed files reference, so a reviewer can compare. The n/a categories other than `metadata` and `docs-tests` are the author's statement and a reviewer's to check. + +Modules can be named in any of these ways: + +- the legal id, `us:statutes/26/24/d#ctc_social_security_tax`; +- the module path, `uk/regulations/uksi/2013/376/62.yaml`; +- a corpus citation path, `uk/regulation/uksi/2013/376/32`. A subsection path resolves to the module that covers it. + +Tests can be cited as `32.test.yaml::case_name`, as a test file path, or as a case name in backticks. + +### Allowed n/a categories + +The rule names `infra`, `data`, `ui`, `microsim-only` and `emulator-mapping`. Three more cover policy-path changes that alter no provision of law: + +- `docs-tests`: documentation and tests; +- `metadata`: labels, references and units; +- `not-law`: contributed reforms, proposals, and announcements not yet legislated. + +"Axiom doesn't encode this yet" is never an n/a reason, because that case is what `queued` is for. A reason that says it is rejected unless it names a category explicitly. + +### Dispatch-ready + +A queued issue must let the signed encoder run without further research. Its body and comments together must have: + +- the target module path or legal id; +- the corpus citation path (`us/statute/26/32/d`, not a corpus release file such as `…-dedup.jsonl`); +- the operative law, quoted; +- the required outputs; +- a pasteable `review_finding`; +- companion tests. + +Each element is found under a heading that names it (`## Law (verbatim)`, `## Required outputs`, `## review_finding (paste as-is)`, `## Companion tests`), or after an inline cue that introduces it, such as "Required outputs: `a`, `b`" or "Paste this as `review_finding`:". The lint reports where it found each element. These rules keep it honest: + +- An element with a `TODO` in it is unfinished and counts as missing. The finder's drafts therefore never pass until a person completes them. +- Companion tests must state an expected outcome: "holds", "expected: 250", "→", "= 250", "gives 250", "allowed", a value under an `output:` key, a numeric table cell, a table with an expected column, or a described case followed by its result ("Pay £40,000, loss £10,000: relief £10,000"). Input facts are not one: "input income: 0", the lines under an `input:` key, or a bare "income: 0" or "income is 0". +- A required output is a declared output: a section or cue that lists outputs, or a finding that says "Output `x_y_z`", "Add a Judgment x_y_z" or "Add … to `9.yaml#rule`". "Add an input named x_y_z" declares an input. +- A cue or heading hands the finding over only if it does so now. "`review_finding` will be supplied later:" and "**Review finding (pending):**" hand over nothing, and an inline cue takes the quote or code block right below it, not one further down. +- The `review_finding` is whatever `drain-plan` could paste into the encoder. One extractor decides both, so a lint-ready issue always has dispatch input. + +The lint checks presence, not legal completeness. An issue that leaves a question of interpretation open can still pass. + +## Limits + +The `axiom:` line, the issues and their findings are free text, and the tools read them with rules, not understanding. Where a rule has to guess, it guesses towards the safe side: an issue is not ready, a claim is not made, a module is requested work, a target is not in the pin. The limits that remain: + +- **Coverage.** The check verifies the claims a PR makes, not that every changed provision has one. +- **Claims.** The claim grammar is strict, so its errors fall on the safe side: a claim written in prose is not read. The line then fails for having no claim, or, when it has other claims, that one goes unchecked; the summary notes a status that stands in prose with a reference after it. Its remaining unsafe edge is a status that follows its reference with the denial after it ("rulespec-us#1416 queued? no"), which reads as a claim. +- **Evidence.** A companion test named in the reach of a negation is not evidence: "(not `a`, `b`)" rules out both. A test reference is read whole, so the "::" in "file.test.yaml::case" is not punctuation. Within its own bracket group the reach ends at a colon, a semicolon, a sentence end or a status word, so a test named after one of those counts again; a negation outside the group still reaches into it. +- **Presence, not substance.** The lint finds each element and checks that it is finished. It does not judge whether the law is right, whether the outputs are the right ones, or whether a stated outcome is correct. + - A described case followed by a number after a colon counts as stating its result, and so does a table with a filled-in expected, output or result column. Cells under a column headed as input, given or facts are inputs. + - A heading, label or cue that says the finding is pending, unavailable or to follow hands over nothing. A finding that is simply wrong or thin still counts. + - An output named in a finding counts only in a few declared shapes: "Output Money x_y_z", "Outputs: `x_y_z`", "Judgment x_y_z", "Add `x_y_z` Money …", "Name them x_y_z", "Add … to 9.yaml#rule". A bare "Add x_y_z" doesn't count, nor a name whose clause speaks of an input or that is followed by an input designation ("as a scoped leaf input"). + - A finding that starts on its label's line counts unless its first sentence defers it ("Coming soon when …"), however long it is. +- **Targets.** `drain-plan` reads targets and modules out of findings. It treats a module as requested work unless its own clause marks an import, a deferral, a precedent or a description, and a request in that clause wins over a description. An import counts only when the import word governs the module ("import X", "importing X", "Imports: X", "from X"); "add the import to X" is work on X. A path may be wrapped in code ticks or emphasis, and a program module may sit at the root (`programs/us-ar/tanf/fy-2026.yaml`). It holds an issue when it can't find a citation for requested work. A module it wrongly counts adds a target or a hold. A module a finding only describes ("X currently carries the amount as an input") is not counted, even where the description implies it must change. The encoder owner confirms the targets before dispatching, and its preparation, not the plan, decides what is dispatched. +- **Rulespec state.** Lookups use rulespec `main` today, so a claim can pass because a module landed after the PR merged. + +## Rollout + +Country repos call the workflow with `mode: warn` first. The job summary and annotations show what would fail, and nothing blocks merging. Switch to `mode: enforce`, then make the job a required check in branch protection. + +## Running locally + +```bash +cd axiom-parity +uv run axiom-parity check --repo PolicyEngine/policyengine-uk --pr 2242 \ + --local-repo ../../policyengine-uk --base origin/main --head HEAD +uv run axiom-parity suggest --repo PolicyEngine/policyengine-uk --pr 2242 \ + --local-repo ../../policyengine-uk --base origin/main --head HEAD +uv run axiom-parity lint-issue TheAxiomFoundation/rulespec-us#1416 +uv run axiom-parity backlog --json backlog.json +``` + +Set `GITHUB_TOKEN` (or `GH_TOKEN`). The check makes about 10 to 30 API calls per PR. `suggest` takes shallow, blobless, sparse clones of the rulespec repos it needs. + +## Tests + +```bash +uv run --group dev pytest +``` + +The tests use an in-memory GitHub. Besides example cases, they include property tests: + +- rendered claims and references round-trip through the parser; +- the parser never raises and is deterministic; +- for every subset of sections removed, the lint reports exactly that subset missing; +- a draft the finder writes is never dispatch-ready; +- anything the drain can paste as a `review_finding`, the lint counts (differential); +- descriptive edits never count as behaviour changes; value edits and changed formula locals always do; +- a PR passes if and only if every claim passes; +- warn and enforce modes block on exactly the same findings (differential); +- adding a passing claim never breaks a passing PR; +- a drain batch is ready if and only if every issue is lint-ready and every target is in the pin; +- every drain batch holds one source document and one jurisdiction, each target sits in exactly one batch, and a batch's run count is its citations over 16, rounded up; +- an aside or emphasis never cancels a negation: "is not (…) queued", "is not, …, queued" and "is not **queued**" claim nothing; +- an input is never an expected outcome, however many are listed, and a named input is never a required output; +- the URL mapper never contradicts 427 URL-to-citation pairs checked against the corpus (differential; `tests/fixtures/url_vectors.json`). + +`scripts/replay_history.py` replays the check over merged PRs to measure compliance and calibrate the parser. With `--rulespec-dir` and `--issues-file` it reads rulespec and the open issues from local copies, and uses the API only for closed issues and rulespec PRs. diff --git a/axiom-parity/docs/drain.md b/axiom-parity/docs/drain.md new file mode 100644 index 0000000..0be7d18 --- /dev/null +++ b/axiom-parity/docs/drain.md @@ -0,0 +1,192 @@ +# Draining the pe-parity backlog + +The PR check stops new parity debt from slipping through. This document covers the debt already queued: the open `pe-parity` issues in `TheAxiomFoundation/rulespec-*`, none of which has ever been closed. Figures are as measured on 2026-10-10. Rerun the commands below for current numbers. + +## Where things stand + +| | rulespec-uk | rulespec-us | +|---|---|---| +| Open pe-parity issues (ever closed) | 83 (0) | 175 (0) | +| Pass the dispatch-ready lint | 79 | 84 | +| Lint-ready, every target citation in the pinned corpus release, and no hold on the work it asks for | 17 | 43 | +| Also clear of the encoder owner's blocked-module list | 17 | 25 | +| Also clear of the encoder owner's blocked citations and the pause on manual paths | 15 | 19 | +| Also clear of the encoder owner's dispatch-preparation verdicts | 13 | 8 | +| Also clear of its hold on the rulespec-uk route | 0 | 8 | +| Pinned corpus release | `uk-rulespec-2026-09-07` | `us-rulespec-2026-08-08-obbb-alien-snap` | + +The main lint failures: + +- 85 US issues have no pasteable `review_finding`. 60 of them are among the 71 issues filed on 2026-10-09 (#1554–#1625). +- 21 US issues name no target module. +- 6 US issues have no companion cases with an expected outcome. +- 6 US and 2 UK issues have no required outputs. + +The unit of work is the source document. A signed run takes one primary citation and a bundle of up to 16 more, and the encoder refuses a bundle that leaves the primary's jurisdiction (`parse_source_bundle` in axiom-encode's `prepare_signed_backfill.py`). So the plan gives each document its own run. A document is ready when every issue with a target in it is. An issue whose targets span several documents needs all of them. + +The plan's own checks leave 16 documents: 4 UK and 12 US, holding 17 citations, one run each. Those 16 runs would complete 11 issues. Without the holds of 2026-10-10 (blocked citations, and manual paths paused), 28 documents and 31 citations pass, and their runs would complete 17 issues. + +**Passing the plan is necessary, not sufficient.** On 2026-10-10 the encoder owner prepared each of the 16 documents for dispatch. By its account that means reading the pinned source text against the finding, and checking the existing module and manifest, the import closure and the encoder's admission code. Its verdicts (`prep-verdicts.json`, as it stood at 20:28Z): + +| Verdict | Documents | +|---|---| +| Dispatched the same day, waiting on the signing approval | 4: `us-co/statute/39` (rulespec-us#1530), and `us-nm/statute/7-2-18.13`, `7-2-18.1` and `7-2-39` (#1618) | +| Waits on an encoder fix, axiom-encode#1819 | 5: `us-ok/statute/68-2906`, `us-az/statute/43-1072`, `us-va/statute/58.1` (2 citations), `us-in/statute/6-3-1-11`, `us-in/statute/6-3.1-21-6` | +| A source the finding relies on is not in the pin | 3: `us/statute/42/1397gg`, `us-mo/statute/143.177`, `us-or/statute/316.695` | +| rulespec-uk has no working signed route | 4: `uk/regulation/uksi/2008/794` (rulespec-uk#386), and `uk/statute/ukpga/2021/26`, `2023/1` and `2026/11` (#440) | + +So 4 of the 16 were dispatched that day, and none of the other 12 can be. The plan cannot see what stopped them: how the pinned text is laid out, a second source a finding relies on without citing it in corpus form, or whether the rulespec repo can take a signed run at all. + +**rulespec-uk has no working signed route.** At rulespec-uk `1bdb983` and axiom-encode `63e292c`: + +- `targeted-signed-reencode.yml` reads `.axiom/workflow-toolchain.toml` from the rulespec checkout in its `Checkout RuleSpec-pinned axiom-compose` step, with no fallback when the file is missing. rulespec-uk has no such file: its `.axiom/` holds `toolchain.toml`, `repository-structure.yaml` and the ingest manifests. +- rulespec-uk's `tests/test_repository_layout.py` asserts that `.axiom/encoding-manifests` holds no manifest at all. The encoder owner reports that a signed run always writes one. +- Its checks run with the generated-file guard off (`run-generated-guard: false` in `repository-checks.yml`). + +The encoder owner has queued the route question for Max as one decision. Until it is settled every rulespec-uk issue is held, whatever the plan's other checks say. `drain-plan` carries that as `--blocked-citation-pattern '^uk[-/]'`. + +`drain-plan` reads the verdicts back as holds (`--blocked-citations` takes that file too). With the verdicts and the UK hold applied, it lists no document that is ready and not already dispatched. + +Some lint-ready issues are held for the work they ask for, whatever their citations: + +- 10 ask for a change to an assembled pipeline, which the encoder owner says a signed run does not make (`--work-holds`, below). Examples are the Universal Credit and Housing Benefit composed pipelines and `us/policies/income_tax/taxable_income_pipeline.yaml`. +- 13 ask for work on a module and give no corpus citation for it. Five name a state statute module whose section the issue never cites in corpus form, four a guidance module, three the Universal Credit program file, and one a state resident-core module. +- Two issues, rulespec-uk#390 and #396, are in both groups. + +Two earlier versions of this plan overcounted, at 25 documents and then at 16 documents completing 15 issues. The first grouped an issue under its first citation's document only, so one run could mix jurisdictions, and it read targets from an issue's last comment only. The second took a module as a target only when its own sentence had a verb such as "encode", so a numbered list of modules under one "Encode these:" line went unread (rulespec-uk#429). + +The lint checks that each element is present and finished. It cannot judge whether the law in an issue is settled: an independent read of 30 issues found 3 that pass the lint while leaving a legal question open (rulespec-uk#357, rulespec-us#1517 and #1358). Target citations are read out of free text. The reading errs towards more targets and more holds, not fewer, but it is still a reading: the encoder owner confirms the targets, and reads the issue, before dispatching it. + +### The signed encoder today + +Model spend is taken from the "Model spend for this run" summary that every `targeted-signed-reencode` run writes. Prices come from `axiom-encode/src/axiom_encode/harness/pricing_rates.toml` v6 (GPT-6 Luna $0.10 in / $0.50 out per million tokens; GPT-6 Sol $2 / $10). + +| Window | Completed runs | Succeeded | Model spend | Mean per run | Median / p90 / max per priced run | Spend per success | +|---|---|---|---|---|---|---| +| 2026-09-25 to 10-10 (GPT-6 Luna, escalating to Sol) | 121 | 11 (9%) | $7.08 | $0.058 | $0.058 / $0.150 / $0.496 | $0.64 | +| 2026-09-01 to 09-24 (GPT-5.6 Terra / Sol) | 332 | 51 (15%) | $88.05 | $0.265 | $0.293 / $0.742 / $2.69 | $1.73 | + +A run with no spend summary stopped before the model step; it counts as $0 in the mean. + +The 453 runs covered 98 citations. Four of those citations have a manifest record on rulespec-us `main` (at `16a6677`, counting records nested inside a manifest file): + +| Citation | Runs (succeeded) | Model spend | Manifest generated | By run | +|---|---|---|---|---| +| `us/statute/7/2015/f` | 28 (9) | $4.77 | 2026-09-14, GPT-5.6 Sol | 34877389801 | +| `us/guidance/irs/rev-proc-2025-32/page-15` | 8 (1) | $1.46 | 2026-09-15, GPT-5.6 Terra | 34912099966 | +| `us/regulation/7/273/4` | 88 (9) | $38.12 | 2026-09-25, no model named | 36166100091 | +| `us/guidance/usda/fns/snap-fy2024-cola/page-2` | 12 (6) | $0.97 | 2026-10-01, GPT-6 Sol | 36924316695 | + +A record is matched to a run by its citation and by its `generated_at` falling inside the run. The 273.4 record names no model: its run promoted an already reviewed candidate. The SNAP COLA record sits under `replacement_manifest` in `us/policies/usda/snap/fy-2024-cola/income-eligibility-standards.json` and landed in rulespec-us `201b0e5` on 2026-10-01. + +So one model encode from the 121 GPT-6 runs is on `main`. rulespec-uk `main` has no encoding manifests. Draft PRs from the signed backfill merged in 14 of 68 cases in rulespec-us and 0 of 35 in rulespec-uk. + +**Money is not what limits the drain.** These are: + +1. **The encoder fails most runs.** The Axiom eng hub classified all 106 failures from 2026-09-24 to 10-10 (`~/reviews/axiom-hub/jobs/encode-throughput/work-a2/REPORT-A3.md`): + - Source-completeness refusals explain 49. + - Missing or repealed corpus source text explains 8. + - 82 failed runs escalated to Sol and still failed. The report counts 83 escalations in them: 79 from GPT-6 Luna and 4 from GPT-5.6 Terra. The run ended at the same gate before and after escalation in 27 of the 82, and at a different one in 55. + - A failed run waited a median of 28 minutes for the protected job to start (86 minutes for runs since 2026-09-29), against 14 minutes of encoding. +2. **The encoder pin lags.** The encoder runs at axiom-encode `main`, and rulespec CI requires each manifest's `validation_execution.axiom_encode` to equal the pin. The US pin, in `.axiom/workflow-toolchain.toml`, was 105 commits behind; the UK pin, hardcoded in `repository-checks.yml`, was 1,909 behind. +3. **Every run waits on a human click.** The `production-signing` environment requires MaxGhenis or PavelMakarchuk as reviewer, including for queue-dispatched runs, and d350 bars agents from approving it. +4. **Corpus pins.** About half the backlog cites provisions missing from the pinned release. d1176 (approved 2026-10-10) mirrors the W6 release and re-pins rulespec-us, which should clear many of them. +5. **Approvals per issue.** Billed runs were approved one issue at a time (d442, d453, d467, d694, d784, d867, d954, d971, d975, d996, d1016, d1101, d1159, and d1142 for its own batch). On 2026-10-10 Max approved d1262, a standing approval on the terms below, which replaces them. + +## The drain + +1. **Intake.** The PR check rejects a `queued` claim unless its issue exists, carries `pe-parity`, and passes the dispatch-ready lint. New debt therefore arrives ready to dispatch. +2. **Corpus pin.** `axiom-parity corpus-pin` lists every citation the encoder could read in the release a rulespec clone pins. It follows the encoder owner's procedure: + - read the release from `.axiom/toolchain.toml`, and the corpus commit from `.axiom/workflow-toolchain.toml` (rulespec-us) or `repository-checks.yml` (rulespec-uk); + - read the release manifest's selected scopes; + - read those scopes' provision rows from `axiom-corpus` with `git`, keeping rows that have an operative body and are not marked repealed (the encoder refuses a repealed source). + It takes about two seconds and makes no API calls. +3. **Plan.** `axiom-parity drain-plan --corpus-citations pin.txt` lints every open issue and extracts its target citations and pasteable `review_finding`. + - The finding is every finding block in the issue, body first and comments after, joined in order. Comments usually add to the body's finding ("it adds to step 1's"), so the dispatcher gets all of them. + - The targets are: + - the citation the issue labels as the one to encode; + - every citation a finding names; + - the citation on any line that only pairs a module with a citation ("`us-nm/statutes/7-2-18/13.yaml` — `us-nm/statute/7-2-18.13`"); + - the citation of every module a finding names, unless the module's own clause says it is something else: an import ("import X", "from X"), a deferral ("do not import X", "deferred dependency on X"), a precedent ("mirroring the existing X", "as X does") or a description ("X currently carries …", "keep X unchanged"). A request in the clause ("encode", "add", "repair", "compose" and the like) wins over a description: "encode the deferred X" and "add it to the guarantee that X composes" ask for work on X. + - A module's citation is the one the issue gives for it or for the provision above it, else the one its path spells (federal US and UK statutes and regulations only; a state module's path respells its section, and a `policies/` or `programs/` path spells no citation). When neither exists, the issue is held and the plan names the module. + - `a/b/{1,2}` is read as two paths, and "`6.yaml`, `9.yaml`" after a full path as its siblings. + - Each target is filed under its source document: an Act or SI, a US Code section, a CFR part, a state code. A document is ready only if every issue with a target in it passes the lint, has a finding to paste, and has every target in the pin. The plan ranks the documents and prices the wave. + + `--blocked-modules` takes the encoder owner's list of modules that must not be written, extended or imported. Its first use is the obsolete `source_verification.values` field: 17 modules on rulespec-us `main` at `16a6677` carry it under `module.source_verification`, and a target that imports one fails to compile. The list holds those 17 plus the modules that reference them (35 directly, 64 transitively). An issue is blocked when the list contains any of: + - the module it declares, or one a finding tells the encoder to use; + - the module a target's path gives, or a module above it; + - with `--rulespec-dir`, a module on `main` that cites the target or a provision above it. + + A precedent or a module the finding only describes is not at stake. On the lint-ready, pinned issues this blocks 18 in rulespec-us and none in rulespec-uk, including the IRC §1 group. + + `--blocked-citations` takes the encoder owner's list of citations to hold, each with a reason and whether paths below it are held too. It is repeatable, and it also reads the owner's dispatch-preparation verdicts, where every row (blocked, waiting for a ruling, or already dispatched) holds its citation. On 2026-10-10 that list had 351 entries: sources that change in the W6 corpus release, Title 26 text that selects on filing status, and paths missing from the pin or the corpus. + + `--work-holds` takes the encoder owner's list of modules that a signed run does not edit, with its rule for why. Its first use is assembled pipelines: modules composed from other modules, where the change an issue asks for is a reviewed hand edit. An issue is held when a finding, or a line pairing a module with a citation, asks for work on a listed module. An issue that only imports from one is not held. The plan prints the owner's evidence for each module and its rule once. On 2026-10-10 the list had 261 modules (254 in rulespec-us, 7 in rulespec-uk); 28 of them (21 US, 7 UK) are listed on their file name alone. + + `--blocked-citation-pattern` blocks a class of source by pattern. The encoder owner asked for `/manual/` on 2026-10-10, until a fix for masks that hide table values on manual paths (axiom-encode#1821) merges. + +Steps 1 to 3 are what the tooling does. Steps 4 to 10 are a procedure for the encoder owner; nothing in this package dispatches, approves, links or closes anything. + +The plan's order is a starting point. On 2026-10-10 the encoder owner added three constraints the tooling does not see: + +- hold three of the five pilot documents (Oklahoma, Arizona, Virginia) until axiom-encode#1819 merges, because a signed run is locked to the encoder at its dispatch commit; +- treat status-selecting Title 26 provisions as blocked until the filing-status chain (26 USC 2, 7703, 6013, 152, 151) is encoded; +- encode Ohio R.C. 5747.02 before the W6 corpus re-pin or wait for a successor release (d1251). +4. **Pin-bump gate.** Before each wave, the rulespec repo's encoder pin must equal axiom-encode `main`. If it doesn't, the encoder owner lands a pin bump first. Moving the UK pin into `.axiom/workflow-toolchain.toml`, as US already does, makes that a one-file PR in both repos. rulespec-uk needs that file before the signed workflow can run there at all (see above). +5. **Pilot.** Encode 5 ready documents, chosen by the encoder owner from the top of the plan once the failure taxonomy's fixes are in. The drain proceeds only if at least 3 of the 5 produce a rulespec PR that: + - passes the required `validate` gate (generated-guard, proofs, companion tests); + - carries the issue's companion cases. +6. **Weekly waves.** The encoder owner dispatches up to the weekly cap of `targeted-signed-reencode.yml` runs, one per source document. A run's bundle (`source_bundle_json`) can carry up to 16 citations (`MAX_SOURCE_BUNDLE_CITATIONS` in axiom-encode's `prepare_signed_backfill.py`), all from the primary citation's jurisdiction, so a document's provisions share one run and one signing approval. The plan counts 16 citations a run, primary included. Each run uses the citations, `country`, `rulespec_ref` (rulespec `main` HEAD), the `corpus_ref` and `rules_engine_ref` from `workflow-toolchain.toml`, the issues' `review_finding`, and `open_pr: true`. Keep at most 4 in flight. The attempt budget (3 consecutive failures per citation in 7 days, `scripts/enforce_attempt_budget.py`) still applies. +7. **Signing gate.** Max or Pavel approves the week's waiting runs in one pass: + + ```bash + env_id=$(gh api repos/TheAxiomFoundation/axiom-encode/environments/production-signing --jq .id) + for run in $(gh run list -R TheAxiomFoundation/axiom-encode -w targeted-signed-reencode.yml --status waiting --json databaseId --jq '.[].databaseId'); do + gh api -X POST "repos/TheAxiomFoundation/axiom-encode/actions/runs/$run/pending_deployments" \ + -F "environment_ids[]=$env_id" -f state=approved -f comment="pe-parity wave $(date +%F)" + done + ``` + + Agents never run this (d350). A queue-only signing environment, with no reviewers and accepting only bot runs from a reviewed, merged queue manifest, would remove the clicks. That is a security trade-off for Max to decide separately. +8. **Verify.** Run three checks: + - the rulespec PR's required `validate` check; + - compare the issue's companion cases with the generated `.test.yaml`; + - for modules that axiom-oracles covers, run `uv run python scripts/run_comparison.py --summary` with `AXIOM_RULESPEC__ROOT` pointing at the PR head. The suite comes from `comparisons/affected_map.json`. +9. **Close on merge.** The encoder's PR body has no issue link, and the workflow's 25 dispatch inputs are all used. So the dispatcher adds `Closes #` to each draft PR body before marking it ready (the plan JSON lists the issues per document), and GitHub closes the issue on merge. A later change could append those lines from the queue manifest in axiom-encode. +10. **Report.** The encoder owner posts a weekly line: runs, spend, PRs opened and merged, issues closed, plus the next plan. That sets next week's cap. + +## Costing a wave + +The scarce resource is the signing approval, so the budget counts approvals. Ten approvals a week cover up to 160 citations. Every measured run so far encoded one citation, so the per-citation figures are: + +| | Per citation | The 17 citations that pass the plan today | A full week (160 citations) | +|---|---|---|---| +| Mean | $0.058 | $0.99 | $9.28 | +| p90 | $0.150 | $2.55 | $24.00 | +| Most any run has cost | $0.496 | $8.43 | $79.36 | + +Bundled runs have not been measured yet; the pilot will give the first figures. A hard cap of $40 a week sits above the p90 for a full week. + +The 16 documents that pass the plan hold at most 2 citations each, and only 4 of them survived preparation. So for now the drain is limited by encoder fixes, sources, the rulespec-uk route and the issues themselves, well before it reaches 10 approvals a week or the 160-citation ceiling. + +The measured spend per successful run is $0.64. One of the 11 successful GPT-6 runs has its output on `main`, which puts the window's whole spend, $7.08, against one landed encode. One is too few to call a rate; the pilot's merged PRs will give the first usable figure. + +## Commands + +```bash +uv run axiom-parity corpus-pin --rulespec-dir ../rulespec-us --corpus-dir ../axiom-corpus --out pin-us.txt +uv run axiom-parity corpus-pin --rulespec-dir ../rulespec-uk --corpus-dir ../axiom-corpus --out pin-uk.txt +cat pin-us.txt pin-uk.txt > pin.txt +uv run axiom-parity drain-plan --corpus-citations pin.txt \ + --blocked-modules blocked-modules.json \ + --blocked-citations blocked-citations.json --blocked-citations prep-verdicts.json \ + --blocked-citation-pattern /manual/ --blocked-citation-pattern '^uk[-/]' \ + --work-holds assembled-pipelines.json \ + --rulespec-dir TheAxiomFoundation/rulespec-us=../rulespec-us \ + --rulespec-dir TheAxiomFoundation/rulespec-uk=../rulespec-uk \ + --weekly-runs 10 --json plan.json --summary plan.md +uv run axiom-parity backlog --json backlog.json +``` + +A pin file lists the citations the encoder can read. A line starting with `!` is a row the release marks repealed, which the encoder refuses even when rows below it have text. `--rulespec-dir` reads the checkout's working tree, so point it at a clean checkout of `main`. + diff --git a/axiom-parity/pyproject.toml b/axiom-parity/pyproject.toml new file mode 100644 index 0000000..d5cb0b2 --- /dev/null +++ b/axiom-parity/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "axiom-parity" +version = "0.1.0" +description = "Checks that PolicyEngine country-model PRs mirror policy changes in Axiom RuleSpec, and finds the Axiom modules a change touches." +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +dependencies = ["pyyaml>=6"] + +[project.scripts] +axiom-parity = "axiom_parity.cli:main" + +[dependency-groups] +dev = ["pytest>=8", "hypothesis>=6.100", "ruff>=0.6"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/axiom_parity"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +pythonpath = ["tests"] + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["E501"] diff --git a/axiom-parity/scripts/replay_history.py b/axiom-parity/scripts/replay_history.py new file mode 100644 index 0000000..747bbca --- /dev/null +++ b/axiom-parity/scripts/replay_history.py @@ -0,0 +1,158 @@ +"""Replay the check over merged PRs, to calibrate it and measure compliance. + + GH_TOKEN=$(gh auth token) uv run python scripts/replay_history.py \ + --repo PolicyEngine/policyengine-uk --since 2026-09-26 \ + --clone ../policyengine-uk --out uk.json + +Rulespec lookups use rulespec main today, not as it was at merge time, so a +module added after a PR merged can make an old claim pass. + +To replay without spending API quota, read rulespec from local clones and the +open pe-parity issues from saved ``gh issue list --json number,title,body,comments,labels`` +output. Only references to anything else (a closed issue, a rulespec PR) still +go to the API:: + + uv run python scripts/replay_history.py --repo PolicyEngine/policyengine-us \ + --since 2026-09-26 --input merged-us.json --clone ../policyengine-us \ + --rulespec-dir TheAxiomFoundation/rulespec-us=../rulespec-us \ + --issues-file TheAxiomFoundation/rulespec-us=issues-us.json --out us.json +""" + +from __future__ import annotations + +import argparse +import collections +import json +import re +import subprocess +import urllib.parse + +from axiom_parity.check import Context, run_check +from axiom_parity.diff import ApiDiff, LocalDiff +from axiom_parity.github import GitHub, _urllib_transport +from axiom_parity.policy_paths import PolicyPaths, country_for_repo, package_for_repo +from axiom_parity.rulespec import LocalSource, rulespec_repo_for_country + +_ISSUE_URL = re.compile(r"^/repos/([^/]+/[^/]+)/issues/(\d+)(/comments)?$") + + +def saved_issue_transport(saved: dict[str, dict[int, dict]]): + """Answer issue and comment lookups from saved issues; pass anything else to the API.""" + + def transport(url: str, headers: dict[str, str]): + parsed = urllib.parse.urlparse(url) + m = _ISSUE_URL.match(urllib.parse.unquote(parsed.path)) + issue = saved.get(m.group(1), {}).get(int(m.group(2))) if m else None + if issue is None: + return _urllib_transport(url, headers) + comments = issue.get("comments") if isinstance(issue.get("comments"), list) else [] + if m.group(3): + page = int(urllib.parse.parse_qs(parsed.query).get("page", ["1"])[0]) + rows = [{"id": c.get("id", n), "body": c.get("body") or ""} for n, c in enumerate(comments, 1)] + return 200, {}, json.dumps(rows if page == 1 else []).encode() + row = { + "number": issue["number"], + "title": issue.get("title", ""), + "body": issue.get("body") or "", + "labels": [{"name": lab["name"]} for lab in issue.get("labels") or [{"name": "pe-parity"}]], + "state": "open", + "comments": len(comments), + "html_url": f"https://github.com/{m.group(1)}/issues/{issue['number']}", + "created_at": issue.get("createdAt", ""), + } + return 200, {}, json.dumps(row).encode() + + return transport + + +def merged_prs(repo: str, since: str) -> list[dict]: + out = subprocess.run( + [ + "gh", + "pr", + "list", + "-R", + repo, + "--state", + "merged", + "--search", + f"merged:>={since}", + "--limit", + "1000", + "--json", + "number,title,body,files,mergedAt,mergeCommit", + ], + check=True, + capture_output=True, + text=True, + ).stdout + return json.loads(out) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--repo", required=True) + ap.add_argument("--since", required=True) + ap.add_argument("--input", help="use a saved `gh pr list` JSON instead of fetching") + ap.add_argument("--out", required=True) + ap.add_argument("--clone", help="local clone of the PE repo; diffs each merge commit against its first parent") + ap.add_argument("--rulespec-dir", action="append", default=[], metavar="REPO=PATH", help="local rulespec clone") + ap.add_argument("--rulespec-ref", default="origin/main", help="ref to read in the local rulespec clones") + ap.add_argument("--issues-file", action="append", default=[], metavar="REPO=PATH", help="saved open issues") + args = ap.parse_args() + + prs = json.load(open(args.input)) if args.input else merged_prs(args.repo, args.since) + saved = { + repo: {i["number"]: i for i in json.load(open(path))} + for repo, path in (item.split("=", 1) for item in args.issues_file) + } + gh = GitHub(transport=saved_issue_transport(saved)) if saved else GitHub() + paths = PolicyPaths(package_for_repo(args.repo)) + rulespec = rulespec_repo_for_country(country_for_repo(args.repo)) + sources: dict = { + repo: LocalSource(path, repo, args.rulespec_ref) + for repo, path in (item.split("=", 1) for item in args.rulespec_dir) + } + rows = [] + for pr in sorted(prs, key=lambda p: p["number"]): + files = [f["path"] for f in pr["files"]] + diff = None + if paths.policy_files(files): + try: + merge = (pr.get("mergeCommit") or {}).get("oid") + if args.clone and merge: + diff = LocalDiff(args.clone, f"{merge}^1", merge) + else: + diff = ApiDiff(gh, args.repo, pr["number"]) + files = diff.changed_files() + except Exception as err: # noqa: BLE001 - calibration keeps going + print(f"#{pr['number']}: diff unavailable ({err})") + ctx = Context(gh=gh, default_rulespec=rulespec, sources=sources, read_diff=diff.read if diff else None) + report = run_check(repo=args.repo, pr=pr["number"], body=pr["body"], changed_files=files, ctx=ctx, paths=paths) + row = report.to_json() + row["title"] = pr["title"] + row["merged_at"] = pr.get("mergedAt") + rows.append(row) + status = "skip" if not report.applies else ("PASS" if not report.would_fail else "FAIL") + codes = sorted( + {f["code"] for f in row["findings"]} + | {f["code"] for c in row["claims"] for f in c["findings"] if f.get("blocking")} + ) + print(f"#{pr['number']} {status} {codes}") + json.dump(rows, open(args.out, "w"), indent=2) + applies = [r for r in rows if r["applies"]] + passed = [r for r in applies if not r["would_fail_enforced"]] + print(f"\n{args.repo}: {len(rows)} merged, {len(applies)} change policy files, {len(passed)} would pass") + codes = collections.Counter( + f["code"] + for r in applies + for f in r["findings"] + [g for c in r["claims"] for g in c["findings"]] + if f.get("blocking") + ) + for code, n in codes.most_common(): + print(f" {code}: {n}") + print(f"API calls: {gh.calls}") + + +if __name__ == "__main__": + main() diff --git a/axiom-parity/src/axiom_parity/__init__.py b/axiom-parity/src/axiom_parity/__init__.py new file mode 100644 index 0000000..0fb0a3e --- /dev/null +++ b/axiom-parity/src/axiom_parity/__init__.py @@ -0,0 +1,10 @@ +"""Axiom parity tooling for PolicyEngine country models. + +PolicyEngine's contributing rule requires every policy change in a country +model to leave the same provision right in the matching Axiom RuleSpec repo, +and every such PR to say how in one ``axiom:`` line. This package parses that +line, verifies what it cites against GitHub, lints ``pe-parity`` issues for +dispatch readiness, and suggests the line for a given change. +""" + +__version__ = "0.1.0" diff --git a/axiom-parity/src/axiom_parity/axiom_line.py b/axiom-parity/src/axiom_parity/axiom_line.py new file mode 100644 index 0000000..7be94a2 --- /dev/null +++ b/axiom-parity/src/axiom_parity/axiom_line.py @@ -0,0 +1,433 @@ +"""Parse ``axiom:`` lines in a PR description into claims. + +The contributing rule asks for one line:: + + axiom: encoded-correct # cite the companion test too + axiom: encoded + axiom: queued + axiom: n/a: + +In practice a PR that touches several provisions writes several claims, on +one line separated by ``;`` or ``|``, or on several ``axiom:`` lines, often +with a parenthetical note after each status. This module splits a line into +claims, one per status keyword, and collects the references each claim cites. +Status keywords inside parentheses or backticks are notes, not claims. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from .refs import LegalRef, RulespecRef, TestRef, norm_dashes, parse_legal_refs, parse_rulespec_refs, parse_test_refs + +ENCODED_CORRECT = "encoded-correct" +ENCODED = "encoded" +QUEUED = "queued" +NA = "n/a" +NEEDED = "needed" +STATUSES = (ENCODED_CORRECT, ENCODED, QUEUED, NA, NEEDED) + +# "debt" was the vocabulary before 2026-09-26; it means queued. +LEGACY_ALIASES = {"debt": QUEUED} + +_LINE = re.compile( + r"^[ \t]*(?:[-*+][ \t]+|>[ \t]*|\d+\.[ \t]+)*" # list bullets, quotes + r"(?:\*\*|__)?[ \t]*axiom[ \t]*(?:\*\*|__)?[ \t]*:[ \t]*(?:\*\*|__)?" + r"(?P.*)$", + re.IGNORECASE | re.MULTILINE, +) +_HTML_COMMENT = re.compile(r"", re.DOTALL) +_PLACEHOLDER = re.compile(r"<\s*(legal id|rulespec[^>]*|reason|category)\s*>", re.IGNORECASE) +_STATUS_WORD = re.compile(r"(? str: + """The text with every emphasis mark gone, however deeply nested: ``***queued***`` is ``queued``. + + An asterisk means nothing else on an axiom line. Underscores go only at a + word's edge, so rule and test names keep theirs. + """ + text = text.replace("*", "") + while True: + stripped = _UNDERSCORE_EMPHASIS.sub("", text) + if stripped == text: + return text + text = stripped + + +_NA_START = re.compile( + r"^\s*(?:\*\*|__|`)?n\s*/\s*a(?:\*\*|__|`)?(?![\w])\s*(?P.*)$", re.IGNORECASE | re.DOTALL +) +_NEEDED_START = re.compile(r"^\s*(?:\*\*|`)?needed(?:\*\*|`)?(?![\w-])\s*(?P.*)$", re.IGNORECASE | re.DOTALL) + + +@dataclass +class Claim: + """One status claim and the references written with it.""" + + status: str + text: str + line_no: int + rulespec_refs: list[RulespecRef] = field(default_factory=list) + legal_refs: list[LegalRef] = field(default_factory=list) + test_refs: list[TestRef] = field(default_factory=list) + na_reason: str | None = None + legacy_keyword: str | None = None + + +@dataclass +class AxiomLine: + raw: str + line_no: int + claims: list[Claim] = field(default_factory=list) + problems: list[str] = field(default_factory=list) # fail the check + notes: list[str] = field(default_factory=list) # informational + + +def clean_body(body: str | None) -> str: + """Drop HTML comments (the PR template's guidance lives in one).""" + text = (body or "").replace("\r\n", "\n") + return _HTML_COMMENT.sub("", text) + + +def _fenced_lines(text: str) -> set[int]: + """Line numbers (1-based) that sit inside fenced code blocks.""" + inside, out, fence = False, set(), None + for i, line in enumerate(text.split("\n"), start=1): + m = re.match(r"^[ \t]*(```|~~~)", line) + if m and (not inside or m.group(1) == fence): + inside, fence = (not inside), m.group(1) + continue + if inside: + out.add(i) + return out + + +_BULLET = re.compile(r"^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?P\S.*)$") + + +def find_lines(body: str | None) -> list[tuple[int, str, list[str]]]: + """Return (line number, text after ``axiom:``, continuation bullets). + + Authors often put the line in a code block, as CONTRIBUTING shows it, so + fenced lines count. A fenced line that still has a template placeholder + is an example, not a claim, and is skipped. A line ending in a colon + continues in the bullet list right below it ("The rest is queued:"). + """ + text = clean_body(body) + fenced = _fenced_lines(text) + lines = text.split("\n") + out = [] + for m in _LINE.finditer(text): + line_no = text.count("\n", 0, m.start()) + 1 + rest = m.group("rest").strip() + if line_no in fenced and _PLACEHOLDER.search(rest): + continue + items: list[str] = [] + if rest.endswith(":"): + for nxt in lines[line_no:]: + b = _BULLET.match(nxt) + if not b: + break + items.append(b.group("item").strip()) + out.append((line_no, rest, items)) + return out + + +def _depth_map(text: str) -> list[int]: + """Nesting depth of each character: parentheses, brackets and backticks.""" + depth = 0 + tick = False + out = [] + for ch in text: + if ch == "`": + out.append(depth + 1) + tick = not tick + elif tick: + out.append(depth + 1) + elif ch in "([": + out.append(depth) + depth += 1 + elif ch in ")]": + depth = max(0, depth - 1) + out.append(depth) + else: + out.append(depth) + return out + + +def _split_top(text: str, pattern: re.Pattern[str]) -> list[str]: + """Split ``text`` on ``pattern`` where it occurs at nesting depth zero.""" + depths = _depth_map(text) + parts, last = [], 0 + for m in pattern.finditer(text): + if depths[m.start()] == 0: + parts.append(text[last : m.start()]) + last = m.end() + parts.append(text[last:]) + return [p for p in parts if p.strip()] + + +_SEPARATOR = re.compile(r"\s*(?:;|\|\||\s\|\s)\s*") +_SENTENCE = re.compile(r"(?<=[.)])\s+(?=[A-Z`*])") + + +def _status_words(text: str) -> list[re.Match[str]]: + """Status keywords at nesting depth zero: inside brackets or backticks they are notes.""" + depths = _depth_map(text) + return [m for m in _STATUS_WORD.finditer(text) if depths[m.start()] == 0] + + +def _ends_with_reference(core: str) -> bool: + """Does ``core`` end with a rulespec or legal reference? + + The last token is parsed on its own, because the parsers fold repeats of + one reference together ("#403" and a URL to #403's comment are one ref); + the whole text is parsed too, for a relative reference ("/4A") whose base + comes earlier. + """ + # A Markdown link reads as its text: "[rulespec-uk#380](https://…/issues/380) queued". + core = re.sub(r"\]\([^()\s]*\)?$", "", core) # the closing ")" may already be gone + token = core.split()[-1].lstrip("([{`'\"") + for found in (parse_rulespec_refs(token), parse_legal_refs(token)): + if any(r.start == 0 and len(r.raw) == len(token) for r in found): + return True + return any(r.start + len(r.raw) == len(core) for r in parse_legal_refs(core)) + + +def _claimed(piece: str, m: re.Match[str]) -> bool: + """Does ``piece`` claim the status ``m``? + + It does when nothing before the status denies it, and the status follows + a reference or a citation directly, or opens the piece ("queued: + rulespec-uk#415"). "rulespec-us#1416 is not [yet] queued" and + "rulespec-us#1416 isn’t queued" claim nothing: a word other than the few in + ``_CLAIM_TAIL`` stands between the reference and the status. + """ + head = piece[: m.start()] + if _DENIES.search(head): + return False + core = _CLAIM_TAIL.sub("", head) + if not core.strip(" \t>-+#.,([{"): + return True # the status opens its piece; its references follow it + if _ends_with_reference(core): + return True + return bool(_CITATION_TOKEN.fullmatch(core.split()[-1])) + + +def _status_hits(text: str) -> list[re.Match[str]]: + """The status keywords ``text`` claims, each judged within its own piece.""" + hits = [] + for piece, offset in _pieces(text): + words = _status_words(piece) + if words and _claimed(piece, words[-1]): + m = words[-1] + hits.append(_STATUS_WORD.match(text, offset + m.start())) + return hits + + +def _first_status(text: str) -> str | None: + hits = _status_hits(text) + if not hits: + return None + word = hits[0].group(1).lower() + return LEGACY_ALIASES.get(word, word) + + +_NOTE = re.compile(r"\s*\([^()]*(?:\([^()]*\)[^()]*)*\)") + + +def _pieces(sentence: str) -> list[tuple[str, int]]: + """(piece, offset): the sentence cut after every top-level status keyword. + + Each piece runs from the end of the previous status (and the parenthetical + note right after it) to the end of its own; the last piece takes the rest + of the sentence. So "rulespec-us#9 is not encoded, rulespec-us#1416 queued" + is two pieces, and the denied first one keeps its reference to itself. + """ + words = _status_words(sentence) + if len(words) <= 1: + return [(sentence, 0)] + out, start = [], 0 + for n, w in enumerate(words): + end = len(sentence) + if n + 1 < len(words): + end = w.end() + note = _NOTE.match(sentence, end) + if note: + end = note.end() + out.append((sentence[start:end], start)) + start = end + return out + + +def _merge_statusless(sentences: list[str]) -> list[str]: + """Attach sentences with no status keyword to the claim before them. + + "X encoded-correct. Its companion test is `t`." is one claim. + """ + out: list[str] = [] + for sentence in sentences: + if out and not _status_words(sentence): + out[-1] = f"{out[-1]} {sentence}" + elif out and not _status_words(out[-1]): + out[-1] = f"{out[-1]} {sentence}" + else: + out.append(sentence) + return out + + +_NA_INLINE = re.compile( + r"(?:\s*(?:\band\b|[.,])\s*|\s+)(?=(?:\*\*|__|`)?n\s*/\s*a(?:\*\*|__|`)?\s*[:(—–-])", re.IGNORECASE +) + + +def _segments(rest: str) -> list[str]: + """Split a line into pieces that each hold at most one status keyword.""" + out = [] + # "X queued and n/a: metadata" and "X queued. n/a: metadata": an n/a that + # opens a clause is its own claim wherever it sits. + parts = [] + for seg in _split_top(rest, _SEPARATOR): + parts.extend(_split_top(seg, _NA_INLINE)) + for seg in parts: + if len(_status_words(seg)) <= 1: + out.append(seg) + continue + for sentence in _merge_statusless(_split_top(seg, _SENTENCE)): + out.extend(piece for piece, _ in _pieces(sentence)) + return out + + +def _claim(status: str, text: str, line_no: int, legacy: str | None = None) -> Claim: + return Claim( + status=status, + text=text.strip(), + line_no=line_no, + rulespec_refs=parse_rulespec_refs(text), + legal_refs=parse_legal_refs(text), + test_refs=parse_test_refs(text), + legacy_keyword=legacy, + ) + + +def _na_claim(text: str, line_no: int) -> Claim | None: + m = _NA_START.match(text) + if not m: + return None + claim = _claim(NA, text, line_no) + claim.na_reason = re.sub(r"^[\s:\-—–]+", "", m.group("reason").strip()).strip() + return claim + + +def _claims_from(text: str, line_no: int) -> list[Claim]: + claims = [] + for seg in _segments(text): + na = _na_claim(seg, line_no) + if na: + # "n/a: metadata; rulespec-us#1 queued" keeps both claims, and so + # does an n/a whose own sentence goes on to queue something. + claims.append(na) + claims.extend(c for c in _claims_from(_NA_START.match(seg).group("reason"), line_no) if c.status != NA) + continue + words = _status_words(seg) + if not words or not _claimed(seg, words[-1]): + continue + word = words[-1].group(1).lower() + status = LEGACY_ALIASES.get(word, word) + claims.append(_claim(status, seg, line_no, legacy=word if word in LEGACY_ALIASES else None)) + return claims + + +def _has_refs(text: str) -> bool: + return bool(parse_rulespec_refs(text) or parse_legal_refs(text)) + + +def _unclaimed(rest: str) -> list[str]: + """Status keywords that stand in prose, with a reference after them but none just before.""" + out = [] + for piece in _segments(rest): + words = _status_words(piece) + if not words or _claimed(piece, words[-1]) or _DENIES.search(piece[: words[-1].start()]): + continue + if _has_refs(piece[words[-1].end() :]): + out.append(words[-1].group(1)) + return out + + +_LEAD_STATUS = re.compile(r"(? AxiomLine: + """Parse the text after ``axiom:`` (and any continuation bullets) into claims.""" + rest = without_emphasis(norm_dashes(rest).translate(_APOSTROPHES)) + items = [without_emphasis(norm_dashes(i).translate(_APOSTROPHES)) for i in items] + line = AxiomLine(raw=rest, line_no=line_no) + if not rest.strip(): + line.problems.append("empty axiom line") + return line + if _PLACEHOLDER.search(rest): + line.problems.append("the template placeholder is still there; replace it with one real claim") + return line + if _NEEDED_START.match(rest): + line.claims.append(_claim(NEEDED, rest, line_no)) + return line + lead = _claims_from(rest, line_no) + if items: + # "The rest is queued:" gives its status to the bullets below it, unless + # anything in it denies, defers or conditions that status. + tail = _LEAD_STATUS.search(rest) + inherited = None + if tail and not _DENIES.search(rest[: tail.start()]): + word = tail.group(1).lower() + inherited = LEGACY_ALIASES.get(word, word) + if lead and inherited and lead[-1].status == inherited and not (lead[-1].rulespec_refs or lead[-1].legal_refs): + lead = lead[:-1] + for item in items: + own = _claims_from(item, line_no) + if own: + lead.extend(own) + # A bullet that says anything about a status speaks for itself: "- #5 is not + # queued" must not inherit "queued" from the line above. + elif inherited and _has_refs(item) and not _STATUS_WORD.search(item): + lead.append(_claim(inherited, item, line_no)) + line.claims.extend(lead) + for word in _unclaimed(rest): + line.notes.append( + f"`{word}` here is not read as a claim: write the reference right before it, e.g. " + f"`rulespec-us#123 {word.lower()}` or `us:statutes/26/24/d {word.lower()}`" + ) + if not line.claims: + msg = "no claim found: write a status (encoded-correct, encoded, queued, or n/a: : )" + # A line that cites rulespec work but states no status is a broken + # claim; a prose line that happens to start with "Axiom:" is not. + (line.problems if _has_refs(rest) or not rest else line.notes).append(msg) + return line + + +def parse_body(body: str | None) -> list[AxiomLine]: + """Parse every axiom line in a PR description.""" + return [parse_line(rest, line_no, items) for line_no, rest, items in find_lines(body)] diff --git a/axiom-parity/src/axiom_parity/backlog.py b/axiom-parity/src/axiom_parity/backlog.py new file mode 100644 index 0000000..c594166 --- /dev/null +++ b/axiom-parity/src/axiom_parity/backlog.py @@ -0,0 +1,55 @@ +"""Lint the whole pe-parity backlog: which queued issues could the encoder run today?""" + +from __future__ import annotations + +import collections +from typing import Any + +from .github import GitHub +from .issue_lint import ELEMENTS, lint_issue + + +def backlog_report(gh: GitHub, repos: list[str], label: str = "pe-parity") -> dict[str, Any]: + out: dict[str, Any] = {"label": label, "repos": {}} + for repo in repos: + rows = [] + for issue, comments in gh.open_issues_with_comments(repo, label): + res = lint_issue(issue, comments) + rows.append( + { + "number": issue["number"], + "title": issue["title"], + "created_at": issue["created_at"], + "ready": res.ready, + "missing": res.missing, + "found": res.found, + "url": issue["html_url"], + } + ) + missing = collections.Counter(m for r in rows for m in r["missing"]) + out["repos"][repo] = { + "open": len(rows), + "ready": sum(r["ready"] for r in rows), + "missing_by_element": {e: missing.get(e, 0) for e in ELEMENTS}, + "issues": rows, + } + return out + + +def backlog_markdown(report: dict[str, Any]) -> str: + lines = ["## pe-parity backlog: dispatch readiness", ""] + lines += ["| Repo | Open | Dispatch-ready | " + " | ".join(f"missing {e}" for e in ELEMENTS) + " |"] + lines += ["|---|---|---|" + "---|" * len(ELEMENTS)] + for repo, r in report["repos"].items(): + cells = " | ".join(str(r["missing_by_element"][e]) for e in ELEMENTS) + lines.append(f"| {repo} | {r['open']} | {r['ready']} | {cells} |") + lines.append("") + for repo, r in report["repos"].items(): + bad = [i for i in r["issues"] if not i["ready"]] + if not bad: + continue + lines += [f"### {repo}: not dispatch-ready ({len(bad)})", ""] + for i in bad: + lines.append(f"- [#{i['number']}]({i['url']}) {i['title'][:90]}: missing {', '.join(i['missing'])}") + lines.append("") + return "\n".join(lines) + "\n" diff --git a/axiom-parity/src/axiom_parity/behaviour.py b/axiom-parity/src/axiom_parity/behaviour.py new file mode 100644 index 0000000..3414727 --- /dev/null +++ b/axiom-parity/src/axiom_parity/behaviour.py @@ -0,0 +1,192 @@ +"""Tell whether a changed policy file changes what the model computes. + +``axiom: n/a: metadata`` and ``n/a: docs-tests`` claim that no computed value +changed. This module checks that claim file by file: + +- parameter YAML: compare the documents with descriptive keys removed + (``description``, ``label``, ``reference``, ``documentation`` and + presentation-only metadata); values, brackets, ``uprating``, ``period`` and + ``breakdown`` all count; +- Python (variables, reforms): compare ASTs with docstrings and descriptive + class attributes (``label``, ``documentation``, ``reference``) removed; +- Markdown changes nothing; any other file type counts as a change. +""" + +from __future__ import annotations + +import ast +import re +from typing import Any + +from . import pe_yaml + +DESCRIPTIVE_KEYS = {"description", "label", "reference", "references", "documentation", "note", "notes"} +PRESENTATION_METADATA = {"unit", "economy", "household", "display", "propagate_metadata_to_children", "name"} +DESCRIPTIVE_ATTRS = {"label", "documentation", "reference", "__doc__"} + + +def _strip_yaml(node: Any, in_metadata: bool = False) -> Any: + if isinstance(node, dict): + out = {} + for k, v in node.items(): + key = str(k) + if key in DESCRIPTIVE_KEYS: + continue + if in_metadata and key in PRESENTATION_METADATA: + continue + if isinstance(v, dict) and (key == "values" or _is_dated(v)): + # "values:" or a bracket's threshold/amount/rate keyed by date. + out[key] = {str(d): _dated_value(e) for d, e in v.items()} + continue + stripped = _strip_yaml(v, in_metadata or key == "metadata") + if key == "metadata" and stripped in ({}, None): + continue # a metadata block that held only descriptions + out[key] = stripped + return out + if isinstance(node, list): + return [_strip_yaml(v, in_metadata) for v in node] + return node + + +_DATE_KEY = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _is_dated(node: dict) -> bool: + return bool(node) and all(_DATE_KEY.match(str(k)) for k in node) + + +def _dated_value(entry: Any) -> Any: + """A dated entry is ``x`` or ``{value: x, ...}``; only the value is read.""" + if isinstance(entry, dict) and "value" in entry: + return _strip_yaml(entry["value"]) + return _strip_yaml(entry) + + +def yaml_behaviour(text: str) -> Any: + """The behaviour-relevant content of a parameter file.""" + return _strip_yaml(pe_yaml.load(text)) + + +class _StripDescriptive(ast.NodeTransformer): + """Remove docstrings everywhere, and descriptive attributes from class bodies only. + + ``label = 0`` inside a formula is an executable local, not metadata, so it + is kept. + """ + + @staticmethod + def _is_docstring(i: int, stmt: ast.stmt) -> bool: + return ( + i == 0 + and isinstance(stmt, ast.Expr) + and isinstance(stmt.value, ast.Constant) + and isinstance(stmt.value.value, str) + ) + + @staticmethod + def _is_descriptive(stmt: ast.stmt) -> bool: + if isinstance(stmt, ast.Assign): + return all(isinstance(t, ast.Name) and t.id in DESCRIPTIVE_ATTRS for t in stmt.targets) + if isinstance(stmt, ast.AnnAssign): + return isinstance(stmt.target, ast.Name) and stmt.target.id in DESCRIPTIVE_ATTRS + return False + + def __init__(self, dict_is_builtin: bool = True) -> None: + self.dict_is_builtin = dict_is_builtin + + def _is_inert(self, node: ast.AST) -> bool: + """A literal, or a builtin ``dict(...)`` of literals: evaluating it does nothing.""" + if isinstance(node, ast.Constant): + return True + if isinstance(node, ast.List | ast.Tuple | ast.Set): + return all(self._is_inert(e) for e in node.elts) + if isinstance(node, ast.Dict): + return all( + k is not None and self._is_inert(k) and self._is_inert(v) + for k, v in zip(node.keys, node.values, strict=True) + ) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "dict" and not node.args: + # Only the builtin is known to do nothing; a module that binds the name could do anything. + return self.dict_is_builtin and all(kw.arg and self._is_inert(kw.value) for kw in node.keywords) + return False + + def _strip(self, body: list[ast.stmt], class_body: bool) -> list[ast.stmt]: + out = [] + for i, stmt in enumerate(body): + if self._is_docstring(i, stmt): + continue + if class_body and self._is_descriptive(stmt): + continue + # A bare ``dict(title=..., href=...)`` in a class body (a reference + # missing its ``reference =``) computes nothing. + if class_body and isinstance(stmt, ast.Expr) and self._is_inert(stmt.value): + continue + out.append(stmt) + return out or [ast.Pass()] + + def visit_Module(self, node: ast.Module) -> ast.AST: + node.body = self._strip(node.body, class_body=False) + self.generic_visit(node) + return node + + def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST: + node.body = self._strip(node.body, class_body=True) + self.generic_visit(node) + return node + + def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST: + node.body = self._strip(node.body, class_body=False) + self.generic_visit(node) + return node + + visit_AsyncFunctionDef = visit_FunctionDef + + +_MODEL_API = re.compile(r"policyengine_[a-z]+\.model_api") + + +def _binds(tree: ast.AST, name: str) -> bool: + """Does the module define, assign, take as an argument or import ``name``? + + A star import could bind anything, so it counts, with one exception: the + preamble every variable file starts with, ``from policyengine_.model_api + import *``. None of the six ``model_api`` modules (core, us, uk, canada, il, + ng), nor anything they star-import in their own repos, binds ``dict`` + (scanned 2026-10-10). + """ + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) and node.name == name: + return True + if isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, ast.Store): + return True + if isinstance(node, ast.arg) and node.arg == name: + return True + if isinstance(node, ast.Import | ast.ImportFrom): + preamble = isinstance(node, ast.ImportFrom) and bool(_MODEL_API.fullmatch(node.module or "")) + if any((a.name == "*" and not preamble) or (a.asname or a.name).split(".")[0] == name for a in node.names): + return True + return False + + +def python_behaviour(text: str) -> str: + tree = ast.parse(text) + tree = _StripDescriptive(dict_is_builtin=not _binds(tree, "dict")).visit(tree) + return ast.dump(tree, annotate_fields=False, include_attributes=False) + + +def changes_behaviour(path: str, before: str | None, after: str | None) -> bool: + """True unless the change to ``path`` provably leaves computed values alone.""" + lower = path.lower() + if lower.endswith((".md", ".rst", ".txt")): + return False + if before is None or after is None: + # Adding or deleting a parameter, variable or reform changes the model. + return True + try: + if lower.endswith((".yaml", ".yml")): + return yaml_behaviour(before) != yaml_behaviour(after) + if lower.endswith(".py"): + return python_behaviour(before) != python_behaviour(after) + except (pe_yaml.YAMLError, SyntaxError, ValueError): + return True + return before != after diff --git a/axiom-parity/src/axiom_parity/check.py b/axiom-parity/src/axiom_parity/check.py new file mode 100644 index 0000000..bc96bc8 --- /dev/null +++ b/axiom-parity/src/axiom_parity/check.py @@ -0,0 +1,506 @@ +"""The PR check: does a policy PR carry a well-formed, verifiable axiom line? + +Rules, per claim: + +``queued`` + cites a rulespec *issue* that exists, carries the ``pe-parity`` label, + is open (or closed as completed), and passes the dispatch-ready lint. +``encoded`` + cites a rulespec *pull request* that exists and is open or merged. +``encoded-correct`` + names a RuleSpec module that exists on the rulespec repo's main branch + (and the cited rule, if any), plus a companion test case or file that + exists and outputs a rule of that module. +``n/a`` + stands alone, gives an allowed category, and, when the category claims + no computed value changed (``metadata``, ``docs-tests``), the diff + agrees. +``needed`` + is a placeholder an external contributor may write; a maintainer must + replace it, so it never passes. + +A PR that changes no policy path needs no line. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from . import na +from .axiom_line import ENCODED, ENCODED_CORRECT, NA, NEEDED, QUEUED, AxiomLine, Claim, parse_body +from .behaviour import changes_behaviour +from .github import GitHub, GitHubError, NotFound +from .issue_lint import HINTS, LintResult, lint_issue +from .policy_paths import PolicyPaths +from .refs import AXIOM_OWNER, RulespecRef, TestRef +from .rulespec import GitHubSource, Resolver, Source, rulespec_repo_for_jurisdiction + +PE_PARITY_LABEL = "pe-parity" + +ERROR, WARNING, NOTICE = "error", "warning", "notice" + + +@dataclass +class Finding: + level: str + code: str + message: str + # True for a finding that fails the check under ``enforce``, including + # one shown as a warning during the warn-only rollout. + blocking: bool = False + + +@dataclass +class ClaimResult: + claim: Claim + findings: list[Finding] = field(default_factory=list) + evidence: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not any(f.level == ERROR for f in self.findings) + + @property + def blocking(self) -> bool: + return any(f.blocking for f in self.findings) + + +@dataclass +class Report: + repo: str + pr: int | None + policy_files: list[str] + lines: list[AxiomLine] + claims: list[ClaimResult] + findings: list[Finding] + mode: str = "enforce" + api_calls: int = 0 + would_fail: bool = False + + @property + def applies(self) -> bool: + return bool(self.policy_files) + + @property + def errors(self) -> list[Finding]: + out = [f for f in self.findings if f.level == ERROR] + for c in self.claims: + out.extend(f for f in c.findings if f.level == ERROR) + return out + + @property + def ok(self) -> bool: + return not self.errors + + def to_json(self) -> dict[str, Any]: + return { + "repo": self.repo, + "pr": self.pr, + "mode": self.mode, + "applies": self.applies, + "ok": self.ok, + "would_fail_enforced": self.would_fail, + "policy_files": self.policy_files, + "findings": [f.__dict__ for f in self.findings], + "claims": [ + { + "status": c.claim.status, + "text": c.claim.text, + "line": c.claim.line_no, + "ok": c.ok, + "blocking": c.blocking, + "findings": [f.__dict__ for f in c.findings], + "evidence": c.evidence, + } + for c in self.claims + ], + "api_calls": self.api_calls, + } + + +# A function returning a file's text on one side of the diff ("base"/"head"), +# or None when the file doesn't exist on that side. +DiffReader = Callable[[str, str], str | None] + + +@dataclass +class Context: + gh: GitHub + default_rulespec: str + sources: dict[str, Source] = field(default_factory=dict) + read_diff: DiffReader | None = None + rulespec_ref: str = "main" + + def source(self, repo: str) -> Source | None: + """The rulespec repo as a Source, or None if it doesn't exist.""" + if repo not in self.sources: + try: + self.gh.repo(repo) + except NotFound: + return None + self.sources[repo] = GitHubSource(self.gh, repo, self.rulespec_ref) + return self.sources[repo] + + def resolver(self) -> Resolver: + return Resolver(self.source) + + +_NEGATOR = re.compile(r"\b(?:not|no|without|except|excluding|never|nor|neither)\b|n't\b", re.IGNORECASE) +_SCAN = re.compile( + r"(?P[(\[{])|(?P[)\]}])|(?P[;:]|[.!?]\s+(?=[A-Z`]))|" + r"(?P(?\b(?:not|no|without|except|excluding|never|nor|neither)\b|n't\b)", + re.IGNORECASE, +) + + +def _ruled_out(text: str, at: int) -> bool: + """Is the reference at ``text[at]`` named in the reach of a negation? + + A negation reaches to the end of its own bracket group, nested groups + included: "(not (`a`, `b`))" rules out both. A colon, a semicolon, a + sentence end or a status word ends its reach, so "(no new test is needed: + `t` covers it)" and "Not in Axiom yet, #5 queued (`t`)" keep `t`. + """ + reach = [False] # one flag per open group + for m in _SCAN.finditer(text, 0, at): + if m.group("open"): + reach.append(False) + elif m.group("close"): + if len(reach) > 1: + reach.pop() + elif m.group("neg"): + reach[-1] = True + else: + reach[-1] = False + return any(reach) + + +def _affirmed(claim: Claim) -> list[TestRef]: + """The claim's test references, less any it names only to rule out. + + References are read as whole tokens: the "::" in "file.test.yaml::case" + or a colon inside backticks is not punctuation that ends a negation's reach. + """ + text = claim.text + for t in claim.test_refs: + at = text.find(t.raw) + if at >= 0: + text = text[:at] + "x" * len(t.raw) + text[at + len(t.raw) :] + text = re.sub(r"`[^`]*`", lambda m: "x" * len(m.group(0)), text) + out = [] + for t in claim.test_refs: + at = claim.text.find(t.raw) + if at < 0 or not _ruled_out(text, at): + out.append(t) + return out + + +class Checker: + def __init__(self, ctx: Context) -> None: + self.ctx = ctx + self.resolver = ctx.resolver() + self._issues: dict[tuple[str, int], dict[str, Any] | None] = {} + self._lints: dict[tuple[str, int], LintResult] = {} + + # GitHub reads, cached --------------------------------------------- + + def issue(self, ref: RulespecRef) -> dict[str, Any] | None: + key = (f"{ref.owner}/{ref.repo}", ref.number) + if key not in self._issues: + try: + self._issues[key] = self.ctx.gh.issue(*key) + except NotFound: + self._issues[key] = None + return self._issues[key] + + def lint(self, ref: RulespecRef, issue: dict[str, Any]) -> LintResult: + key = (f"{ref.owner}/{ref.repo}", ref.number) + if key not in self._lints: + comments = self.ctx.gh.issue_comments(*key) if issue.get("comments") else [] + self._lints[key] = lint_issue(issue, comments) + return self._lints[key] + + # Claims ----------------------------------------------------------- + + def check_claim(self, claim: Claim, all_claims: list[Claim], policy_files: list[str]) -> ClaimResult: + res = ClaimResult(claim) + if claim.legacy_keyword: + res.findings.append( + Finding(NOTICE, "legacy-keyword", f"“{claim.legacy_keyword}” is read as `queued`; write `queued`") + ) + handler = { + QUEUED: self._queued, + ENCODED: self._encoded, + ENCODED_CORRECT: self._encoded_correct, + NA: self._na, + NEEDED: self._needed, + }[claim.status] + handler(claim, res, all_claims, policy_files) + return res + + def _rulespec_refs(self, claim: Claim, res: ClaimResult) -> list[RulespecRef]: + refs = [] + for ref in claim.rulespec_refs: + if ref.owner != AXIOM_OWNER: + res.findings.append( + Finding(ERROR, "foreign-repo", f"{ref.raw} is not a {AXIOM_OWNER} rulespec repository") + ) + continue + refs.append(ref) + return refs + + def _queued(self, claim: Claim, res: ClaimResult, *_: Any) -> None: + refs = self._rulespec_refs(claim, res) + if not refs: + res.findings.append( + Finding( + ERROR, + "queued-no-issue", + "`queued` must cite a rulespec issue, e.g. TheAxiomFoundation/rulespec-us#1416", + ) + ) + return + passing = 0 + for ref in refs: + issue = self.issue(ref) + if issue is None: + res.findings.append(Finding(ERROR, "not-found", f"{ref.slug} does not exist")) + continue + if "pull_request" in issue: + # A PR cited in a queued claim is context (an earlier attempt); + # the claim still needs an issue. + res.evidence.append(f"{ref.slug} is a pull request (context)") + continue + labels = {lab["name"] if isinstance(lab, dict) else lab for lab in issue.get("labels", [])} + problems = [] + if PE_PARITY_LABEL not in labels: + problems.append(Finding(ERROR, "no-pe-parity-label", f"{ref.slug} lacks the `{PE_PARITY_LABEL}` label")) + if issue.get("state") == "closed": + if issue.get("state_reason") == "not_planned": + problems.append(Finding(ERROR, "closed-not-planned", f"{ref.slug} was closed as not planned")) + else: + res.evidence.append( + f"{ref.slug} is closed as completed; `encoded` with the PR may be more accurate" + ) + lint = self.lint(ref, issue) + if not lint.ready: + missing = "; ".join(f"{m} ({HINTS[m]})" for m in lint.missing) + problems.append( + Finding(ERROR, "not-dispatch-ready", f"{ref.slug} is not dispatch-ready: missing {missing}") + ) + if any(p.level == ERROR for p in problems): + res.findings.extend(problems) + else: + passing += 1 + res.evidence.append(f"{ref.slug}: open pe-parity issue, dispatch-ready ({', '.join(lint.found)})") + if passing == 0 and not any(f.level == ERROR for f in res.findings): + res.findings.append( + Finding(ERROR, "queued-no-issue", "`queued` cites only pull requests; cite the pe-parity issue") + ) + + def _encoded(self, claim: Claim, res: ClaimResult, *_: Any) -> None: + refs = self._rulespec_refs(claim, res) + pulls = 0 + for ref in refs: + issue = self.issue(ref) + if issue is None: + res.findings.append(Finding(ERROR, "not-found", f"{ref.slug} does not exist")) + continue + if "pull_request" not in issue: + res.evidence.append(f"{ref.slug} is an issue") + continue + pulls += 1 + try: + pr = self.ctx.gh.pull(f"{ref.owner}/{ref.repo}", ref.number) + except (NotFound, GitHubError): + pr = {} + if pr.get("merged") or pr.get("merged_at"): + res.evidence.append(f"{ref.slug} merged") + elif issue.get("state") == "open": + res.evidence.append(f"{ref.slug} open{' (draft)' if pr.get('draft') else ''}") + res.findings.append(Finding(NOTICE, "encoded-open", f"{ref.slug} is not merged yet")) + else: + res.findings.append(Finding(ERROR, "encoded-closed", f"{ref.slug} was closed without merging")) + continue + try: + files = [f["filename"] for f in self.ctx.gh.pull_files(f"{ref.owner}/{ref.repo}", ref.number)] + except (NotFound, GitHubError): + files = [] + if files and not any("/.axiom/encoding-manifests/" in f"/{f}" or f.startswith(".axiom/") for f in files): + res.findings.append( + Finding( + WARNING, + "no-encoding-manifest", + f"{ref.slug} carries no encoder manifest under .axiom/; is it from the signed encoder?", + ) + ) + if pulls == 0 and not any(f.code == "not-found" for f in res.findings): + res.findings.append( + Finding( + ERROR, "encoded-no-pr", "`encoded` must cite the rulespec pull request; for an issue, use `queued`" + ) + ) + + def _encoded_correct(self, claim: Claim, res: ClaimResult, all_claims: list[Claim], *_: Any) -> None: + modules = [] + for ref in claim.legal_refs: + repo = rulespec_repo_for_jurisdiction(ref.jurisdiction, self.ctx.default_rulespec) + mod = self.resolver.resolve_module(ref, repo) + if mod is None: + # However the module is written (legal id, path, corpus citation, + # sibling), a reference that resolves to nothing fails the claim. + res.findings.append( + Finding( + ERROR, + "module-not-found", + f"no module for {ref.raw} on {repo}@{self.ctx.rulespec_ref} (looked for {ref.module_path})", + ) + ) + continue + if ref.rule and ref.rule not in mod.rules: + res.findings.append(Finding(ERROR, "rule-not-found", f"{mod.path} has no rule `{ref.rule}`")) + continue + note = "" if mod.match == "exact" else f" (nearest module for {ref.raw})" + res.evidence.append(f"module {repo}:{mod.path}{note}") + modules.append((repo, mod)) + if not modules: + if not any(f.code in ("module-not-found", "rule-not-found") and f.level == ERROR for f in res.findings): + res.findings.append( + Finding( + ERROR, + "encoded-correct-no-module", + "`encoded-correct` must name the module, e.g. `us:statutes/26/24/d#rule` or `uk/regulations/uksi/2013/376/62.yaml`", + ) + ) + return + # Tests are looked for in the claim, then in the rest of its axiom line: + # "X encoded-correct | Y queued (...). The companion test `t` exercises it." + # A test a claim names only to rule it out ("(not `other_case`)") is not evidence. + test_refs = _affirmed(claim) + for other in all_claims: + if other is not claim and other.line_no == claim.line_no: + test_refs.extend(t for t in _affirmed(other) if t not in test_refs) + tests = self.resolver.resolve_tests(test_refs, modules, self.ctx.default_rulespec) + exercising = [t for t in tests if t.exercises] + for t in exercising[:5]: + label = f"{t.path}::{t.case}" if t.case else t.path + res.evidence.append(f"companion test {label} outputs {', '.join(t.exercises)}") + if not tests: + res.findings.append( + Finding( + ERROR, + "encoded-correct-no-test", + "`encoded-correct` must cite a companion test case that exercises the module, e.g. " + "`32.test.yaml::case_name` or the case name in backticks", + ) + ) + return + # Every module the claim names needs a cited test that outputs it: one + # exercised module doesn't vouch for the others. + covered = {mid for t in exercising for mid in t.exercises} + for mid in dict.fromkeys(m.legal_id for _, m in modules): + if mid not in covered: + labels = ", ".join(f"{t.path}::{t.case}" if t.case else t.path for t in tests[:3]) + res.findings.append( + Finding( + ERROR, + "test-does-not-exercise", + f"no cited companion test outputs a rule of `{mid}` (cited: {labels})", + ) + ) + + def _na(self, claim: Claim, res: ClaimResult, all_claims: list[Claim], policy_files: list[str]) -> None: + if len(all_claims) > 1: + res.findings.append(Finding(ERROR, "na-combined", "`n/a` cannot be combined with other axiom claims")) + verdict = na.classify(claim.na_reason) + if not verdict.ok: + res.findings.append(Finding(ERROR, "na-category", verdict.message)) + return + res.evidence.append(verdict.message) + if verdict.category in na.NO_BEHAVIOUR_CATEGORIES and self.ctx.read_diff is not None: + # Every file is checked: an unchecked file could hide a value change. + changed = [] + checked = policy_files + for path in checked: + if changes_behaviour(path, self.ctx.read_diff(path, "base"), self.ctx.read_diff(path, "head")): + changed.append(path) + if changed: + shown = ", ".join(changed[:5]) + (f" and {len(changed) - 5} more" if len(changed) > 5 else "") + res.findings.append( + Finding( + ERROR, + "na-changes-behaviour", + f"`n/a: {verdict.category}` says no computed value changes, but these files change values or formulas: {shown}", + ) + ) + else: + res.evidence.append(f"diff check: none of {len(checked)} policy files changes a value or formula") + + def _needed(self, claim: Claim, res: ClaimResult, *_: Any) -> None: + res.findings.append( + Finding( + ERROR, + "needed", + "`axiom: needed` is a placeholder; a maintainer must replace it with encoded-correct, encoded, queued or n/a", + ) + ) + + +def run_check( + *, + repo: str, + pr: int | None, + body: str | None, + changed_files: list[str], + ctx: Context, + paths: PolicyPaths, + mode: str = "enforce", +) -> Report: + policy_files = paths.policy_files(changed_files) + lines = parse_body(body) + findings: list[Finding] = [] + all_claims = [c for line in lines for c in line.claims] + for line in lines: + for p in line.problems: + findings.append(Finding(ERROR if policy_files else WARNING, "malformed-line", f"line {line.line_no}: {p}")) + for note in line.notes: + findings.append(Finding(NOTICE, "line-note", f"line {line.line_no}: {note}")) + checker = Checker(ctx) + claims: list[ClaimResult] = [] + if policy_files and not lines: + findings.append( + Finding( + ERROR, + "missing", + "this PR changes policy files but its description has no `axiom:` line. Add one: " + "`axiom: encoded-correct ()`, `axiom: encoded`, " + "`axiom: queued`, or `axiom: n/a: : `", + ) + ) + elif policy_files and not all_claims and not any(f.level == ERROR for f in findings): + findings.append( + Finding( + ERROR, + "no-claim", + "no axiom line states a status (encoded-correct, encoded, queued, or n/a: : )", + ) + ) + for claim in all_claims: + result = checker.check_claim(claim, all_claims, policy_files) + if not policy_files: + for f in result.findings: + if f.level == ERROR: + f.level = WARNING + claims.append(result) + report = Report(repo, pr, policy_files, lines, claims, findings, mode, ctx.gh.calls) + every = report.findings + [f for c in report.claims for f in c.findings] + for f in every: + f.blocking = f.level == ERROR + if mode == "warn" and f.level == ERROR: + f.level = WARNING + report.would_fail = any(f.blocking for f in every) + return report diff --git a/axiom-parity/src/axiom_parity/cli.py b/axiom-parity/src/axiom_parity/cli.py new file mode 100644 index 0000000..466b25b --- /dev/null +++ b/axiom-parity/src/axiom_parity/cli.py @@ -0,0 +1,276 @@ +"""Command line: ``axiom-parity check|suggest|lint-issue|backlog|corpus-pin|drain-plan``.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +from .check import Context, run_check +from .diff import ApiDiff, LocalDiff +from .github import GitHub +from .issue_lint import HINTS, lint_issue +from .policy_paths import PolicyPaths, country_for_repo, package_for_repo +from .render import annotations, markdown +from .rulespec import rulespec_repo_for_country + + +def _write(path: str | None, text: str) -> None: + if not path: + return + with open(path, "a" if path == os.environ.get("GITHUB_STEP_SUMMARY") else "w", encoding="utf-8") as fh: + fh.write(text) + + +def cmd_check(args: argparse.Namespace) -> int: + try: + return _check(args) + except Exception as err: # noqa: BLE001 - a tooling failure must not read as a rule violation + level = "warning" if args.mode == "warn" else "error" + print(f"::{level} title=Axiom parity::the check could not run: {type(err).__name__}: {err}") + try: + _write(args.summary, f"## Axiom parity\n\nThe check could not run: `{type(err).__name__}: {err}`.\n") + except OSError: + pass # the annotation above already reports it + return 0 if args.mode == "warn" else 2 + + +def _check(args: argparse.Namespace) -> int: + gh = GitHub() + repo = args.repo + package = args.package or package_for_repo(repo) + rulespec = args.rulespec or rulespec_repo_for_country(country_for_repo(repo)) + if args.body_file: + body = Path(args.body_file).read_text(encoding="utf-8") + else: + body = gh.pull(repo, args.pr).get("body") or "" + if args.local_repo: + diff = LocalDiff(args.local_repo, args.base, args.head) + else: + diff = ApiDiff(gh, repo, args.pr) + changed = diff.changed_files() + ctx = Context(gh=gh, default_rulespec=rulespec, read_diff=diff.read, rulespec_ref=args.rulespec_ref) + report = run_check( + repo=repo, + pr=args.pr, + body=body, + changed_files=changed, + ctx=ctx, + paths=PolicyPaths(package), + mode=args.mode, + ) + summary = markdown(report) + _write(args.summary, summary) + if args.json: + Path(args.json).write_text(json.dumps(report.to_json(), indent=2), encoding="utf-8") + if args.annotations: + for line in annotations(report): + print(line) + if not args.summary: + print(summary) + if report.would_fail and args.mode == "enforce": + return 1 + return 0 + + +_ISSUE_REF = re.compile(r"^(?P[\w.-]+/[\w.-]+)#(?P\d+)$") + + +def cmd_lint_issue(args: argparse.Namespace) -> int: + gh = GitHub() + bad = 0 + for ref in args.refs: + m = _ISSUE_REF.match(ref) + if not m: + print(f"{ref}: expected OWNER/REPO#N", file=sys.stderr) + return 2 + repo, n = m.group("repo"), int(m.group("n")) + issue = gh.issue(repo, n) + comments = gh.issue_comments(repo, n) if issue.get("comments") else [] + res = lint_issue(issue, comments) + status = "dispatch-ready" if res.ready else "NOT dispatch-ready" + print(f"{ref}: {status}") + for element, evidence in res.found.items(): + print(f" found {element}: {evidence}") + for element in res.missing: + print(f" missing {element}: {HINTS[element]}") + bad += not res.ready + return 1 if bad else 0 + + +def cmd_backlog(args: argparse.Namespace) -> int: + from .backlog import backlog_report + + gh = GitHub() + report = backlog_report(gh, args.repos, label=args.label) + if args.json: + Path(args.json).write_text(json.dumps(report, indent=2), encoding="utf-8") + from .backlog import backlog_markdown + + text = backlog_markdown(report) + _write(args.summary, text) + if not args.summary: + print(text) + return 0 + + +def cmd_drain_plan(args: argparse.Namespace) -> int: + from .drain import ( + issue_inputs, + load_blocked_citations, + load_blocked_modules, + load_issues, + load_pin_file, + load_saved, + load_work_holds, + markdown, + plan, + to_json, + ) + from .finder.index import Index + + gh = GitHub() + saved = dict(item.split("=", 1) for item in args.issues_file) + rows = [] + for repo in args.repos: + items = load_saved(saved[repo], repo) if repo in saved else load_issues(gh, repo, args.label) + rows.extend(issue_inputs(repo, i, c) for i, c in items) + corpus, repealed = load_pin_file(args.corpus_citations) if args.corpus_citations else (None, None) + blocked = load_blocked_modules(args.blocked_modules) if args.blocked_modules else None + listed = [hold for path in args.blocked_citations for hold in load_blocked_citations(path)] or None + # With a rulespec checkout, a blocked module also blocks the citations it owns on main. + indexes = [Index.build(repo, path) for repo, path in (item.split("=", 1) for item in args.rulespec_dir)] + + def owners(citation: str) -> list[str]: + return [m.path for idx in indexes for m in idx.by_citation.get(citation, [])] + + holds = load_work_holds(args.work_holds) if args.work_holds else None + batches = plan( + rows, corpus, blocked, args.blocked_citation_pattern, listed, repealed, owners if indexes else None, holds + ) + if args.json: + Path(args.json).write_text(json.dumps(to_json(batches, args.weekly_runs, holds), indent=2), encoding="utf-8") + text = markdown(batches, args.weekly_runs, holds) + _write(args.summary, text) + if not args.summary: + print(text) + return 0 + + +def cmd_corpus_pin(args: argparse.Namespace) -> int: + from .corpus_pin import write_pin_file + + pin, n = write_pin_file(args.rulespec_dir, args.corpus_dir, args.out, args.ref, args.corpus_ref) + print( + f"{pin.release} @ axiom-corpus {pin.corpus_ref[:12]} ({pin.ref_source}): " + f"{n} citations with an operative body -> {args.out}" + ) + return 0 + + +def cmd_find(args: argparse.Namespace) -> int: + from .finder.cli import run_find + + return run_find(args) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="axiom-parity", description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + + c = sub.add_parser("check", help="check a PR's axiom line") + c.add_argument("--repo", required=True, help="PolicyEngine/policyengine-uk") + c.add_argument("--pr", type=int, required=True) + c.add_argument("--body-file", help="read the PR description from this file instead of the API") + c.add_argument("--local-repo", help="a local clone to diff instead of the API") + c.add_argument("--base", default="HEAD^1") + c.add_argument("--head", default="HEAD") + c.add_argument("--package", help="policy package dir (default from the repo name)") + c.add_argument("--rulespec", help="default rulespec repo (default from the repo name)") + c.add_argument("--rulespec-ref", default="main") + c.add_argument("--mode", choices=("warn", "enforce"), default="enforce") + c.add_argument("--summary", help="append the markdown summary here (e.g. $GITHUB_STEP_SUMMARY)") + c.add_argument("--json", help="write the JSON report here") + c.add_argument("--annotations", action="store_true", help="print ::error:: workflow commands") + c.set_defaults(func=cmd_check) + + li = sub.add_parser("lint-issue", help="lint pe-parity issues for dispatch readiness") + li.add_argument("refs", nargs="+", help="TheAxiomFoundation/rulespec-us#1416 ...") + li.set_defaults(func=cmd_lint_issue) + + b = sub.add_parser("backlog", help="lint every open pe-parity issue in rulespec repos") + b.add_argument("--repos", nargs="+", default=["TheAxiomFoundation/rulespec-uk", "TheAxiomFoundation/rulespec-us"]) + b.add_argument("--label", default="pe-parity") + b.add_argument("--json") + b.add_argument("--summary") + b.set_defaults(func=cmd_backlog) + + d = sub.add_parser( + "drain-plan", help="group dispatch-ready pe-parity issues by source document and price the waves" + ) + d.add_argument("--repos", nargs="+", default=["TheAxiomFoundation/rulespec-uk", "TheAxiomFoundation/rulespec-us"]) + d.add_argument("--label", default="pe-parity") + d.add_argument("--issues-file", action="append", default=[], metavar="REPO=PATH") + d.add_argument("--corpus-citations", help="file of citation paths in the pinned corpus release, one per line") + d.add_argument( + "--blocked-modules", + help="module paths the encoder owner has ruled out as targets or imports (text list, or JSON of lists)", + ) + d.add_argument( + "--rulespec-dir", + action="append", + default=[], + metavar="REPO=PATH", + help="rulespec checkout at main, to find the modules that own each target citation (repeatable)", + ) + d.add_argument( + "--blocked-citations", + action="append", + default=[], + help="citations the encoder owner holds for now: JSON {blocked: [{citation, match, kind, reason}]} or its " + "dispatch-preparation verdicts {documents: [{citation, verdict, reason, until}]} (repeatable)", + ) + d.add_argument( + "--blocked-citation-pattern", + action="append", + default=[], + metavar="REGEX", + help="block issues with a target citation matching this pattern, e.g. /manual/ (repeatable)", + ) + d.add_argument( + "--work-holds", + help="modules a signed run does not edit, with the encoder owner's rule: JSON {rule, modules: [{module, " + "evidence}]}; an issue that asks for work on one is held (importing from one is not)", + ) + d.add_argument("--weekly-runs", type=int, default=10, help="signed runs (signing approvals) a week") + d.add_argument("--json") + d.add_argument("--summary") + d.set_defaults(func=cmd_drain_plan) + + cp = sub.add_parser("corpus-pin", help="list the citations in the corpus release a rulespec clone pins") + cp.add_argument("--rulespec-dir", required=True, help="local rulespec- clone") + cp.add_argument("--corpus-dir", required=True, help="local axiom-corpus clone that has the pinned ref") + cp.add_argument("--ref", default="origin/main", help="rulespec ref to read the pin from") + cp.add_argument("--corpus-ref", help="axiom-corpus commit to read the release at (default: from the pin)") + cp.add_argument("--out", required=True) + cp.set_defaults(func=cmd_corpus_pin) + + # "suggest" is the name to use: a shell guard on some machines blocks any command containing "find". + f = sub.add_parser("suggest", aliases=["find"], help="suggest the axiom line for a PR or a set of changed files") + from .finder.cli import add_find_arguments + + add_find_arguments(f) + f.set_defaults(func=cmd_find) + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/axiom-parity/src/axiom_parity/corpus_pin.py b/axiom-parity/src/axiom_parity/corpus_pin.py new file mode 100644 index 0000000..4d9016f --- /dev/null +++ b/axiom-parity/src/axiom_parity/corpus_pin.py @@ -0,0 +1,170 @@ +"""Which corpus citations are in the release a rulespec repo pins? + +The signed encoder reads only the pinned release, so a pe-parity issue whose +citation isn't in it can't be encoded until the corpus is re-pinned. The +check follows the Axiom encoder owner's procedure: + +1. read ``axiom_corpus_release`` from ``.axiom/toolchain.toml`` and + ``axiom_corpus_ref`` from ``.axiom/workflow-toolchain.toml`` on rulespec + main; +2. read ``manifests/releases/.json`` at that axiom-corpus ref; it + lists the selected scopes ``(jurisdiction, document_class, version)``; +3. read each scope's ``data/corpus/provisions///.jsonl`` + at that ref. A citation is in the pin only if a selected scope carries + its row with an operative body; bodyless container rows and rows marked + repealed don't count. + +Everything is read from local clones with ``git``, never the GitHub API. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Pin: + release: str + corpus_ref: str + ref_source: str = "workflow-toolchain.toml" + + +def _show(repo: Path, ref: str, path: str) -> str: + res = subprocess.run(["git", "-C", str(repo), "show", f"{ref}:{path}"], capture_output=True, text=True) + if res.returncode != 0: + raise FileNotFoundError(f"{ref}:{path} in {repo}: {res.stderr.strip()}") + return res.stdout + + +def _toml_value(text: str, key: str) -> str | None: + m = re.search(rf'^\s*{re.escape(key)}\s*=\s*"([^"]+)"', text, re.MULTILINE) + return m.group(1) if m else None + + +def read_pin( + rulespec: Path, ref: str = "origin/main", corpus: Path | None = None, corpus_ref: str | None = None +) -> Pin: + """The release a rulespec repo pins, and the axiom-corpus commit to read it at. + + rulespec-us records the corpus commit in ``.axiom/workflow-toolchain.toml``. + rulespec-uk records only the release; its commit is then the axiom-corpus + commit that wrote ``manifests/releases/.json``. + """ + release = _toml_value(_show(rulespec, ref, ".axiom/toolchain.toml"), "axiom_corpus_release") + if not release: + raise ValueError(f"{rulespec}: no axiom_corpus_release pin") + if corpus_ref: + return Pin(release, corpus_ref, "--corpus-ref") + try: + found = _toml_value(_show(rulespec, ref, ".axiom/workflow-toolchain.toml"), "axiom_corpus_ref") + except FileNotFoundError: + found = None + if found: + return Pin(release, found) + # rulespec-uk pins the corpus commit in its checks workflow instead. + try: + wf = _show(rulespec, ref, ".github/workflows/repository-checks.yml") + m = re.search(r"^\s*axiom-corpus-ref:\s*([0-9a-f]{40})\s*$", wf, re.MULTILINE) + except FileNotFoundError: + m = None + if m: + return Pin(release, m.group(1), "repository-checks.yml") + if corpus is None: + raise ValueError(f"{rulespec}: no axiom_corpus_ref pin; pass --corpus-ref") + res = subprocess.run( + [ + "git", + "-C", + str(corpus), + "log", + "-1", + "--format=%H", + "origin/main", + "--", + f"manifests/releases/{release}.json", + ], + capture_output=True, + text=True, + ) + if res.returncode != 0 or not res.stdout.strip(): + raise ValueError(f"no axiom-corpus commit writes manifests/releases/{release}.json") + return Pin(release, res.stdout.strip(), "commit that wrote the release manifest") + + +def pinned_citations(corpus: Path, pin: Pin) -> set[str]: + """Citations with an operative body that aren't repealed.""" + return pinned_rows(corpus, pin)[0] + + +def pinned_rows(corpus: Path, pin: Pin) -> tuple[set[str], set[str]]: + """(citations the encoder can read, citations whose row is marked repealed).""" + manifest = json.loads(_show(corpus, pin.corpus_ref, f"manifests/releases/{pin.release}.json")) + paths = [ + f"data/corpus/provisions/{s['jurisdiction']}/{s['document_class']}/{s['version']}.jsonl" + for s in manifest.get("scopes", []) + ] + out: set[str] = set() + repealed: set[str] = set() + proc = subprocess.Popen( + ["git", "-C", str(corpus), "cat-file", "--batch"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + assert proc.stdin and proc.stdout + for path in paths: + proc.stdin.write(f"{pin.corpus_ref}:{path}\n".encode()) + proc.stdin.flush() + header = proc.stdout.readline().decode().split() + if len(header) != 3: # " missing" + continue + size = int(header[2]) + remaining = size + buf = b"" + while remaining > 0: + chunk = proc.stdout.read(min(remaining, 1 << 20)) + if not chunk: + break + remaining -= len(chunk) + buf += chunk + *lines, buf = buf.split(b"\n") + for line in lines: + _take(line, out, repealed) + _take(buf, out, repealed) + proc.stdout.read(1) # trailing newline after each object + proc.stdin.close() + proc.wait() + return out, repealed - out + + +def _take(line: bytes, out: set[str], repealed: set[str] | None = None) -> None: + if not line.strip(): + return + try: + row = json.loads(line) + except json.JSONDecodeError: + return + body = row.get("body") + meta = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} + # The encoder refuses a repealed source (corpus_resolver: "Active corpus source ... is repealed"). + if meta.get("status") == "repealed": + if repealed is not None and row.get("citation_path"): + repealed.add(row["citation_path"]) + return + if row.get("citation_path") and isinstance(body, str) and body.strip(): + out.add(row["citation_path"]) + + +def write_pin_file( + rulespec: str | Path, corpus: str | Path, out: str | Path, ref: str = "origin/main", corpus_ref: str | None = None +) -> tuple[Pin, int]: + pin = read_pin(Path(rulespec), ref, Path(corpus), corpus_ref) + cites, repealed = pinned_rows(Path(corpus), pin) + # One file: readable citations, then repealed ones marked with "!". A repealed + # row blocks its citation even when rows below it still carry text. + lines = sorted(cites) + [f"!{c}" for c in sorted(repealed)] + Path(out).write_text("\n".join(lines) + "\n", encoding="utf-8") + return pin, len(cites) diff --git a/axiom-parity/src/axiom_parity/diff.py b/axiom-parity/src/axiom_parity/diff.py new file mode 100644 index 0000000..4ea587d --- /dev/null +++ b/axiom-parity/src/axiom_parity/diff.py @@ -0,0 +1,77 @@ +"""Changed files and their before/after text, from git or the GitHub API.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from .github import GitHub, NotFound + + +class LocalDiff: + """A diff between two refs of a local clone. + + In CI the clone is the PR's merge commit fetched with depth 2, so + ``HEAD^1..HEAD`` is exactly what merging the PR would change. + """ + + def __init__(self, root: str | Path, base: str, head: str) -> None: + self.root = Path(root) + self.base = base + self.head = head + self._renames: dict[str, str] = {} + + def _git(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", "-C", str(self.root), *args], capture_output=True, text=True) + + def changed_files(self) -> list[str]: + out = self._git("diff", "--name-status", "-M", self.base, self.head) + if out.returncode != 0: + raise RuntimeError(out.stderr.strip()) + files = [] + for line in out.stdout.splitlines(): + parts = line.split("\t") + if parts[0].startswith("R") and len(parts) == 3: + self._renames[parts[2]] = parts[1] + files.append(parts[2]) + elif len(parts) >= 2: + files.append(parts[-1]) + return files + + def read(self, path: str, side: str) -> str | None: + ref = self.base if side == "base" else self.head + if side == "base": + path = self._renames.get(path, path) + res = self._git("show", f"{ref}:{path}") + return res.stdout if res.returncode == 0 else None + + +class ApiDiff: + """A PR's diff read through the REST API (for runs without a checkout).""" + + def __init__(self, gh: GitHub, repo: str, pr: int) -> None: + self.gh = gh + self.repo = repo + self.pr = pr + pull = gh.pull(repo, pr) + self.head = pull["head"]["sha"] + compare = gh.get_json(f"/repos/{repo}/compare/{pull['base']['sha']}...{self.head}") + self.base = compare.get("merge_base_commit", {}).get("sha") or pull["base"]["sha"] + self._files = gh.pull_files(repo, pr) + self._renames = {f["filename"]: f["previous_filename"] for f in self._files if f.get("previous_filename")} + self._status = {f["filename"]: f.get("status") for f in self._files} + + def changed_files(self) -> list[str]: + return [f["filename"] for f in self._files] + + def read(self, path: str, side: str) -> str | None: + status = self._status.get(path) + if side == "base" and status == "added" or side == "head" and status == "removed": + return None + ref = self.base if side == "base" else self.head + if side == "base": + path = self._renames.get(path, path) + try: + return self.gh.get_raw(self.repo, path, ref) + except NotFound: + return None diff --git a/axiom-parity/src/axiom_parity/drain.py b/axiom-parity/src/axiom_parity/drain.py new file mode 100644 index 0000000..5fb92a3 --- /dev/null +++ b/axiom-parity/src/axiom_parity/drain.py @@ -0,0 +1,767 @@ +"""Plan a drain of the pe-parity backlog through the signed encoder. + +``axiom-parity drain-plan`` lints every open pe-parity issue, groups the +dispatch-ready ones by source document (the unit the encoder should encode +whole), extracts the inputs the signed encoder needs (corpus citation, +pasteable ``review_finding``), and prices each wave from measured run costs. +It plans; it never dispatches. Dispatching is the Axiom encoder owner's job, +under whatever approval Max has given. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .finder.draft import module_path_for +from .finder.propose import document_of +from .github import GitHub +from .issue_lint import ( + _ARTIFACT, + _CORPUS_SINGULAR, + _JURIS, + _LIST_MARKER, + expand_braces, + lint_issue, + review_findings, +) +from .refs import norm_dashes + +# Measured model spend per completed targeted-signed-reencode run, GPT-6 Luna +# with Sol escalation (axiom-encode#1699), 2026-09-25 to 2026-10-10: 121 runs, +# 90 priced from each run's "Model spend for this run" summary. +MEASURED = { + "window": "2026-09-25..2026-10-10", + "runs": 121, + "successes": 11, + "spend_usd": 7.08, + "mean_run_usd": 0.058, + "p90_run_usd": 0.150, + "max_run_usd": 0.496, + "spend_per_success_usd": 0.64, +} + + +@dataclass +class IssueInputs: + repo: str + number: int + title: str + url: str + ready: bool + missing: list[str] + citations: list[str] # every corpus citation the issue mentions + targets: list[str] # the ones to encode: labelled as the citation, or asked for in a review_finding + review_finding: str | None # every finding in the issue, body first, joined by blank lines + pe_prs: list[str] + blocked: list[str] = field(default_factory=list) # measured blockers only + notes: list[str] = field(default_factory=list) # blockers the issue text mentions + modules: list[str] = field(default_factory=list) # modules the issue declares or tells the encoder to use + finding_blocks: int = 0 # how many separate findings were joined + unresolved: list[str] = field(default_factory=list) # modules a finding asks for, with no citation to be found + work_modules: list[str] = field(default_factory=list) # modules a finding, or a pairing line, asks for work on + + @property + def documents(self) -> list[str]: + """The source documents the targets sit in. Each needs a signed run of its own.""" + out: list[str] = [] + for c in self.targets: + if document_of(c) not in out: + out.append(document_of(c)) + return out + + @property + def dispatchable(self) -> bool: + return bool(self.ready and self.review_finding and not self.blocked) + + +@dataclass +class Batch: + """One source document and every issue with a target in it. + + The encoder takes a bundle only from one jurisdiction + (``prepare_signed_backfill.parse_source_bundle``), and the encoder owner + wants a document encoded whole, so the unit is the document. An issue whose + targets span several documents is in several batches and needs them all. + """ + + document: str + repo: str + issues: list[IssueInputs] + + @property + def citations(self) -> list[str]: + """The issues' targets that sit in this document.""" + out: list[str] = [] + for i in self.issues: + for c in i.targets: + if document_of(c) == self.document and c not in out: + out.append(c) + return out + + @property + def ready(self) -> bool: + """Every issue passes the lint, has a review_finding to paste, and is in the pin.""" + return all(i.dispatchable for i in self.issues) + + +_PE_PR = re.compile(r"PolicyEngine/policyengine-(?:us|uk|canada|il|ng|nz)(?:/pull/|#)(\d+)") +# Blockers an issue's text mentions. They are notes: the text may be stale, so +# only the measured corpus check blocks a batch. +_BLOCKERS = { + "corpus": re.compile( + r"not in the pinned corpus|absent from the (?:signed )?release|re-?pin|corpus (?:ingest|release admission)", + re.I, + ), + "encoder-pin": re.compile(r"does not match the running pinned encoder|encoder pin", re.I), + "waiver": re.compile(r"waiver-frozen|validate_failures", re.I), +} + + +_EXPLICIT_TARGET = re.compile( + r"(?:corpus citation(?: path)?|citation=|citation:)\W{0,8}(?P[a-z]{2}(?:-[a-z0-9]+)*/[a-z]+/[\w./:\-]*\w)", + re.IGNORECASE, +) + + +def _explicit_targets(text: str, cites: list[str]) -> list[str]: + """Citations the issue labels as the one to encode: "corpus citation `X`", "citation=X".""" + out: list[str] = [] + for m in _EXPLICIT_TARGET.finditer(text): + c = m.group("c").rstrip(".,;:)") + # A target is a corpus citation. "uk/regulations/…/75D.yaml" is a module path. + if _CORPUS_SINGULAR.fullmatch(c) and not c.endswith(".yaml") and not _ARTIFACT.search(c) and c not in out: + out.append(c) + return out + + +# A module named in running text: a path (with or without ".yaml") or a legal id. +# The plural kind tells a module ("us/statutes/…") from a corpus citation ("us/statute/…"). +# A trailing slash makes it a directory ("at the matching uk/statutes/ukpga/2014/28/ paths"). +_MODULE_KINDS = "statutes|regulations|policies|legislation|programs" +# Program modules also live at the root: "programs/us-ar/tanf/fy-2026.yaml". +_MODULE_NAME = re.compile( + rf"(?[\w./:\-]*\w)(?P/)?(?:#[\w.]+)?" +) +# Why a finding names a module. Unless the words around it say otherwise, a named +# module is work for the encoder: that is the safe reading, because a plan that +# misses requested work calls an issue ready when it is not. +WORK, IMPORT, MENTION, DEFERRED = "work", "import", "mention", "deferred" +_CLAUSE_STARTS = re.compile(r"[,;]|\b(?:but|and|then)\b", re.IGNORECASE) +_LIST_GOES_ON = re.compile(r"^[\s,()/]*(?:(?:and|or|&|to)[\s,()/]*)?$", re.IGNORECASE) +# A request for work. When one stands between the start of the sentence (or the +# module before) and this module, the module is work whatever describes it: +# "encode the deferred X", "add it to the guarantee that X composes", "encode X as a module". +_REQUESTS = re.compile( + r"\b(?:re-?encode|encode|create|write|add|repair|replace|extend|change|correct|update|compose|wire|complete|" + r"finali[sz]e|fix|split|move|rename|remove|delete)\b", + re.IGNORECASE, +) +# "Declare a deferred dependency on X", "do not import X", "without importing X", "62.yaml defers X". +# The bare adjective is not one: "encode the deferred X" asks for X. +_DEFERS = re.compile( + r"deferred dependenc|\bdefers\b|\bdeferring\b|\bblocked_by\b|do(?:es)? not import|don't import|" + r"must not import|never import|not import\b|without importing|rather than import|instead of import", + re.IGNORECASE, +) +_DEFERRED_AFTER = re.compile(r"^\s+(?:cannot|can't|must not|should not|is not to) be imported", re.IGNORECASE) +_INTO = re.compile(r"\binto\s*$", re.IGNORECASE) # "import it into X": X is edited +# The import word must govern X: "import X", "importing the rule X", "Imports: X", "… from X". +# "Add the import to X" edits X. +_IMPORTS = re.compile( + r"\bimport(?:s|ed|ing)?\s*:?\s*(?:(?:the|a|an|its|their)\s+)?(?:(?:module|rule|amount|value)s?\s+)?$" + r"|\bfrom\b[^,;()]{0,30}$", + re.IGNORECASE, +) +# After a request, only a word that makes X the model to copy turns it into a mention: +# "encode it mirroring the existing X". +_PRECEDENT = re.compile( + r"\b(?:matching|mirror(?:s|ing)?|like|unlike|precedents?|same as|as in|model+ed on|following)\b" + r"[^,;:()]*$", # "existing" alone is not one: "repair the existing X" asks for work on X + re.IGNORECASE, +) +# With no request in sight, these make X a description, a precedent or a condition: +# "matching the existing England module X", "defined in X", "once X has …", "keep X". +_MENTIONS = re.compile( + r"\b(?:existing|matching|mirror(?:s|ing)?|like|unlike|precedents?|sibling|same as|as in|model+ed on|" + r"follow(?:s|ing)?|see|cf|compare|defined in|declared in|once|until|when|if|unless|because|since|whereas|" + r"while|keep|leave|preserve|retain|stays? in|remains? in|lives? in|kept in|left in)\b[^,;:()]*$", + re.IGNORECASE, +) +# "X does", "X currently defers …", "X carries the aggregate", "X's blocked_by": X is the +# subject of a description. The verbs are listed: "X as a module" and "X needs a rule" are not here. +_MENTIONED_AFTER = re.compile( + r"^(?:'s\b|\s+(?:does|do|did|already|currently|still|now|today|has|have|had|is|are|was|were|carries|outputs|" + r"defines|states|takes|uses|reads|imports|exposes|encodes|covers|models|labels|composes|holds|contains|" + r"includes|gives|provides|sets|keeps|treats|applies|computes|derives|returns|remains|stays|" + r"must (?:still )?pass)\b)", + re.IGNORECASE, +) +# "following the precedent min(a, 3) in X": within its clause the precedent word reaches X across brackets. +_PRECEDENT_ANYWHERE = re.compile(r"\b(?:precedents?|existing|mirror(?:s|ing)?|matching)\b", re.IGNORECASE) + + +def _own_clause(gap: str) -> str: + """The part of ``gap`` after its last clause start that is not inside brackets.""" + depth, start = 0, 0 + breaks = {m.start(): m.end() for m in _CLAUSE_STARTS.finditer(gap)} + for i, ch in enumerate(gap): + if ch in "([": + depth += 1 + elif ch in ")]": + depth = max(0, depth - 1) + elif depth == 0 and i in breaks: + start = breaks[i] + return gap[start:] + + +# "uk/statutes/ukpga/1992/14/6.yaml, 9.yaml and 77.yaml": the short names are siblings of the first. +_SIBLINGS = re.compile( + rf"(?P(?[\w.:\-]+\.yaml)(?P(?:(?:\s*,\s*|\s+(?:and|or|to)\s+)[\w.:\-]+\.yaml\b(?!/))+)" +) + + +def expand_siblings(text: str) -> str: + """Spell out sibling module names given short after a full path.""" + + def spell(m: re.Match[str]) -> str: + rest = re.sub(r"(? str: + """The text as paths are read from it. + + No code ticks or emphasis (``_x_``, ``**x**``; underscores inside a name stay), + and braces and sibling names spelled out. + """ + return expand_siblings(expand_braces(_EDGE_EMPHASIS.sub("", text.replace("`", "")))) + + +def normal_module(name: str) -> str: + """``us:statutes/26/1/c#x``, ``us/statutes/26/1/c`` and ``us/statutes/26/1/c.yaml`` are one module.""" + stem = norm_dashes(name).split("#", 1)[0].rstrip(".") + # "13.yaml/.test.yaml" names a module and its test file together. + stem = re.sub(r"(?:\.yaml)?/?\.test\.yaml$|\.yaml$", "", stem) + juris, sep, rest = stem.partition(":") + if sep and "/" not in juris: + stem = f"{juris}/{rest}" + return stem + ".yaml" + + +def _spelled_citation(module: str) -> str | None: + """The citation a statute or regulation module's path spells, whatever the jurisdiction.""" + juris, kind, *rest = normal_module(module)[: -len(".yaml")].split("/") + singular = {"statutes": "statute", "regulations": "regulation"}.get(kind) + if not singular or not rest: + return None + if juris == "us" and kind == "regulations" and rest[0].endswith("-cfr"): + rest = [rest[0][: -len("-cfr")], *rest[1:]] + return f"{juris}/{singular}/{'/'.join(rest)}" + + +def citation_for_module(module: str) -> str | None: + """The corpus citation a statute or regulation module encodes, from its path. + + ``us/statutes/26/32/e.yaml`` -> ``us/statute/26/32/e``; + ``us/regulations/7-cfr/273/9.yaml`` -> ``us/regulation/7/273/9``. Only + federal US and UK paths mirror their citations: a state module's path + respells the section (``47:44.1`` becomes ``47/44/1``), and a policy + module's path doesn't give its citation at all. + """ + return _spelled_citation(module) if module.split("/")[0] in ("us", "uk") else None + + +def cited_above(module: str, cites: list[str]) -> str | None: + """The citation the issue gives for the module's provision, or for the nearest provision above it. + + ``us-mt/statutes/15-30-2131/1/g.yaml`` is a part of ``us-mt/statute/15-30-2131`` + when the issue cites that section. + """ + spelled = _spelled_citation(module) + segs = spelled.split("/") if spelled else [] + for i in range(len(segs), 2, -1): + if "/".join(segs[:i]) in cites: + return "/".join(segs[:i]) + return None + + +def _sentences(text: str) -> list[str]: + return [t for t in re.split(r"(?<=[.!?;])\s+|\n+", text) if t.strip()] + + +def _modules_in(sentence: str) -> list[tuple[str, str]]: + """(module, WORK | IMPORT | MENTION | DEFERRED) for each module one sentence of plain text names. + + The words between the start of the module's own clause and the module + decide: "import X", "do not import X", "mirroring the existing X". A list + ("a.yaml, b.yaml and c.yaml") shares the first module's kind. + """ + out: list[tuple[str, str]] = [] + last_end, kind = 0, WORK + for m in _MODULE_NAME.finditer(sentence): + if m.group("dir"): + continue + gap = sentence[last_end : m.start()] + after = sentence[m.end() :] + if out and _LIST_GOES_ON.match(gap): + pass # the list's first module decided + else: + # The module's own clause decides. A request in it makes the module work + # unless what stands between the request and the module says otherwise. + lead = _own_clause(gap) + requests = list(_REQUESTS.finditer(lead)) + tail = lead[requests[-1].end() :] if requests else lead + comparison = re.search(r"\bas\s*$", lead) and re.match(r"\s+(?:does|do|did)\b", after) + if _DEFERS.search(tail) or _DEFERRED_AFTER.match(after): + kind = DEFERRED + elif _INTO.search(lead): + kind = WORK + elif _IMPORTS.search(tail): + kind = IMPORT + elif requests: + kind = MENTION if _PRECEDENT.search(tail) or comparison else WORK + else: + # "(X)" on its own is a pointer to where something lives. + aside = lead.rstrip().endswith("(") and after.lstrip().startswith(")") + described = _MENTIONS.search(lead.rstrip("( ") + " ") or _MENTIONED_AFTER.match(after) + kind = MENTION if described or aside or _PRECEDENT_ANYWHERE.search(lead) else WORK + out.append((normal_module(m.group(0)), kind)) + last_end = m.end() + return out + + +_STRUCTURAL = {"paragraph", "schedule", "article", "section", "part", "chapter", "regulation", "page", "block"} + + +def _complete(citation: str) -> bool: + """A citation that reaches a provision: not "uk/regulation/nisr", not "…/schedule/3/paragraph".""" + segs = citation.split("/") + return len(segs) >= len(document_of(citation + "/x/x/x").split("/")) and segs[-1] not in _STRUCTURAL + + +def _corpus_citations(text: str) -> list[str]: + out: list[str] = [] + for m in _CORPUS_SINGULAR.finditer(text): + c = m.group(0).rstrip(".,;:)") + if not _ARTIFACT.search(c) and _complete(c) and c not in out: + out.append(c) + return out + + +# A line that only pairs a module with a citation: "- us-nm/statutes/7-2-18/13.yaml — us-nm/statute/7-2-18.13", +# "Module path: us/statutes/26/32/d.yaml (corpus citation us/statute/26/32/d)", or a table row of the two. +_PAIR_FILLER = re.compile( + r"^(?:[\s—–\-:→|()\[\],.>*#]|\b(?:corpus|citation|path|module|target|destination|new|at|from|for|and|test|file)\b)*$", + re.IGNORECASE, +) + + +def _pair_line(line: str) -> tuple[list[str], list[str]]: + """(modules, citations) when the line is nothing but a module-and-citation pairing.""" + modules = [normal_module(m.group(0)) for m in _MODULE_NAME.finditer(line) if not m.group("dir")] + cites = _corpus_citations(line) + if not modules or not cites: + return [], [] + # "1. module — citation" and "2) module — citation" are list items like "- module — citation". + rest = _CORPUS_SINGULAR.sub("", _MODULE_NAME.sub("", _LIST_MARKER.sub("", line))) + return (modules, cites) if _PAIR_FILLER.match(rest) else ([], []) + + +def issue_inputs(repo: str, issue: dict[str, Any], comments: list[dict[str, Any]]) -> IssueInputs: + texts = [issue.get("body") or ""] + [c.get("body") or "" for c in comments] + joined = plain("\n".join(texts)) + res = lint_issue(issue, comments) + cites = _corpus_citations(joined) + # Comments add to the body's finding ("it adds to step 1's") more often than they + # replace it, so the dispatcher gets every block, in order, and decides. + findings = [f for t in texts for f in review_findings(t)] + finding = "\n\n".join(findings) if findings else None + asked = plain(finding or "") + + targets = _explicit_targets(joined, cites) + modules: list[str] = [] + work_modules: list[str] = [] + unresolved: list[str] = [] + + def add(items: list[str], into: list[str]) -> None: + into.extend(i for i in items if i not in into) + + # Everything a finding names in corpus form is a target: an explicit target + # must not hide the findings' other requests. + add([c for c in cites if re.search(rf"(? bool: + """The citation's own row, or rows below it, carry an operative body in the pin. + + The encoder composes a row's text from its descendants when the row itself + has none, so text below the path counts. A row marked repealed does not: + the encoder refuses it outright. + """ + if repealed and citation in repealed: + return False + if citation in corpus: + return True + prefix = citation + "/" + return any(c.startswith(prefix) for c in corpus) + + +def load_pin_file(path: str) -> tuple[set[str], set[str]]: + """(readable citations, repealed citations) from a ``corpus-pin`` file; "!" marks repealed.""" + present: set[str] = set() + repealed: set[str] = set() + # One citation a line: a citation can hold spaces ("…/chapter-45/7 AAC 45.010"). + for line in Path(path).read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + (repealed if line.startswith("!") else present).add(line.lstrip("!")) + return present, repealed + + +# One signed run can carry this many citations in ``source_bundle_json`` +# (axiom-encode ``prepare_signed_backfill.MAX_SOURCE_BUNDLE_CITATIONS``), so a +# document's provisions can share one run and one signing approval. +MAX_BUNDLE_CITATIONS = 16 + + +def load_blocked_modules(path: str) -> set[str]: + """Module paths the encoder owner has ruled out, from a text list or a JSON of lists. + + The first use is modules that carry, or import a module that carries, the + obsolete ``source_verification.values`` field: a target importing one + fails to compile. + """ + text = Path(path).read_text(encoding="utf-8") + try: + data = json.loads(text) + except json.JSONDecodeError: + return {line.strip() for line in text.splitlines() if line.strip()} + out: set[str] = set() + for value in data.values() if isinstance(data, dict) else [data]: + if isinstance(value, list): + out.update(str(v) for v in value) + return out + + +@dataclass(frozen=True) +class BlockedCitation: + """A citation the encoder owner has ruled out for now, and why.""" + + citation: str + descendants: bool # also block paths below it + kind: str + reason: str + + def matches(self, target: str) -> bool: + return target == self.citation or (self.descendants and target.startswith(self.citation + "/")) + + +def load_blocked_citations(path: str) -> list[BlockedCitation]: + """Read the encoder owner's holds: ``{"blocked": [{citation, match, kind, reason}, ...]}`` or a bare list. + + ``match`` is ``exact`` or ``exact-or-descendant``. The owner's + dispatch-preparation verdicts are read too: ``{"documents": [{citation, + verdict, reason, until}, ...]}``. Every row is a hold on dispatching that + citation now, whether it is blocked, waiting for a ruling or already in + flight. A text file with one citation a line blocks each exactly. + """ + text = Path(path).read_text(encoding="utf-8") + try: + data = json.loads(text) + except json.JSONDecodeError: + return [BlockedCitation(c.strip(), False, "listed", "listed") for c in text.splitlines() if c.strip()] + rows = (data.get("blocked") or data.get("documents") or []) if isinstance(data, dict) else data + out = [] + for r in rows: + if not (isinstance(r, dict) and r.get("citation")): + continue + reason = str(r.get("reason", "")) + if r.get("until"): + reason += f" (until {r['until']})" + out.append( + BlockedCitation( + str(r["citation"]), + r.get("match", "exact") == "exact-or-descendant", + str(r.get("kind") or r.get("verdict") or "listed"), + reason, + ) + ) + return out + + +@dataclass(frozen=True) +class WorkHolds: + """Modules the encoder owner says a signed run does not edit, and its rule for why. + + The first use is assembled pipelines: a change an issue asks for in one is a + reviewed hand edit, not an encode. An issue that only imports from such a + module is not held; one that asks for work on it is. + """ + + rule: str + modules: dict[str, tuple[str, ...]] # module path -> the owner's evidence for listing it + + +def load_work_holds(path: str) -> WorkHolds: + """Read ``{"rule": "...", "modules": [{"module": "...", "evidence": [...]}, ...]}``.""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + modules: dict[str, tuple[str, ...]] = {} + for row in data.get("modules", []): + if isinstance(row, dict) and row.get("module"): + evidence = row.get("evidence") or [] + modules[normal_module(str(row["module"]))] = tuple( + str(e) for e in ([evidence] if isinstance(evidence, str) else evidence) + ) + return WorkHolds(str(data.get("rule", "")).strip(), modules) + + +def _path_ancestors(module: str) -> list[str]: + """``a/statutes/26/32/d/1.yaml`` -> itself, ``…/32/d.yaml``, ``…/32.yaml``, ``…/26.yaml``.""" + segs = module[: -len(".yaml")].split("/") + return ["/".join(segs[:i]) + ".yaml" for i in range(len(segs), 2, -1)] + + +def modules_at_stake(row: IssueInputs, owners: Callable[[str], Iterable[str]] | None = None) -> set[str]: + """Every module a run for this issue would write, extend or import. + + That is: the modules the issue declares or its findings tell the encoder + to use; the module each target's path gives, and the modules above it (a + section module owns its subsections); and, when ``owners`` can look them + up, the modules on rulespec ``main`` that cite the target or a provision + above it. + """ + out = set(row.modules) + for c in row.targets: + out.update(_path_ancestors(module_path_for(c))) + if owners: + segs = c.split("/") + for i in range(len(segs), 2, -1): + out.update(owners("/".join(segs[:i]))) + return out + + +def plan( + rows: list[IssueInputs], + corpus: set[str] | None = None, + blocked_modules: set[str] | None = None, + blocked_patterns: list[str] | None = None, + blocked_citations: list[BlockedCitation] | None = None, + repealed: set[str] | None = None, + owners: Callable[[str], Iterable[str]] | None = None, + work_holds: WorkHolds | None = None, +) -> list[Batch]: + """Check each issue against the pin and the block lists, then group by source document.""" + blocked_modules = {normal_module(m) for m in blocked_modules or ()} + groups: dict[tuple[str, str], list[IssueInputs]] = {} + for r in rows: + if not r.targets: + continue + if corpus is not None and not all(in_pin(c, corpus, repealed) for c in r.targets): + r.blocked.append("not in the pinned corpus release") + hits = sorted({b.kind for b in blocked_citations or [] for c in r.targets if b.matches(c)}) + if hits: + r.blocked.append(f"target held by the encoder owner ({', '.join(hits)})") + for pattern in blocked_patterns or []: + # A class of source the encoder owner has paused, e.g. "/manual/". + if any(re.search(pattern, c) for c in r.targets): + r.blocked.append(f"target matches the blocked pattern {pattern}") + if blocked_modules and modules_at_stake(r, owners) & blocked_modules: + r.blocked.append("targets or imports a module the encoder owner has blocked") + held = [m for m in r.work_modules if work_holds and m in work_holds.modules] + if held: + # The owner's rule explains these better than a missing citation would: + # naming a citation would not make the issue dispatchable. + r.blocked.append( + "asks for work on a module the encoder owner holds (see its rule): " + + ", ".join(f"{m} [{'; '.join(work_holds.modules[m])}]" for m in held) + ) + unresolved = [m for m in r.unresolved if m not in held] + if unresolved: + r.blocked.append( + "a finding asks for work on a module whose corpus citation the issue doesn't give: " + + ", ".join(unresolved) + ) + for doc in r.documents: + groups.setdefault((r.repo, doc), []).append(r) + batches = [Batch(doc, repo, issues) for (repo, doc), issues in groups.items()] + batches.sort(key=lambda b: (not b.ready, -len(b.issues), -sum(len(i.pe_prs) for i in b.issues), b.document)) + return batches + + +def drainable(batches: list[Batch]) -> list[IssueInputs]: + """Issues every one of whose documents is ready: the ones a full wave would close.""" + ready_docs = {(b.repo, b.document) for b in batches if b.ready} + out: list[IssueInputs] = [] + for b in batches: + for i in b.issues: + if i not in out and all((i.repo, d) in ready_docs for d in i.documents): + out.append(i) + return out + + +def load_issues(gh: GitHub, repo: str, label: str = "pe-parity") -> list[tuple[dict, list[dict]]]: + return gh.open_issues_with_comments(repo, label) + + +def load_saved(path: str, repo: str) -> list[tuple[dict, list[dict]]]: + out = [] + for i in json.loads(Path(path).read_text(encoding="utf-8")): + i.setdefault("html_url", f"https://github.com/{repo}/issues/{i['number']}") + out.append((i, i.get("comments") if isinstance(i.get("comments"), list) else [])) + return out + + +def runs_needed(batch: Batch) -> int: + """Signed runs, and so signing approvals, a document needs: 16 citations a run.""" + return max(1, -(-len(batch.citations) // MAX_BUNDLE_CITATIONS)) + + +def to_json(batches: list[Batch], weekly_runs: int, work_holds: WorkHolds | None = None) -> dict[str, Any]: + ready = [b for b in batches if b.ready] + issues = {(i.repo, i.number): i for b in batches for i in b.issues} + return { + "measured_cost": MEASURED, + "weekly_runs": weekly_runs, + "work_hold_rule": work_holds.rule if work_holds else None, + "batches": [ + { + "document": b.document, + "repo": b.repo, + "ready": b.ready, + "runs": runs_needed(b), + "citations": b.citations, + "issues": [{**i.__dict__, "documents": i.documents} for i in b.issues], + } + for b in batches + ], + "summary": { + "documents": len(batches), + "issues": len(issues), + "ready_documents": len(ready), + "ready_issues": len(drainable(batches)), + "ready_citations": sum(len(b.citations) for b in ready), + "ready_runs": sum(runs_needed(b) for b in ready), + "blocked_issues": sum(1 for i in issues.values() if i.blocked), + "not_ready_issues": sum(1 for i in issues.values() if not i.ready), + }, + } + + +def markdown(batches: list[Batch], weekly_runs: int, work_holds: WorkHolds | None = None) -> str: + s = to_json(batches, weekly_runs)["summary"] + m = MEASURED + units = weekly_runs * MAX_BUNDLE_CITATIONS + out = [ + "## pe-parity drain plan", + "", + f"{s['issues']} issues with a target citation, across {s['documents']} source documents. " + f"{s['ready_documents']} documents are ready to encode: {s['ready_citations']} citations, " + f"{s['ready_runs']} signed runs (one document a run, up to {MAX_BUNDLE_CITATIONS} citations each). Those " + f"runs would complete {s['ready_issues']} issues. {s['blocked_issues']} issues are blocked, and " + f"{s['not_ready_issues']} fail the dispatch-ready lint. An issue whose targets span several documents is " + "listed under each and needs them all.", + "", + f"Cost basis, measured over {m['window']}: {m['runs']} signed runs of one citation each, {m['successes']} " + f"succeeded, ${m['spend_usd']:.2f} model spend. That is ${m['mean_run_usd']:.3f} a citation on average, " + f"${m['p90_run_usd']:.3f} at p90 and ${m['max_run_usd']:.3f} at most. A week of {weekly_runs} signing " + f"approvals covers up to {units} citations: about ${units * m['mean_run_usd']:.2f} at the mean, " + f"${units * m['p90_run_usd']:.2f} at p90 and at most ${units * m['max_run_usd']:.2f}. Bundled runs have not " + "been measured yet.", + "", + "| # | Document | Repo | Issues | Citations | Ready | Blockers or missing |", + "|---|---|---|---|---|---|---|", + ] + for n, b in enumerate(batches, 1): + problems = sorted( + {x for i in b.issues for x in i.blocked} | {f"missing {x}" for i in b.issues for x in i.missing} + ) + issues = ", ".join(f"[#{i.number}]({i.url})" for i in b.issues[:6]) + (" …" if len(b.issues) > 6 else "") + out.append( + f"| {n} | `{b.document}` | {b.repo.split('/')[-1]} | {issues} | {len(b.citations)} | " + f"{'yes' if b.ready else 'no'} | {', '.join(problems) or '-'} |" + ) + if ( + work_holds + and work_holds.rule + and any("holds (see its rule)" in x for b in batches for i in b.issues for x in i.blocked) + ): + out += ["", f"The encoder owner's rule for held modules: {work_holds.rule}"] + return "\n".join(out) + "\n" diff --git a/axiom-parity/src/axiom_parity/finder/__init__.py b/axiom-parity/src/axiom_parity/finder/__init__.py new file mode 100644 index 0000000..2d03b0e --- /dev/null +++ b/axiom-parity/src/axiom_parity/finder/__init__.py @@ -0,0 +1 @@ +"""The parity finder: from a PolicyEngine change to the Axiom modules and issues it touches.""" diff --git a/axiom-parity/src/axiom_parity/finder/citations.py b/axiom-parity/src/axiom_parity/finder/citations.py new file mode 100644 index 0000000..37c7bb2 --- /dev/null +++ b/axiom-parity/src/axiom_parity/finder/citations.py @@ -0,0 +1,439 @@ +"""Map a reference URL to an Axiom corpus citation path. + +The grammar comes from the ``corpus_citation_path`` values RuleSpec modules +actually carry (about 19,000 in rulespec-us and rulespec-uk on 2026-10-10): + +- US Code: ``us/statute//<section>[/<subsection>...]`` + (``us/statute/26/151/d/5``); +- CFR: ``us/regulation/<title>/<part>/<section>[/...]``; 7 CFR 273.9 is + ``us/regulation/7/273/9`` and 26 CFR 1.1401-1(d)(2)(i) is + ``us/regulation/26/1/1401-1/d/2/i``; +- UK primary legislation: ``uk/statute/<type>/<year>/<number>/<section>``; + secondary: ``uk/regulation/<type>/<year>/<number>/<regulation>``; + schedules ``.../schedule/<n>/paragraph/<p>``, articles ``.../article/<n>``; +- GOV.UK guidance: ``uk/guidance/govuk/<slug>[/<page>]``; +- state statutes vary by state (``us-ca/statute/rtc/17041``, + ``us-md/statute/gtg/10-709``, ``us-oh/statute/5747.02``). Where no exact + rule is known, ``StateHint`` carries the state and section number so the + index can match it against that state's citations. +""" + +from __future__ import annotations + +import re +import urllib.parse +from dataclasses import dataclass +from typing import Any + +from ..refs import norm_dashes + +UK_PRIMARY = { + "ukpga", + "asp", + "anaw", + "asc", + "nia", + "ukla", + "ukcm", + "mwa", + "apni", + "aep", + "aosp", + "aip", + "apgb", + "gbla", + "ukppa", +} +UK_SECONDARY = { + "uksi", + "ssi", + "wsi", + "nisr", + "nisi", + "ukmo", + "uksro", + "ssro", + "nisro", + "ukci", + "ukdsi", + "sdsi", + "wdsi", + "nidsr", +} + + +@dataclass(frozen=True) +class Citation: + path: str # corpus citation path + source: str # which rule produced it + exact: bool = True # False for a heuristic hint + + +@dataclass(frozen=True) +class StateHint: + """A state source with no exact grammar: match by section number.""" + + jurisdiction: str # "us-ny" + section: str # "606" + source: str + + +def _fragment_parts(fragment: str) -> list[str]: + """``#i_2`` -> ["i", "2"]; ``#p-273.9(d)(5)`` -> ["d", "5"]; ``#c_1_A_ii`` -> [...].""" + if not fragment: + return [] + paren = re.findall(r"\(([A-Za-z0-9]+)\)", fragment) + if paren: + return paren + frag = re.sub(r"^(?:p-|section-|subsection-|para-)", "", fragment) + if re.fullmatch(r"[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*", frag): + return frag.split("_") + return [] + + +def _usc(title: str, section: str, fragment: str = "") -> Citation: + parts = [p for p in _fragment_parts(fragment)] + return Citation("/".join(["us/statute", title, section.lower() if section.isalpha() else section, *parts]), "usc") + + +def _cfr(title: str, section: str, fragment: str = "") -> Citation | None: + if "." not in section: + return None + part, sec = section.split(".", 1) + parts = _fragment_parts(fragment) + return Citation("/".join(["us/regulation", title, part, sec, *parts]), "cfr") + + +def _legislation_gov_uk(path: str) -> Citation | None: + segs = [s for s in path.strip("/").split("/") if s] + if segs and segs[0] == "id": # the identifier form of the same URL + segs = segs[1:] + if len(segs) < 3: + return None + kind, year, number = segs[0], segs[1], segs[2] + if kind in UK_PRIMARY: + root = "uk/statute" + elif kind in UK_SECONDARY: + root = "uk/regulation" + else: + return None + if not re.fullmatch(r"\d{4}|[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", year) or not re.fullmatch(r"\d+", number): + return None + rest = segs[3:] + doc = "/".join([root, kind, year, number]) + if rest and rest[0] == "pdfs": + # The instrument's own PDF is the document; explanatory notes and memoranda are not. + name = rest[1] if len(rest) > 1 else "" + return Citation(doc, "legislation.gov.uk") if re.fullmatch(rf"{kind}_\d+_en(?:_\d+)?\.pdf", name) else None + # Parts, chapters and cross-headings are groupings, not provisions. + if rest and rest[0] in ("part", "chapter", "crossheading"): + return None + # Drop version and format suffixes: /2024-04-06, /enacted, /made, /data.xml, /contents. + clean: list[str] = [] + for s in rest: + if re.fullmatch(r"\d{4}-\d{2}-\d{2}|enacted|made|contents|data\.(?:xml|htm|html|pdf)|\w+\.pdf|prospective", s): + break + clean.append(s) + out = [doc] + units = ("schedule", "appendix", "article", "paragraph", "part") + i = 0 + while i < len(clean): + key = clean[i] + val = clean[i + 1] if i + 1 < len(clean) else None + if key in ("section", "regulation", "rule") and val: + out.append(val) # corpus rows are whole sections; /section/190/2 is still section 190 + break + if key in units and val in units: + # An unnumbered schedule: /schedule/paragraph/16 + out.append(key) + i += 1 + continue + if key in units and val: + out.extend([key, val]) + i += 2 + continue + break # a bare /schedule, or anything unrecognised, is the document so far + return Citation("/".join(out), "legislation.gov.uk") + + +# GOV.UK pages whose corpus slug differs from the URL slug. +_GOVUK_ALIASES = {"repaying-your-student-loan": "student-loan-repayments"} + + +def _gov_uk(path: str) -> Citation | None: + segs = [s for s in path.strip("/").split("/") if s] + if not segs or segs[0] in ("government", "search", "browse"): + # Publications and policy papers have no rule-derivable corpus path. + return None + if segs[0] == "guidance": + segs = segs[1:] + if not segs: + return None + segs[0] = _GOVUK_ALIASES.get(segs[0], segs[0]) + return Citation("uk/guidance/govuk/" + "/".join(segs), "gov.uk") + + +_USC_GRANULE = re.compile(r"USC-(?:prelim-)?title(\d+)-section([0-9A-Za-z-]+)", re.IGNORECASE) +_GOVINFO_USC = re.compile( + r"USCODE-\d{4}-title(\d+)/[^/]*?USCODE-\d{4}-title\d+-[^/]*?sec([0-9A-Za-z-]+)", re.IGNORECASE +) +_GOVINFO_CFR = re.compile(r"CFR-\d{4}-title(\d+)-vol\d+-sec(\d+[-.]?[0-9A-Za-z-]*)", re.IGNORECASE) + + +def map_url(url: str) -> Citation | StateHint | None: + """Map one reference URL; None when the URL names no corpus source.""" + # One normalized form (percent-decoded, any dash as a hyphen) feeds every rule below. + url = norm_dashes(urllib.parse.unquote(url.strip())) + try: + parsed = urllib.parse.urlparse(url) + except ValueError: + return None + host = (parsed.netloc or "").lower().removeprefix("www.") + path = urllib.parse.unquote(parsed.path) + query = urllib.parse.parse_qs(parsed.query) + frag = urllib.parse.unquote(parsed.fragment) + + if host == "legislation.gov.uk": + return _legislation_gov_uk(path) + if host == "gov.uk": + return _gov_uk(path) + + if host == "law.cornell.edu": + m = re.match(r"/uscode/text/(\d+)/([0-9A-Za-z-]+)", path) + if m: + return _usc(m.group(1), m.group(2), frag) + m = re.match(r"/cfr/text/(\d+)/(\d+\.[0-9A-Za-z-]+)", path) + if m: + return _cfr(m.group(1), m.group(2), frag) + return None + if host == "uscode.house.gov": + m = _USC_GRANULE.search(url) + if m: + return _usc(m.group(1), m.group(2), frag) + return None + if host == "govinfo.gov": + m = ( + _USC_GRANULE.search(url) + or _GOVINFO_USC.search(url) + or re.search(r"/link/uscode/(\d+)/([0-9A-Za-z-]+)", path) + or re.search(r"USCODE-\d{4}-title(\d+)-.*?-sec([0-9A-Za-z-]+)\.htm", path) + ) + if m: + return _usc(m.group(1), m.group(2), frag) + m = re.search(r"PLAW-(\d+)publ(\d+)", path) + if m: + return Citation(f"us/statute/public-law/{m.group(1)}-{m.group(2)}", "govinfo-plaw") + m = _GOVINFO_CFR.search(url) + if m: + return _cfr(m.group(1), m.group(2).replace("-", ".", 1) if "." not in m.group(2) else m.group(2), frag) + return None + if host == "ecfr.gov": + m = re.search(r"/title-(\d+)/(?:.*/)?section-(\d+\.[0-9A-Za-z-]+)", path) + if m: + return _cfr(m.group(1), m.group(2), frag) + m = re.search(r"/title-(\d+)/(?:.*/)?part-(\d+)(?:/|$)", path) + if m and "section" in query: + return _cfr(m.group(1), query["section"][0], frag) + if m: + # A part URL: #p-273.9(d)(5) names the section; otherwise the part itself. + sec = re.match(rf"p-({m.group(2)}\.[0-9A-Za-z-]+)", frag) + if sec: + return _cfr(m.group(1), sec.group(1), frag) + return Citation(f"us/regulation/{m.group(1)}/{m.group(2)}", "cfr") + return None + if host == "irs.gov": + # Revenue procedures and notices: /pub/irs-drop/rp-25-32.pdf, n-25-67.pdf + m = re.search(r"/pub/irs-drop/(rp|n)-(\d{2})-(\d+)\.pdf$", path) + if m: + kind = "rev-proc" if m.group(1) == "rp" else "notice" + return Citation(f"us/guidance/irs/{kind}-20{m.group(2)}-{int(m.group(3))}", "irs-drop") + return None + + # California codes: codes_displaySection.xhtml?lawCode=RTC§ionNum=17041 + if host == "leginfo.legislature.ca.gov": + code = (query.get("lawCode") or [""])[0].lower() + sec = (query.get("sectionNum") or [""])[0].rstrip(".") + if code and sec: + return Citation(f"us-ca/statute/{code}/{sec}", "ca-leginfo") + return None + # Maryland: mgaleg.maryland.gov/...?article=gtg§ion=10-709 + if host == "mgaleg.maryland.gov": + art = (query.get("article") or [""])[0].lower() + sec = (query.get("section") or [""])[0] + if art and sec: + return Citation(f"us-md/statute/{art}/{sec}", "md-mgaleg") + return None + # Ohio: codes.ohio.gov/ohio-revised-code/section-5747.02 + if host == "codes.ohio.gov": + m = re.search(r"/ohio-revised-code/section-([0-9.]+)", path) + if m: + return Citation(f"us-oh/statute/{m.group(1)}", "oh-codes") + return None + + exact = _state_exact(host, path, query, frag) + if exact: + return exact + + state = _STATE_HOSTS.get(host) + if state: + section = _section_token(path, query) + if section: + return StateHint(state, section, host) + return None + + +def _state_exact(host: str, path: str, query: dict[str, list[str]], frag: str) -> Citation | None: + """State code sites whose URL gives the corpus path exactly.""" + + def q(key: str) -> str: + return (query.get(key) or [""])[0] + + rules: list[tuple[str, str, Any]] = [ + ( + "nysenate.gov", + r"^/legislation/laws/([A-Za-z]+)/([0-9A-Za-z.\-]+)$", + lambda m: f"us-ny/statute/{m[1].upper()}/{m[2]}", + ), + ( + "malegislature.gov", + r"/Laws/GeneralLaws/.*?/Chapter(\w+)/Section(\w+)$", + lambda m: f"us-ma/statute/{m[1]}/{m[2]}", + ), + ( + "legislature.maine.gov", + r"^/statutes/(\w+)/title\w+sec([0-9A-Za-z-]+)\.html$", + lambda m: f"us-me/statute/{m[1]}/{m[2]}", + ), + ("revisor.mn.gov", r"^/statutes/cite/([0-9A-Za-z.]+)$", lambda m: f"us-mn/statute/{m[1]}"), + ( + "legislature.vermont.gov", + r"^/statutes/section/(\d+)/\d+/0*(\d+\w*)$", + lambda m: f"us-vt/statute/{int(m[1])}-{m[2]}", + ), + ("legis.iowa.gov", r"^/docs/code/(?:\d{4}/)?([0-9A-Za-z.]+)\.pdf$", lambda m: f"us-ia/statute/{m[1]}"), + ( + "law.lis.virginia.gov", + r"^/vacode/(?:title[\d.]+/chapter[\d.]+/section)?((\d+(?:\.\d+)?)-[0-9A-Za-z.:]+)/?$", + lambda m: f"us-va/statute/{m[2]}/{m[1]}", + ), + ("docs.legis.wisconsin.gov", r"^/statutes/statutes/(\d+)/\w+/(\w+)$", lambda m: f"us-wi/statute/{m[1]}.{m[2]}"), + ] + for rule_host, pattern, build in rules: + if host == rule_host: + m = re.search(pattern, path) + if m: + return Citation(build(m), rule_host) + if host == "cga.ct.gov": + m = re.fullmatch(r"sec_([0-9A-Za-z-]+)", frag) + if m: + return Citation(f"us-ct/statute/{m[1]}", host) + if host == "ilga.gov" and q("DocName"): + m = re.fullmatch(r"(\d{4})(\d{4})0K([0-9A-Za-z.\-]+)", q("DocName")) + if m: + return Citation(f"us-il/statute/{int(m[1])}/{int(m[2])}/{m[3]}", host) + if host == "legislature.mi.gov" and q("objectName"): + m = re.fullmatch(r"mcl-(\d+)-([0-9A-Za-z]+)", q("objectName"), re.I) + if m: + return Citation(f"us-mi/statute/{m[1]}.{m[2]}", host) + if host == "revisor.mo.gov" and q("section"): + return Citation(f"us-mo/statute/{q('section')}", host) + if host == "oregonlegislature.gov": + m = re.fullmatch(r"(\d+[A-Z]?\.\d+)", frag) + if m and re.search(r"/ors/ors\w+\.html$", path): + return Citation(f"us-or/statute/{m[1]}", host) + if host == "app.leg.wa.gov" and q("cite"): + cite = q("cite") + parts = cite.split(".") + if len(parts) == 3: + return Citation(f"us-wa/statute/{parts[0]}/{parts[0]}.{parts[1]}/{cite}", host) + if host == "mygov.scot": + segs = [s for s in path.strip("/").split("/") if s] + if segs: + return Citation("uk/guidance/mygovscot/" + "/".join(segs), host) + return None + + +def _section_token(path: str, query: dict[str, list[str]]) -> str | None: + for key in ("section", "sectionNum", "SectionNumber", "sec", "statute", "num"): + if key in query and query[key]: + return query[key][0] + tokens = re.findall(r"\d+[A-Za-z]?(?:[-.:]\d+[A-Za-z]?)+|\d{2,}[A-Za-z]?", path) + return tokens[-1] if tokens else None + + +# State legislature and code hosts. A hit gives a StateHint, matched by +# section number against that state's corpus citations. +_STATE_HOSTS = { + "nysenate.gov": "us-ny", + "codes.findlaw.com": "", + "revisor.mo.gov": "us-mo", + "law.lis.virginia.gov": "us-va", + "ncleg.gov": "us-nc", + "legislature.mi.gov": "us-mi", + "ilga.gov": "us-il", + "malegislature.gov": "us-ma", + "docs.legis.wisconsin.gov": "us-wi", + "legislature.vermont.gov": "us-vt", + "legislature.maine.gov": "us-me", + "capitol.hawaii.gov": "us-hi", + "data.capitol.hawaii.gov": "us-hi", + "azleg.gov": "us-az", + "leg.mt.gov": "us-mt", + "iga.in.gov": "us-in", + "legis.ga.gov": "us-ga", + "oscn.net": "us-ok", + "leg.colorado.gov": "us-co", + "law.justia.com": "", + "le.utah.gov": "us-ut", + "legislature.idaho.gov": "us-id", + "revisor.mn.gov": "us-mn", + "legis.iowa.gov": "us-ia", + "kslegislature.gov": "us-ks", + "kslegislature.org": "us-ks", + "apps.legislature.ky.gov": "us-ky", + "legis.la.gov": "us-la", + "legislature.la.gov": "us-la", + "nebraskalegislature.gov": "us-ne", + "leg.state.nv.us": "us-nv", + "gencourt.state.nh.us": "us-nh", + "nmlegis.gov": "us-nm", + "legislature.nd.gov": "us-nd", + "ndlegis.gov": "us-nd", + "oregonlegislature.gov": "us-or", + "legis.state.pa.us": "us-pa", + "palegis.us": "us-pa", + "webserver.rilegislature.gov": "us-ri", + "rilegislature.gov": "us-ri", + "scstatehouse.gov": "us-sc", + "sdlegislature.gov": "us-sd", + "capitol.tn.gov": "us-tn", + "statutes.capitol.texas.gov": "us-tx", + "app.leg.wa.gov": "us-wa", + "code.wvlegislature.gov": "us-wv", + "wyoleg.gov": "us-wy", + "cga.ct.gov": "us-ct", + "delcode.delaware.gov": "us-de", + "flsenate.gov": "us-fl", + "leg.state.fl.us": "us-fl", + "alisondb.legislature.state.al.us": "us-al", + "arkleg.state.ar.us": "us-ar", + "akleg.gov": "us-ak", + "code.dccouncil.gov": "us-dc", + "code.dccouncil.us": "us-dc", + "njleg.state.nj.us": "us-nj", + "lis.njleg.state.nj.us": "us-nj", + "pub.njleg.gov": "us-nj", + "legislature.ms.gov": "us-ms", +} +_STATE_HOSTS = {k: v for k, v in _STATE_HOSTS.items() if v} + + +def map_urls(urls: list[str]) -> list[Citation | StateHint]: + out: list[Citation | StateHint] = [] + seen: set = set() + for url in urls: + c = map_url(url) + if c is not None and c not in seen: + seen.add(c) + out.append(c) + return out diff --git a/axiom-parity/src/axiom_parity/finder/cli.py b/axiom-parity/src/axiom_parity/finder/cli.py new file mode 100644 index 0000000..bb78ba4 --- /dev/null +++ b/axiom-parity/src/axiom_parity/finder/cli.py @@ -0,0 +1,293 @@ +"""``axiom-parity suggest``: suggest the axiom line for a PR. + + axiom-parity suggest --repo PolicyEngine/policyengine-uk --pr 2237 --local-repo . \\ + --base origin/main --head HEAD + +It reads the reference URLs on the changed parameters and variables, maps +them to corpus citations, and looks up rulespec modules (from a shallow +sparse clone, or ``--rulespec-dir``) and open pe-parity issues. With +``--comment`` it keeps one comment on the PR up to date. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import subprocess +from pathlib import Path + +from ..diff import ApiDiff, LocalDiff +from ..github import GitHub, GitHubError, NotFound +from ..policy_paths import PolicyPaths, country_for_repo, package_for_repo +from ..rulespec import rulespec_repo_for_country +from .citations import Citation, StateHint, map_url +from .draft import draft_issue +from .index import Index, sparse_clone +from .propose import Proposal, collect_references, group_by_document, pe_test_cases, propose + +MARKER = "<!-- axiom-parity-finder -->" +# The account a workflow's GITHUB_TOKEN comments as. A caller that passes another +# token names that account with --comment-author. +COMMENT_AUTHOR = "github-actions[bot]" + + +def add_find_arguments(p: argparse.ArgumentParser) -> None: + p.add_argument("--repo", required=True, help="PolicyEngine/policyengine-us") + p.add_argument("--pr", type=int) + p.add_argument("--title", default="", help="PR title (default: read from the API)") + p.add_argument("--local-repo", help="local clone to diff instead of the API") + p.add_argument("--base", default="HEAD^1") + p.add_argument("--head", default="HEAD") + p.add_argument("--package") + p.add_argument( + "--rulespec-dir", help="directory holding rulespec-<country> clones (default: shallow clones in --cache)" + ) + p.add_argument("--cache", default=os.path.join(os.environ.get("RUNNER_TEMP", "/tmp"), "axiom-parity-cache")) + p.add_argument("--no-fetch", action="store_true", help="don't fetch law text for drafts") + p.add_argument("--json") + p.add_argument("--markdown", help="write the markdown here (default: stdout)") + p.add_argument("--summary", help="append the markdown to this file (e.g. $GITHUB_STEP_SUMMARY)") + p.add_argument("--comment", action="store_true", help="create or update the finder comment on the PR") + p.add_argument( + "--comment-author", + default=COMMENT_AUTHOR, + help="login the finder comment is written as; only that account's comment is ever updated", + ) + p.add_argument("--check-json", help="the check's JSON report; the comment says whether the check passes") + p.add_argument( + "--issues-file", + action="append", + default=[], + metavar="REPO=PATH", + help="read a rulespec repo's open pe-parity issues from saved `gh issue list --json number,title,body,comments` output", + ) + + +def _indexes(repos_juris: dict[str, set[str]], args: argparse.Namespace) -> tuple[dict[str, Index], dict[str, str]]: + indexes, refs = {}, {} + for repo, juris in repos_juris.items(): + name = repo.split("/", 1)[1] + root = Path(args.rulespec_dir) / name if args.rulespec_dir else Path(args.cache) / name + try: + if not args.rulespec_dir: + sparse_clone(repo, root, sorted(juris)) + sha = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--short=9", "HEAD"], capture_output=True, text=True + ).stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + continue + indexes[repo] = Index.build(repo, root, sorted(j for j in juris if (root / j).is_dir())) + refs[repo] = sha or "main" + return indexes, refs + + +def _open_issues(gh: GitHub, repo: str) -> list[tuple[dict, list[dict]]]: + """Open pe-parity issues with their comments (a citation is often named only in a comment).""" + try: + return gh.open_issues_with_comments(repo, "pe-parity") + except (NotFound, GitHubError, KeyError, TypeError): + pass + try: + issues = [ + i + for i in gh.paginate(f"/repos/{repo}/issues", {"labels": "pe-parity", "state": "open"}) + if "pull_request" not in i + ] + except (NotFound, GitHubError): + return [] + out = [] + for i in issues: + try: + comments = gh.issue_comments(repo, i["number"]) if i.get("comments") else [] + except (NotFound, GitHubError): + comments = [] + out.append((i, comments)) + return out + + +def _saved_issues(path: str, repo: str) -> list[tuple[dict, list[dict]]]: + rows = json.loads(Path(path).read_text(encoding="utf-8")) + out = [] + for i in rows: + i.setdefault("html_url", f"https://github.com/{repo}/issues/{i['number']}") + out.append((i, i.get("comments") if isinstance(i.get("comments"), list) else [])) + return out + + +def run_find(args: argparse.Namespace) -> int: + check = ( + json.loads(Path(args.check_json).read_text()) if args.check_json and Path(args.check_json).exists() else None + ) + if check is not None and not check.get("applies"): + return 0 # no policy files changed: nothing to suggest + gh = GitHub() + package = args.package or package_for_repo(args.repo) + paths = PolicyPaths(package) + diff = LocalDiff(args.local_repo, args.base, args.head) if args.local_repo else ApiDiff(gh, args.repo, args.pr) + changed = diff.changed_files() + policy = paths.policy_files(changed) + tests = [f for f in changed if f.startswith(f"{package}/tests/") and f.endswith((".yaml", ".yml"))] + title = args.title + if not title and args.pr: + try: + title = gh.pull(args.repo, args.pr).get("title", "") + except (NotFound, GitHubError): + title = "" + + refs = collect_references(policy, diff.read, package) + default_rs = rulespec_repo_for_country(country_for_repo(args.repo)) + juris: dict[str, set[str]] = {} + for r in refs: + c = map_url(r.url) + if isinstance(c, Citation): + j = c.path.split("/", 1)[0] + elif isinstance(c, StateHint): + j = c.jurisdiction + else: + continue + juris.setdefault(f"TheAxiomFoundation/rulespec-{j.split('-', 1)[0]}", set()).add(j) + indexes, shas = _indexes(juris, args) + saved = dict(item.split("=", 1) for item in args.issues_file) + open_issues = { + repo: _saved_issues(saved[repo], repo) if repo in saved else _open_issues(gh, repo) + for repo in juris or {default_rs: set()} + } + pe_ref = f"{args.repo.split('/', 1)[1]}#{args.pr}" if args.pr else None + proposals, unmapped = propose(refs, indexes, open_issues, pe_pr=pe_ref) + + pe_cases = [c for f in tests for c in pe_test_cases(f, diff.read(f, "head"))] + searched = ", ".join(f"{k}@{v}" for k, v in shas.items()) or "main" + drafts = { + doc: draft_issue(group, args.repo, args.pr, title, pe_cases, searched, fetch=not args.no_fetch) + for doc, group in group_by_document(proposals).items() + } + + md = render(proposals, unmapped, policy, check, drafts) + if args.json: + Path(args.json).write_text(json.dumps(to_json(proposals, unmapped, drafts), indent=2), encoding="utf-8") + if args.markdown: + Path(args.markdown).write_text(md, encoding="utf-8") + if args.summary: + with open(args.summary, "a", encoding="utf-8") as fh: + fh.write(md) + if not args.markdown and not args.summary: + print(md) + if args.comment and args.pr and policy: + upsert_comment(gh, args.repo, args.pr, md, check, args.comment_author) + return 0 + + +def render( + proposals: list[Proposal], unmapped, policy: list[str], check: dict | None, drafts: dict[str, str] | None = None +) -> str: + out = [MARKER, "## Axiom parity finder", ""] + if check is not None: + state = "passes" if not check.get("would_fail_enforced") else "does not pass yet" + out += [f"The axiom-line check **{state}**. Suggestions for this PR's changes follow.", ""] + if not policy: + return "\n".join(out + ["This PR changes no parameters, variables or reforms.", ""]) + if not proposals and not unmapped: + out.append( + "The changed files carry no reference URLs, so there is nothing to look up. Name the provision in the axiom line yourself." + ) + return "\n".join(out) + "\n" + if proposals: + out += [ + "Suggested axiom line (edit before use; an encoded-correct case must exercise *this* change):", + "", + "```text", + ] + out += list(dict.fromkeys(f"axiom: {p.line}" for p in proposals))[:12] + out += [ + "```", + "", + "| Corpus citation | Referenced by | Axiom modules on main | Open pe-parity issues |", + "|---|---|---|---|", + ] + for p in proposals: + by = ", ".join(sorted({f"`{r.name}`" for r in p.references})[:3]) + mods = ( + "<br>".join( + f"`{m.module.legal_id}` ({m.relation}){': cases ' + ', '.join(f'`{c}`' for c in p.cases.get(m.module.path, [])[:3]) if p.cases.get(m.module.path) else ''}" + for m in p.modules[:3] + ) + or "none" + ) + iss = ( + "<br>".join( + f"[{i['repo'].split('/')[1]}#{i['number']}]({i['url']}) {'(extend) ' if i.get('extend') else ''}{'dispatch-ready' if i['ready'] else 'missing ' + ', '.join(i['missing'])}" + for i in p.issues[:3] + ) + or "none" + ) + label = p.citation + ("" if p.exact else " (matched by section number)") + out.append(f"| `{label}` | {by} | {mods} | {iss} |") + out.append("") + for doc, body in (drafts or {}).items(): + out += [ + f"<details><summary>Draft pe-parity issue for <code>{doc}</code> (finish the TODOs, then file it in " + "the rulespec repo with the <code>pe-parity</code> label)</summary>", + "", + body, + "</details>", + "", + ] + if unmapped: + urls = sorted({r.url for r in unmapped}) + out += [ + f"References with no corpus mapping ({len(urls)}): " + + ", ".join(urls[:10]) + + (" …" if len(urls) > 10 else ""), + "", + ] + out.append( + f"_Generated {dt.date.today().isoformat()} by `axiom-parity suggest`; module matches are by corpus citation and path, so check each one._" + ) + return "\n".join(out) + "\n" + + +def to_json(proposals: list[Proposal], unmapped, drafts: dict[str, str] | None = None) -> dict: + return { + "proposals": [ + { + "citation": p.citation, + "exact": p.exact, + "line": p.line, + "references": [r.__dict__ for r in p.references], + "modules": [ + {"path": m.module.path, "legal_id": m.module.legal_id, "relation": m.relation, "via": m.via} + for m in p.modules + ], + "cases": p.cases, + "issues": p.issues, + } + for p in proposals + ], + "unmapped": [r.__dict__ for r in unmapped], + "drafts": drafts or {}, + } + + +def upsert_comment(gh: GitHub, repo: str, pr: int, body: str, check: dict | None, author: str = COMMENT_AUTHOR) -> None: + """Keep one finder comment: create it while the check fails, update it afterwards.""" + try: + comments = gh.issue_comments(repo, pr) + except (NotFound, GitHubError): + return + # Ours is a comment this workflow's own account wrote that opens with the marker. + # Anyone else who quotes the marker, a person or another bot, keeps their comment. + mine = [ + c for c in comments if (c.get("body") or "").startswith(MARKER) and (c.get("user") or {}).get("login") == author + ] + failing = check is None or check.get("would_fail_enforced") + try: + if mine: + if mine[0].get("body") != body: + gh.send_json("PATCH", f"/repos/{repo}/issues/comments/{mine[0]['id']}", {"body": body}) + elif failing: + gh.send_json("POST", f"/repos/{repo}/issues/{pr}/comments", {"body": body}) + except GitHubError as err: + # Fork PRs get a read-only token; the job summary still has the text. + print(f"::notice title=Axiom parity finder::could not write the PR comment ({err})") diff --git a/axiom-parity/src/axiom_parity/finder/draft.py b/axiom-parity/src/axiom_parity/finder/draft.py new file mode 100644 index 0000000..7d4f35c --- /dev/null +++ b/axiom-parity/src/axiom_parity/finder/draft.py @@ -0,0 +1,174 @@ +"""Draft a dispatch-ready pe-parity issue for a citation nothing covers yet. + +The draft uses the section headings the dispatch-ready lint looks for. What +the finder can't know (the blocker, final output names) is left as a TODO, +so an unfinished draft fails the lint instead of passing as ready. +""" + +from __future__ import annotations + +import html +import re +import urllib.request +import xml.etree.ElementTree as ET +from typing import Any + +from .propose import Proposal, TestCase, document_of + +TODO = "TODO" +OGL = "Contains public sector information licensed under the Open Government Licence v3.0." + + +def module_path_for(citation: str) -> str: + """``us/statute/26/32/d`` -> ``us/statutes/26/32/d.yaml``; CFR gets ``<title>-cfr``.""" + juris, kind, *rest = citation.split("/") + plural = {"statute": "statutes", "regulation": "regulations", "guidance": "policies", "policy": "policies"}.get( + kind, kind + ) + if juris == "us" and kind == "regulation" and rest and rest[0].isdigit(): + rest = [f"{rest[0]}-cfr", *rest[1:]] + return f"{juris}/{plural}/{'/'.join(rest)}.yaml" + + +def _get(url: str, timeout: int = 20) -> bytes | None: + try: + req = urllib.request.Request(url, headers={"User-Agent": "policyengine-axiom-parity"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + except Exception: # noqa: BLE001 - a draft without law text is still useful + return None + + +def fetch_law(url: str) -> tuple[str, str] | None: + """(text, provenance) for legislation.gov.uk and eCFR sources; None otherwise.""" + if "legislation.gov.uk" in url: + base = re.sub(r"/(?:data\.(?:xml|htm|html)|contents)?$", "", url.split("#")[0].rstrip("/")) + raw = _get(base + "/data.xml") + if not raw: + return None + try: + root = ET.fromstring(raw) + except ET.ParseError: + return None + ns = "{http://www.legislation.gov.uk/namespaces/legislation}" + body = next((el for el in (root.find(f".//{ns}Body"), root.find(f".//{ns}Schedules")) if el is not None), root) + parts = [] + for el in body.iter(): + if el.tag in (f"{ns}Text", f"{ns}Title", f"{ns}Pnumber"): + t = " ".join("".join(el.itertext()).split()) + if t: + parts.append(t) + text = " ".join(parts) + return (text, f"legislation.gov.uk `{base}/data.xml`. {OGL}") if text else None + m = re.search(r"ecfr\.gov/current/title-(\d+)/(?:.*/)?section-(\d+)\.([0-9A-Za-z-]+)", url) + if m: + title, part, sec = m.groups() + api = f"https://www.ecfr.gov/api/renderer/v1/content/enforce/current/title-{title}?part={part}§ion={part}.{sec}" + raw = _get(api) + if not raw: + return None + text = html.unescape(re.sub(r"<[^>]+>", " ", raw.decode("utf-8", errors="replace"))) + text = " ".join(text.split()) + return (text, f"eCFR renderer API `{api}`") if text else None + return None + + +def _quote(text: str, limit: int = 6000) -> str: + text = text if len(text) <= limit else text[:limit] + " […]" + return "\n".join(f"> {line}" for line in re.findall(r".{1,110}(?:\s|$)", text)) + + +def draft_issue( + group: list[Proposal], + pe_repo: str, + pe_pr: int | None, + pe_title: str, + pe_tests: list[TestCase], + searched_ref: str, + fetch: bool = True, +) -> str: + """One issue per source document, covering every uncovered citation in it.""" + cites = [p.citation for p in group] + document = document_of(cites[0]) + modules = [module_path_for(c) for c in cites] + refs = [r for p in group for r in p.references] + urls = sorted({r.url for r in refs}) + names = sorted({f"`{r.name}` ({r.kind})" for r in refs}) + pr = f"{pe_repo}#{pe_pr}" if pe_pr else pe_repo + + law_parts = [] + for p in group: + law = None + if fetch: + for u in sorted({r.url for r in p.references}): + law = fetch_law(u) + if law: + break + if law: + law_parts.append( + f"**`{p.citation}`**, fetched from {law[1]} Check it against the pinned corpus row before dispatch.\n\n{_quote(law[0])}" + ) + else: + law_parts.append( + f"**`{p.citation}`**: {TODO}: quote the operative text from the corpus or the official source." + ) + law_md = "\n\n".join(law_parts) + + outputs = "\n".join(f"{i}. {n}: {TODO} name the output in the law's terms." for i, n in enumerate(names, 1)) + tests = ( + "\n".join( + f"- `{t.name}` (from `{t.file}`): inputs {', '.join(f'{k}={v}' for k, v in list(_flat(t.input).items())[:6])}; " + f"expected {', '.join(f'{k}={v}' for k, v in list(_flat(t.output).items())[:4])}. Expected values come from " + f"the same external source as the PolicyEngine test." + for t in pe_tests[:8] + ) + or f"- {TODO}: companion cases with expected values from the statute, official form or official calculator." + ) + module_lines = "\n".join(f"- `{m}` for corpus citation `{c}`" for m, c in zip(modules, cites, strict=True)) + finding = ( + f"Encode {', '.join(cites)} from {document} as {', '.join(f'`{m}`' for m in modules)}. {pr} ({pe_title}) " + f"changes {', '.join(names) or 'these provisions'}. State each required output below from the provision's own " + f"terms, each with a verbatim proof excerpt from the corpus provision, and pass the companion cases below." + ) + return f"""## Encoding debt + +PolicyEngine parity for {pr}: {pe_title}. The PR changes {", ".join(names) or "provisions"} citing {", ".join(urls)}. + +The parity finder searched TheAxiomFoundation rulespec `main` at `{searched_ref}`: no module cites {", ".join(f"`{c}`" for c in cites)}, and no open `pe-parity` issue does either. They all sit in `{document}`; encode the document's provisions together. + +## Module and corpus + +{module_lines} + +## Law (verbatim) + +{law_md} + +## Required outputs + +{outputs or f"1. {TODO}"} + +## review_finding (paste as-is) + +> {finding} + +## Companion tests + +{tests} + +## Blocker + +{TODO}: say why the signed encoder can't run now (corpus pin, encoder pin, waiver), or dispatch it. Check that every citation above is in the corpus release rulespec pins. +""" + + +def _flat(d: Any, prefix: str = "") -> dict[str, Any]: + out: dict[str, Any] = {} + if isinstance(d, dict): + for k, v in d.items(): + key = f"{prefix}.{k}" if prefix else str(k) + if isinstance(v, dict): + out.update(_flat(v, key)) + else: + out[key] = v + return out diff --git a/axiom-parity/src/axiom_parity/finder/index.py b/axiom-parity/src/axiom_parity/finder/index.py new file mode 100644 index 0000000..3292409 --- /dev/null +++ b/axiom-parity/src/axiom_parity/finder/index.py @@ -0,0 +1,170 @@ +"""An index of RuleSpec modules by corpus citation path. + +Built from a clone of a rulespec repo. Module files are scanned with regular +expressions rather than parsed as YAML, which keeps a full rulespec-us scan +to a couple of seconds. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +from .. import pe_yaml +from ..refs import norm_dashes, parse_legal_refs +from ..rulespec import candidate_paths, legal_id_for_path, test_cases + + +def _module_paths_for(citation: str) -> list[tuple[str, str]]: + refs = parse_legal_refs(citation) + return candidate_paths(refs[0]) if refs else [] + + +_CITE = re.compile(r"corpus_citation_path:\s*['\"]?([^\s'\",\]]+)") +_RULE = re.compile(r"^ - name:\s*['\"]?([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE) +MODULE_ROOTS = ("statutes", "regulations", "policies", "legislation") + + +@dataclass +class Module: + repo: str + path: str + citations: set[str] = field(default_factory=set) + rules: list[str] = field(default_factory=list) + + @property + def legal_id(self) -> str: + return legal_id_for_path(self.path) + + @property + def test_path(self) -> str: + return self.path[: -len(".yaml")] + ".test.yaml" + + +@dataclass +class Match: + module: Module + citation: str # the corpus path asked for + via: str # the module citation that matched + relation: str # "exact", "module-covers" (module cites an ancestor) or "part" (module cites a descendant) + + @property + def score(self) -> int: + base = {"path": 4, "exact": 3, "module-covers": 2, "part": 1}[self.relation] + # Composed pipelines cite many provisions; the atomic module is the better answer. + return base * 2 - (1 if self.module.path.endswith("_pipeline.yaml") else 0) + + +class Index: + def __init__(self, repo: str, root: str | Path) -> None: + self.repo = repo + self.root = Path(root) + self.modules: list[Module] = [] + self.by_citation: dict[str, list[Module]] = {} + + @classmethod + def build(cls, repo: str, root: str | Path, jurisdictions: list[str] | None = None) -> Index: + idx = cls(repo, root) + base = Path(root) + dirs = [base / j for j in jurisdictions] if jurisdictions else [p for p in base.iterdir() if p.is_dir()] + for d in dirs: + for kind in MODULE_ROOTS: + top = d / kind + if not top.is_dir(): + continue + for f in top.rglob("*.yaml"): + if f.name.endswith(".test.yaml") or f.name.endswith(".meta.yaml"): + continue + text = f.read_text(encoding="utf-8", errors="replace") + rel = f.relative_to(base).as_posix() + # Citations are indexed with hyphens for any dash, so a URL's "1437c-1" finds "1437c–1". + mod = Module(repo, rel, {norm_dashes(c) for c in _CITE.findall(text)}, _RULE.findall(text)) + idx.modules.append(mod) + for c in mod.citations: + idx.by_citation.setdefault(c, []).append(mod) + return idx + + def match(self, citation: str, limit: int = 5) -> list[Match]: + """Modules for a corpus path, best first. + + A module whose own path is the citation's (``us/statutes/26/24/d.yaml`` + for ``us/statute/26/24/d``) ranks first, then modules citing exactly + that path, then modules citing an ancestor, then a descendant. + """ + citation = norm_dashes(citation) + out: list[Match] = [] + seen: set[str] = set() + by_path = self._by_path() + for path, kind in _module_paths_for(citation): + m = by_path.get(path) + if m and kind == "exact" and m.path not in seen: + out.append(Match(m, citation, path, "path")) + seen.add(m.path) + for m in self.by_citation.get(citation, []): + if m.path not in seen: + out.append(Match(m, citation, citation, "exact")) + seen.add(m.path) + parts = citation.split("/") + for i in range(len(parts) - 1, 2, -1): + anc = "/".join(parts[:i]) + for m in self.by_citation.get(anc, []): + if m.path not in seen: + out.append(Match(m, citation, anc, "module-covers")) + seen.add(m.path) + prefix = citation + "/" + for c, mods in self.by_citation.items(): + if c.startswith(prefix): + for m in mods: + if m.path not in seen: + out.append(Match(m, citation, c, "part")) + seen.add(m.path) + out.sort(key=lambda x: (-x.score, len(x.module.path))) + return out[:limit] + + def _by_path(self) -> dict[str, Module]: + if not hasattr(self, "_paths"): + self._paths = {norm_dashes(m.path): m for m in self.modules} + return self._paths + + def match_section(self, jurisdiction: str, section: str, limit: int = 5) -> list[Match]: + """Heuristic match of a state section number against that state's citations.""" + want = section.lower().strip(".") + out = [] + for c, mods in self.by_citation.items(): + if not c.startswith(jurisdiction + "/"): + continue + segs = [s.lower().rstrip(",") for s in c.split("/")[2:]] + if want in segs or any(s.endswith("-" + want) for s in segs): + for m in mods: + out.append(Match(m, f"{jurisdiction}:{section}", c, "exact")) + out.sort(key=lambda x: len(x.via)) + return out[:limit] + + def test_cases_for(self, module: Module) -> list[dict]: + f = self.root / module.test_path + if not f.is_file(): + return [] + try: + return test_cases(pe_yaml.load(f.read_text(encoding="utf-8", errors="replace"))) + except pe_yaml.YAMLError: + return [] + + +def sparse_clone(repo: str, dest: str | Path, jurisdictions: list[str]) -> Path: + """Shallow, blobless clone of ``repo`` with only ``jurisdictions`` checked out.""" + dest = Path(dest) + url = f"https://github.com/{repo}.git" + if not (dest / ".git").exists(): + subprocess.run( + ["git", "clone", "--depth", "1", "--filter=blob:none", "--no-checkout", "--quiet", url, str(dest)], + check=True, + ) + patterns = [] + for j in jurisdictions: + for kind in MODULE_ROOTS: + patterns.append(f"/{j}/{kind}/") + subprocess.run(["git", "-C", str(dest), "sparse-checkout", "set", "--no-cone", *patterns], check=True) + subprocess.run(["git", "-C", str(dest), "checkout", "--quiet"], check=True) + return dest diff --git a/axiom-parity/src/axiom_parity/finder/propose.py b/axiom-parity/src/axiom_parity/finder/propose.py new file mode 100644 index 0000000..a320083 --- /dev/null +++ b/axiom-parity/src/axiom_parity/finder/propose.py @@ -0,0 +1,253 @@ +"""Turn a PolicyEngine change into axiom-line proposals. + +For each corpus citation the changed files reference, the finder proposes: + +- ``encoded-correct`` candidates: modules on rulespec main that cover the + citation, with the companion cases that output each module's rules. The + author still has to pick the case that exercises *this* change; +- ``queued`` candidates: open pe-parity issues that already cite the + citation, with their dispatch-readiness; +- otherwise a drafted dispatch-ready issue body (see ``draft.py``). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from .. import pe_yaml +from ..issue_lint import expand_braces, lint_issue +from ..refs import norm_dashes +from .citations import Citation, StateHint, map_url +from .index import Index, Match +from .references import Reference, references_for_file + + +@dataclass +class TestCase: + file: str + name: str + period: Any + input: dict[str, Any] + output: dict[str, Any] + + +@dataclass +class Proposal: + citation: str + exact: bool + references: list[Reference] + modules: list[Match] = field(default_factory=list) + cases: dict[str, list[str]] = field(default_factory=dict) # module path -> case names that output it + issues: list[dict[str, Any]] = field(default_factory=list) # {number, url, title, ready, missing} + + @property + def line(self) -> str: + """The suggested axiom claim for this citation.""" + if self.modules: + m = self.modules[0].module + case = (self.cases.get(m.path) or ["<case that exercises this change>"])[0] + return f"{m.legal_id} encoded-correct (`{m.test_path.rsplit('/', 1)[-1]}::{case}`)" + if self.issues: + i = self.issues[0] + if i.get("extend"): + return f"{i['repo']}#{i['number']} queued (extend it to cover {self.citation})" + return f"{i['repo']}#{i['number']} queued" + return f"<the pe-parity issue drafted below for {document_of(self.citation)}> queued" + + +def document_of(citation: str) -> str: + """The source document a citation belongs to: the unit to encode as a whole. + + ``uk/statute/ukpga/2004/12/227`` -> ``uk/statute/ukpga/2004/12`` (the Act); + ``us/statute/26/32/i/2`` -> ``us/statute/26/32`` (the section); + ``us/regulation/7/273/9`` -> ``us/regulation/7/273`` (the CFR part); + ``us-ca/statute/rtc/17041`` -> ``us-ca/statute/rtc`` (the code). + """ + segs = citation.split("/") + if len(segs) < 3: + return citation + juris, kind = segs[0], segs[1] + if juris.startswith("uk") and kind in ("statute", "regulation"): + n = 5 + elif kind in ("statute", "regulation") and juris == "us": + n = 4 + elif kind == "guidance": + n = 4 + else: + n = 3 + return "/".join(segs[:n]) + + +def group_by_document(proposals: list[Proposal]) -> dict[str, list[Proposal]]: + """Uncovered citations (no module, no open issue), grouped by source document.""" + groups: dict[str, list[Proposal]] = {} + for p in proposals: + if not p.modules and not p.issues and "/" in p.citation: + groups.setdefault(document_of(p.citation), []).append(p) + return groups + + +def pe_test_cases(path: str, text: str | None) -> list[TestCase]: + """Cases from a changed PolicyEngine YAML test file.""" + if text is None: + return [] + try: + doc = pe_yaml.load(text) + except pe_yaml.YAMLError: + return [] + out = [] + for case in doc if isinstance(doc, list) else []: + if isinstance(case, dict) and "name" in case: + out.append( + TestCase(path, str(case["name"]), case.get("period"), case.get("input") or {}, case.get("output") or {}) + ) + return out + + +def collect_references(files: list[str], read: Any, package: str) -> list[Reference]: + refs: list[Reference] = [] + for f in files: + refs.extend(references_for_file(f, read(f, "head"), package)) + return refs + + +_CORPUS_PATH = re.compile( + r"(?<![\w/-])[a-z]{2}(?:-[a-z0-9]+)*/(?:statute|regulation|guidance|policy|manual|form)/[\w./:\-]*\w" +) + + +def _issue_text(issue: dict[str, Any], comments: list[dict[str, Any]]) -> str: + """Body and comments as one text to search for citations: any dash is a hyphen, braces are expanded.""" + text = (issue.get("body") or "") + "\n" + "\n".join(c.get("body") or "" for c in comments) + return expand_braces(norm_dashes(text)) + + +def _issue_mentions(issue: dict[str, Any], comments: list[dict[str, Any]], needles: list[str]) -> bool: + text = _issue_text(issue, comments) + needles = [norm_dashes(n) for n in needles] + # A needle is a whole path ("us/statute/26/3" doesn't match "us/statute/26/32"). The + # first needle, the citation itself, also matches an issue about a part of it. + if needles and re.search(rf"(?<![\w/-]){re.escape(needles[0])}/[\w.:-]", text): + return True + return any(re.search(rf"(?<![\w/-]){re.escape(n)}(?![\w/-])", text) for n in needles) + + +def propose( + references: list[Reference], + indexes: dict[str, Index], + open_issues: dict[str, list[tuple[dict[str, Any], list[dict[str, Any]]]]], + pe_pr: str | None = None, +) -> tuple[list[Proposal], list[Reference]]: + """Group references by citation and look each up. Returns (proposals, unmapped references).""" + by_cite: dict[tuple[str, bool], list[Reference]] = {} + hints: dict[str, tuple[StateHint, list[Reference]]] = {} + unmapped: list[Reference] = [] + for ref in references: + c = map_url(ref.url) + if isinstance(c, Citation): + by_cite.setdefault((c.path, c.exact), []).append(ref) + elif isinstance(c, StateHint): + key = f"{c.jurisdiction}:{c.section}" + hints.setdefault(key, (c, []))[1].append(ref) + else: + unmapped.append(ref) + + proposals: list[Proposal] = [] + for (path, exact), refs in sorted(by_cite.items()): + repo = _repo_for(path) + p = Proposal(path, exact, refs) + idx = indexes.get(repo) + if idx: + p.modules = idx.match(path) + for m in p.modules[:3]: + p.cases[m.module.path] = _cases_outputting(idx, m) + p.issues = _matching_issues(open_issues.get(repo, []), _ancestors(path), repo, strict=False) + proposals.append(p) + for key, (hint, refs) in sorted(hints.items()): + repo = _repo_for(hint.jurisdiction + "/") + p = Proposal(key, False, refs) + idx = indexes.get(repo) + if idx: + p.modules = idx.match_section(hint.jurisdiction, hint.section) + for m in p.modules[:3]: + p.cases[m.module.path] = _cases_outputting(idx, m) + p.issues = _matching_issues( + open_issues.get(repo, []), [f"{hint.jurisdiction}/statute/{hint.section}", hint.section], repo, strict=False + ) + proposals.append(p) + # A citation with no module and no issue of its own, in a document that + # already has an open issue, extends that issue rather than starting one. + # The index comes from every open issue's own citations, not from which + # other references this PR happens to touch. + by_doc: dict[tuple[str, str], list[tuple[dict[str, Any], list[dict[str, Any]]]]] = {} + for repo, issues in open_issues.items(): + for issue, comments in issues: + text = _issue_text(issue, comments) + for doc in {document_of(m.group(0).rstrip(".,;:)")) for m in _CORPUS_PATH.finditer(text)}: + by_doc.setdefault((repo, doc), []).append((issue, comments)) + for p in proposals: + if not p.modules and not p.issues and "/" in p.citation: + siblings = by_doc.get((_repo_for(p.citation), document_of(norm_dashes(p.citation))), []) + if siblings: + rows = sorted( + (_issue_row(i, c, _repo_for(p.citation)) for i, c in siblings), + key=lambda r: (not r["ready"], r["number"]), + ) + p.issues = [{**rows[0], "extend": True}] + if pe_pr: + # An open issue that already names this PE PR is the best queued candidate. + for repo, issues in open_issues.items(): + for issue, comments in issues: + if _issue_mentions(issue, comments, [pe_pr]): + for p in proposals: + if not any(i["number"] == issue["number"] for i in p.issues) and not p.modules: + p.issues.insert(0, _issue_row(issue, comments, repo)) + return proposals, unmapped + + +def _repo_for(path: str) -> str: + country = path.split("/", 1)[0].split("-", 1)[0] + return f"TheAxiomFoundation/rulespec-{country}" + + +def _cases_outputting(idx: Index, match: Match) -> list[str]: + mid = match.module.legal_id + names = [] + for case in idx.test_cases_for(match.module): + outputs = case.get("output") or {} + if isinstance(outputs, dict) and any(str(k).startswith(mid + "#") for k in outputs): + names.append(str(case.get("name"))) + return names + + +def _issue_row(issue: dict[str, Any], comments: list[dict[str, Any]], repo: str) -> dict[str, Any]: + res = lint_issue(issue, comments) + return { + "repo": repo, + "number": issue["number"], + "title": issue.get("title", ""), + "url": issue.get("html_url", ""), + "ready": res.ready, + "missing": res.missing, + } + + +def _ancestors(citation: str) -> list[str]: + """The citation and its ancestors down to its source document, most specific first.""" + doc_len = len(document_of(citation).split("/")) + segs = citation.split("/") + # Stop above the document itself: an issue about another provision of the + # same Act is a sibling (extend it), not a match. + out = ["/".join(segs[:i]) for i in range(len(segs), doc_len, -1)] + return out or [citation] + + +def _matching_issues(issues, needles: list[str], repo: str, strict: bool = True) -> list[dict[str, Any]]: + rows = [] + for issue, comments in issues: + if _issue_mentions(issue, comments, needles[:1] if strict else needles): + rows.append(_issue_row(issue, comments, repo)) + rows.sort(key=lambda r: (not r["ready"], r["number"])) + return rows[:5] diff --git a/axiom-parity/src/axiom_parity/finder/references.py b/axiom-parity/src/axiom_parity/finder/references.py new file mode 100644 index 0000000..134a548 --- /dev/null +++ b/axiom-parity/src/axiom_parity/finder/references.py @@ -0,0 +1,130 @@ +"""Extract the legal references on PolicyEngine parameters and variables. + +Parameters carry them in ``metadata.reference`` (a list of ``{title, href}`` +mappings, bare URLs, or one mapping). Variables carry a class attribute +``reference`` (a string, or a list or tuple of strings or mappings). +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass +from typing import Any + +from .. import pe_yaml + + +@dataclass(frozen=True) +class Reference: + file: str + name: str # parameter path or variable name + kind: str # "parameter" or "variable" + url: str + title: str = "" + + +def _flatten(ref: Any) -> list[tuple[str, str]]: + """(url, title) pairs from a reference value of any shape.""" + out: list[tuple[str, str]] = [] + if isinstance(ref, str): + for part in ref.split(): + if part.startswith(("http://", "https://")): + out.append((part.strip(",;"), "")) + elif isinstance(ref, dict): + href = ref.get("href") or ref.get("url") + if isinstance(href, str): + out.append((href.strip(), str(ref.get("title") or ""))) + elif isinstance(ref, (list, tuple)): + for item in ref: + out.extend(_flatten(item)) + return out + + +def parameter_name(path: str, package: str) -> str: + """``policyengine_uk/parameters/gov/hmrc/x.yaml`` -> ``gov.hmrc.x``.""" + stem = path.split(f"{package}/parameters/", 1)[-1].rsplit(".", 1)[0] + return stem.replace("/", ".") + + +def parameter_references(path: str, text: str, package: str) -> list[Reference]: + try: + doc = pe_yaml.load(text) + except pe_yaml.YAMLError: + return [] + name = parameter_name(path, package) + out: list[Reference] = [] + + def walk(node: Any, prefix: str) -> None: + if not isinstance(node, dict): + return + meta = node.get("metadata") + if isinstance(meta, dict) and "reference" in meta: + for url, title in _flatten(meta["reference"]): + out.append(Reference(path, prefix, "parameter", url, title)) + if "reference" in node and not isinstance(node.get("reference"), dict | None): + for url, title in _flatten(node["reference"]): + out.append(Reference(path, prefix, "parameter", url, title)) + # A dated value can carry its own reference: {value: x, metadata: {reference: ...}}. + values = node.get("values") + if isinstance(values, dict): + for entry in values.values(): + if isinstance(entry, dict): + entry_meta = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {} + for ref in (entry_meta.get("reference"), entry.get("reference")): + for url, title in _flatten(ref): + out.append(Reference(path, prefix, "parameter", url, title)) + for k, v in node.items(): + if k in ("metadata", "values", "reference"): + continue + if isinstance(v, dict): + walk(v, f"{prefix}.{k}") + elif isinstance(v, list): + for i, item in enumerate(v): + walk(item, f"{prefix}.{k}[{i}]") + + walk(doc, name) + return out + + +def _literal(node: ast.AST) -> Any: + """Evaluate literals, plus the ``dict(title=..., href=...)`` calls variables use.""" + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.List | ast.Tuple): + return [_literal(e) for e in node.elts] + if isinstance(node, ast.Dict): + return {_literal(k): _literal(v) for k, v in zip(node.keys, node.values, strict=False) if k is not None} + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "dict" and not node.args: + return {kw.arg: _literal(kw.value) for kw in node.keywords if kw.arg} + return None + + +def variable_references(path: str, text: str) -> list[Reference]: + try: + tree = ast.parse(text) + except SyntaxError: + return [] + out: list[Reference] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + for stmt in node.body: + if isinstance(stmt, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "reference" for t in stmt.targets + ): + value = _literal(stmt.value) + if value is None: + continue + for url, title in _flatten(value): + out.append(Reference(path, node.name, "variable", url, title)) + return out + + +def references_for_file(path: str, text: str | None, package: str) -> list[Reference]: + if text is None: + return [] + if path.endswith((".yaml", ".yml")): + return parameter_references(path, text, package) + if path.endswith(".py"): + return variable_references(path, text) + return [] diff --git a/axiom-parity/src/axiom_parity/github.py b/axiom-parity/src/axiom_parity/github.py new file mode 100644 index 0000000..3d152b1 --- /dev/null +++ b/axiom-parity/src/axiom_parity/github.py @@ -0,0 +1,210 @@ +"""A small GitHub REST client: just the reads the check needs. + +It uses only the standard library so the check installs in seconds on a +runner. ``transport`` is injectable so tests run without network access. +""" + +from __future__ import annotations + +import http.client +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from typing import Any + +Transport = Callable[[str, dict[str, str]], tuple[int, dict[str, str], bytes]] + + +class NotFound(Exception): + """The resource does not exist, or the token cannot see it.""" + + +class GitHubError(Exception): + pass + + +def _urllib_transport(url: str, headers: dict[str, str]) -> tuple[int, dict[str, str], bytes]: + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, dict(resp.headers), resp.read() + except urllib.error.HTTPError as err: + return err.code, dict(err.headers or {}), err.read() or b"" + except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError) as err: + # A dropped connection is retried like a 503. + return 599, {}, str(err).encode() + + +class GitHub: + def __init__( + self, + token: str | None = None, + api: str = "https://api.github.com", + transport: Transport | None = None, + retries: int = 3, + ) -> None: + self.token = token if token is not None else (os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")) + self.api = api.rstrip("/") + self.transport = transport or _urllib_transport + self.retries = retries + self.calls = 0 + self._cache: dict[str, Any] = {} + + def _headers(self, accept: str) -> dict[str, str]: + headers = { + "Accept": accept, + "User-Agent": "policyengine-axiom-parity", + "X-GitHub-Api-Version": "2022-11-28", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def _request(self, path: str, params: dict[str, Any] | None, accept: str) -> tuple[dict[str, str], bytes]: + url = path if path.startswith("http") else f"{self.api}{path}" + if params: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params) + key = f"{accept} {url}" + if key in self._cache: + return self._cache[key] + for attempt in range(self.retries + 1): + self.calls += 1 + status, headers, body = self.transport(url, self._headers(accept)) + if status == 404 or status == 410: + raise NotFound(url) + if status in (502, 503, 504, 599) or (status == 403 and b"secondary rate limit" in body.lower()): + if attempt < self.retries: + time.sleep(2**attempt) + continue + if status >= 400: + raise GitHubError(f"GET {url} -> {status}: {body[:300]!r}") + self._cache[key] = (headers, body) + return headers, body + raise GitHubError(f"GET {url} failed after retries") + + def get_json(self, path: str, params: dict[str, Any] | None = None) -> Any: + _, body = self._request(path, params, "application/vnd.github+json") + return json.loads(body or b"null") + + def get_raw(self, owner_repo: str, path: str, ref: str = "main") -> str: + """Return a file's text at ``ref``; raise NotFound if it doesn't exist.""" + quoted = urllib.parse.quote(path) + _, body = self._request(f"/repos/{owner_repo}/contents/{quoted}", {"ref": ref}, "application/vnd.github.raw") + return body.decode("utf-8", errors="replace") + + def paginate(self, path: str, params: dict[str, Any] | None = None, limit: int = 3000) -> list[Any]: + out: list[Any] = [] + page = 1 + params = dict(params or {}) + params.setdefault("per_page", 100) + while len(out) < limit: + params["page"] = page + batch = self.get_json(path, params) + if not batch: + break + out.extend(batch) + if len(batch) < params["per_page"]: + break + page += 1 + return out[:limit] + + def send_json(self, method: str, path: str, data: dict[str, Any]) -> Any: + """POST or PATCH (comments only; the check itself never writes).""" + url = path if path.startswith("http") else f"{self.api}{path}" + req = urllib.request.Request( + url, + data=json.dumps(data).encode(), + method=method, + headers={**self._headers("application/vnd.github+json"), "Content-Type": "application/json"}, + ) + self.calls += 1 + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read() or b"null") + except urllib.error.HTTPError as err: + raise GitHubError(f"{method} {url} -> {err.code}: {err.read()[:300]!r}") from err + + _ISSUES_QUERY = """ + query($owner: String!, $name: String!, $label: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + issues(first: 50, after: $cursor, states: OPEN, labels: [$label], + orderBy: {field: CREATED_AT, direction: DESC}) { + pageInfo { hasNextPage endCursor } + nodes { + number title body url createdAt + comments(first: 100) { totalCount nodes { databaseId body } } + } + } + } + }""" + + def open_issues_with_comments( + self, owner_repo: str, label: str + ) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + """Open issues carrying ``label``, each with its comments, in one query per 50 issues. + + A queued issue's dispatch contract often lives in its comments, so + lint and search read them too. + """ + owner, name = owner_repo.split("/", 1) + out: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + cursor = None + for _ in range(40): + data = self.send_json( + "POST", + "/graphql", + { + "query": self._ISSUES_QUERY, + "variables": {"owner": owner, "name": name, "label": label, "cursor": cursor}, + }, + ) + if data.get("errors"): + raise GitHubError(f"GraphQL: {data['errors'][:1]}") + page = data["data"]["repository"]["issues"] + for node in page["nodes"]: + comments = [{"id": c["databaseId"], "body": c["body"]} for c in node["comments"]["nodes"]] + if node["comments"].get("totalCount", len(comments)) > len(comments): + # More than one page of comments: read them all over REST. + comments = [ + {"id": c["id"], "body": c.get("body") or ""} + for c in self.issue_comments(owner_repo, node["number"]) + ] + issue = { + "number": node["number"], + "title": node["title"], + "body": node["body"], + "html_url": node["url"], + "created_at": node["createdAt"], + "comments": len(comments), + } + out.append((issue, comments)) + if not page["pageInfo"]["hasNextPage"]: + break + cursor = page["pageInfo"]["endCursor"] + return out + + # Convenience reads ------------------------------------------------- + + def issue(self, owner_repo: str, number: int) -> dict[str, Any]: + """An issue or a PR (PRs carry a ``pull_request`` key).""" + return self.get_json(f"/repos/{owner_repo}/issues/{number}") + + def issue_comments(self, owner_repo: str, number: int) -> list[dict[str, Any]]: + return self.paginate(f"/repos/{owner_repo}/issues/{number}/comments") + + def pull(self, owner_repo: str, number: int) -> dict[str, Any]: + return self.get_json(f"/repos/{owner_repo}/pulls/{number}") + + def pull_files(self, owner_repo: str, number: int) -> list[dict[str, Any]]: + return self.paginate(f"/repos/{owner_repo}/pulls/{number}/files") + + def repo(self, owner_repo: str) -> dict[str, Any]: + return self.get_json(f"/repos/{owner_repo}") + + def tree(self, owner_repo: str, ref: str = "main") -> list[str]: + data = self.get_json(f"/repos/{owner_repo}/git/trees/{urllib.parse.quote(ref)}", {"recursive": "1"}) + return [e["path"] for e in data.get("tree", []) if e.get("type") == "blob"] diff --git a/axiom-parity/src/axiom_parity/issue_lint.py b/axiom-parity/src/axiom_parity/issue_lint.py new file mode 100644 index 0000000..56239f7 --- /dev/null +++ b/axiom-parity/src/axiom_parity/issue_lint.py @@ -0,0 +1,674 @@ +"""Lint a ``pe-parity`` issue for dispatch readiness. + +A queued issue must let the signed encoder run without further research +(PolicyEngine CONTRIBUTING, "Mirror policy changes in Axiom"). It is +dispatch-ready when the issue body and its comments together carry: + +- ``module_path``: the target RuleSpec module (``us/statutes/26/32/d.yaml`` + or its legal id ``us:statutes/26/32/d``); +- ``corpus_citation``: the corpus citation path (``us/statute/26/32/d``); +- ``verbatim_law``: the operative law, quoted; +- ``required_outputs``: the outputs the module must produce; +- ``review_finding``: a pasteable finding for the encoder's ``review_finding`` + input; +- ``companion_tests``: companion cases with externally sourced expectations. + +Each element is found either in a section whose heading names it, with real +content under the heading, or in an inline cue followed by that content. The +lint reports the evidence it used so a reviewer can check it. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +ELEMENTS = ( + "module_path", + "corpus_citation", + "verbatim_law", + "required_outputs", + "review_finding", + "companion_tests", +) + +HINTS = { + "module_path": "name the target module, e.g. `us/statutes/26/32/d.yaml` (or its legal id `us:statutes/26/32/d`)", + "corpus_citation": "give the corpus citation path the encoder takes as `citation`, e.g. `us/statute/26/32/d` (a release file such as `us/statute/2026-07-13-...-dedup.jsonl` is not a citation)", + "verbatim_law": "quote the operative law under a heading such as `## Law (verbatim)`", + "required_outputs": "list the outputs under `## Required outputs`", + "review_finding": "add a pasteable finding under `## review_finding (paste as-is)` as a quote or code block", + "companion_tests": "list companion cases with externally sourced expected values under `## Companion tests`", +} + +_JURIS = r"[a-z]{2}(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?" +_MODULE = re.compile( + rf"(?<![\w/-])(?:{_JURIS}/(?:statutes|regulations|policies|legislation)/[\w./:\-]+?(?<!\.test)\.yaml" + rf"|{_JURIS}:(?:statutes|regulations|policies|legislation)/[\w./:\-]+)" +) +_CORPUS = re.compile( + rf"(?<![\w/-])(?:{_JURIS})/(?:statute|regulation|guidance|policy|manual|form|notice|instruction)s?/[\w./:\-]*\w" +) +_CORPUS_SINGULAR = re.compile( + rf"(?<![\w/-])(?:{_JURIS})/(?:statute|regulation|guidance|policy|manual|form|notice|instruction)/[\w./:\-]*\w" +) +# A corpus *artifact* (a release's provisions file) is not a citation: +# us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl +_ARTIFACT = re.compile(r"^[a-z]{2}(?:-[a-z0-9]+)*/[a-z]+/\d{4}-\d{2}-\d{2}|\.jsonl?$|\.parquet$") + +_TITLES = { + "verbatim_law": re.compile( + r"verbatim|^\W*(?:the\s+)?law\b|statut|source text|operative|legislation\b|regulation text|" + r"text of|provision text|primary source", + re.IGNORECASE, + ), + "review_finding": re.compile(r"review[\s_-]?finding", re.IGNORECASE), + "required_outputs": re.compile(r"\boutputs?\b", re.IGNORECASE), + "companion_tests": re.compile(r"companion|test cases?|\btests?\b|expected values|cases and", re.IGNORECASE), +} +_INLINE = { + "verbatim_law": re.compile(r"verbatim", re.IGNORECASE), + "review_finding": re.compile(r"review_finding|review finding", re.IGNORECASE), + "required_outputs": re.compile( + r"required (?:[\w-]+ ){0,2}outputs?\b|outputs? (?:required|needed)\b|must (?:output|produce|expose)\b", + re.IGNORECASE, + ), + "companion_tests": re.compile(r"companion (?:tests?|cases?)", re.IGNORECASE), +} +# Issues write several provisions of one document as "uk/regulation/uksi/2006/213/{2,19,13D}", +# sometimes with more than one group: "…/{6,9}/{1,2}.yaml". +_BRACED = re.compile(r"(?<![\w/-])[a-z]{2}(?:-[a-z0-9]+)*[/:][\w.:\-/]*(?:\{[^{}\n]{1,200}\}[\w.:\-/]*)+") +_GROUP = re.compile(r"\{([^{}]*)\}") + + +def expand_braces(text: str) -> str: + """``a/b/{1,2}.yaml`` -> ``a/b/1.yaml, a/b/2.yaml``, so each path is read as a path.""" + + def spell(m: re.Match[str]) -> str: + paths = [""] + last = 0 + for g in _GROUP.finditer(m.group(0)): + alts = [alt.strip() for alt in g.group(1).split(",") if alt.strip()] or [""] + paths = [p + m.group(0)[last : g.start()] + alt for p in paths for alt in alts] + last = g.end() + return ", ".join(p + m.group(0)[last:] for p in paths) + + return _BRACED.sub(spell, text) + + +_HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(?P<title>.+?)\s*#*\s*$") +_LABEL = re.compile(r"^\s{0,3}(?:[-*]\s+)?(?:\*\*|__)(?P<title>[^*_\n]{2,80}?)(?:\*\*|__)\s*[:.]?\s*(?P<after>.*)$") +_QUOTE = re.compile(r"^\s{0,3}>\s?(.*)$") +_LIST = re.compile(r"^\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s+\S") +_TABLE = re.compile(r"^\s*\|.*\|\s*$") +_FENCE = re.compile(r"^\s*(```|~~~)") + + +@dataclass +class Section: + title: str + lines: list[str] + source: str + + +@dataclass +class LintResult: + found: dict[str, str] = field(default_factory=dict) # element -> evidence + missing: list[str] = field(default_factory=list) + + @property + def ready(self) -> bool: + return not self.missing + + +def _sections(text: str, source: str, labels: bool = True) -> list[Section]: + """Split markdown into sections at headings and, if ``labels``, at bold labels. + + A heading's section without labels keeps its bold-labelled sub-parts, so + "## Law (verbatim)" followed by "**SI 2025/969**" and a quote is one + section. + """ + sections = [Section("", [], source)] + fenced = False + for line in text.splitlines(): + if _FENCE.match(line): + fenced = not fenced + # Inside a code block, "# case 1" is a YAML comment, not a heading. + h = _HEADING.match(line) if not fenced else None + lab = _LABEL.match(line) if labels and not h and not fenced else None + if h: + sections.append(Section(h.group("title"), [], source)) + elif lab: + sections.append(Section(lab.group("title"), [lab.group("after")] if lab.group("after") else [], source)) + else: + sections[-1].lines.append(line) + return sections + + +_INLINE_QUOTE = re.compile(r"[“\"]([^”\"]{12,600}?)[”\"]") + + +# An unfinished element: the finder's drafts mark what a person must fill in. +_TODO = re.compile(r"\b(?:TODO|TBD|FIXME|TBC)\b|<\s*fill[^>]*>", re.IGNORECASE) +# A companion case states its expected outcome. A list number, an input amount +# or a section number is not one, so a bare digit doesn't count, and neither does +# a bare "key: 0" or "income is 0", which could as well be an input. A result +# after a colon counts when a described case (four words or more) comes before it. +_OUTCOME = re.compile( + r"\bnot_holds\b|\bholds?\b|\bdoes(?: not|n't) hold\b|\bexpect(?:ed|s)?\b[^.\n]*?(?:[:=]|\d)|→|=>|" + r"(?:=|\bgives?|\breturns?|\byields?|\bequals?|\bshould be|\bmust be)\s*[-–]?[£$€]?\d|" + r"\btrue\b|\bfalse\b|\b(?:in)?eligible\b|\bzero\b|\bnil\b|\bpass(?:es)?\b|\bfails?\b|" + r"\b(?:allowed|denied|disallowed)\b|\boutputs?\s*:\s*\S|" + # "Pay £40,000, loss £10,000: relief £10,000": a described case, then its result. + r"(?:[\w£$€%,.()'’-]+\s+){3,}[\w£$€%,.()'’-]+\s*:\s*(?:[a-z][\w -]{0,40}\s)?[-–]?[£$€]?\d|" + r"\|\s*[-–]?[£$€]?\d[\d,.]*%?\s*\|", + re.IGNORECASE, +) +_LIST_MARKER = re.compile(r"^\s*(?:>\s*)*(?:[-*+]|\d+[.)]|\(\d+\)|\(?[a-z]\)|[ivx]+[.)])\s+") +# A structured case: an ``input:`` key and an ``output:`` (or ``expected:``) key, each +# with its facts indented below it. The key's own column decides what is below it, +# so "- input:" and a sibling " output:" are two blocks. +_BLOCK_KEY = re.compile( + r"^(?P<lead>\s*(?:[-*+]\s+)?)(?:\*\*|`)?(?P<key>inputs?|outputs?|expected|expect)(?:\*\*|`)?\s*:\s*$", + re.IGNORECASE, +) +_KEY_VALUE = re.compile(r"^\s*(?:[-*+]\s+)?[`\w.:/#\-]+\s*:\s*\S") +# An "input income: 0" clause runs until a result is announced. A semicolon or a +# comma only separates one input fact from the next. +_INPUT_LABEL = re.compile(r"\binputs?\b", re.IGNORECASE) +_RESULT_STARTS = re.compile( + r"→|=>|->|\b(?:expect\w*|outputs?|then|should|gives?|returns?|yields?|result\w*)\b", re.IGNORECASE +) + + +def _without_inputs(lines: list[str]) -> list[str]: + """The lines with input facts removed, so that only stated results are left. + + "- input income: 0" gives a fact, not an expected outcome. So does every + line under an ``input:`` key. A "key: value" line under an ``output:`` or + ``expected:`` key is a stated result, and is marked as one. + """ + out: list[str] = [] + block: tuple[int, bool] | None = None # (the key's column, is it an input block) + header: list[bool] | None = None # for each column of the current table, is it an input column + for raw in lines: + indent = len(raw) - len(raw.lstrip()) + if block is not None: + if not raw.strip(): + continue + if indent > block[0]: + if not block[1] and _KEY_VALUE.match(raw): + out.append("expected: " + raw.strip()) + continue + block = None + key = _BLOCK_KEY.match(raw) + if key: + block = (len(key.group("lead")), key.group("key").lower().startswith("input")) + continue + if _TABLE.match(raw): + cells = [c.strip() for c in raw.strip().strip("|").split("|")] + if header is None: + header = [bool(re.search(r"\binputs?\b|\bgiven\b|\bfacts?\b", c, re.I)) for c in cells] + out.append("|" + "|".join("" if n < len(header) and header[n] else c for n, c in enumerate(cells)) + "|") + continue + header = None + line = _LIST_MARKER.sub("", raw) + pos = 0 + while label := _INPUT_LABEL.search(line, pos): + end = _RESULT_STARTS.search(line, label.end()) + if not end: + line = line[: label.start()] + break + line = line[: label.start()] + line[end.start() :] + pos = label.start() + len(end.group(0)) + out.append(line) + return out + + +_RESULT_COLUMN = re.compile(r"\b(?:expect\w*|outputs?|results?|outcomes?)\b", re.IGNORECASE) + + +def _cells(row: str) -> list[str]: + return [c.strip() for c in row.strip().strip("|").split("|")] + + +def _result_table(lines: list[str]) -> bool: + """A table whose header names a result column, with a result filled in under it.""" + rows: list[str] = [] + for line in [*lines, ""]: + if _TABLE.match(line): + rows.append(line) + continue + header = _cells(rows[0]) if rows else [] + columns = [n for n, cell in enumerate(header) if _RESULT_COLUMN.search(cell)] + for row in rows[2:]: + cells = _cells(row) + if any(n < len(cells) and re.search(r"\w", cells[n]) for n in columns): + return True + rows = [] + return False + + +def _states_outcome(lines: list[str]) -> bool: + return _result_table(lines) or any(_OUTCOME.search(line) for line in _without_inputs(lines)) + + +def _finished(lines: list[str]) -> list[str]: + """Drop TODO lines, which carry no content.""" + return [line for line in lines if not _TODO.search(line)] + + +def _quoted_chars(lines: list[str]) -> int: + """Characters of quoted material: blockquotes, fenced blocks and “inline quotes”.""" + n, fenced, plain = 0, False, [] + for line in lines: + if _FENCE.match(line): + fenced = not fenced + continue + if fenced: + n += len(line.strip()) + continue + q = _QUOTE.match(line) + if q: + n += len(q.group(1).strip()) + else: + plain.append(line.strip()) + n += sum(len(m.group(1)) for m in _INLINE_QUOTE.finditer(" ".join(plain))) + return n + + +def _content_chars(lines: list[str]) -> int: + return sum(len(line.strip()) for line in lines) + + +def _items(lines: list[str]) -> int: + return sum(1 for line in lines if _LIST.match(line) or _TABLE.match(line)) + + +def _has_content(element: str, lines: list[str]) -> bool: + if any(_TODO.search(line) for line in lines): + return False # an unfinished element is missing, however long the rest is + if element == "companion_tests" and not _states_outcome(lines): + return False # cases without an expected outcome are not companion tests + if element == "verbatim_law": + return _quoted_chars(lines) >= 60 or _content_chars(lines) >= 300 + if element == "review_finding": + return _quoted_chars(lines) >= 80 or _content_chars(lines) >= 120 + if element == "required_outputs": + return _items(lines) >= 1 or _content_chars(lines) >= 80 + if element == "companion_tests": + return _items(lines) >= 1 or _quoted_chars(lines) >= 80 or _content_chars(lines) >= 120 + return False + + +def _introduces_content(element: str, line: str, following: list[str]) -> bool: + """Does an inline cue introduce the element, on its own line or just below? + + "Required outputs: `a`, `b`" and "Companion cases:" followed by a list do. + A back-reference in running prose ("state each required output below") + doesn't. + """ + if _TODO.search(line): + return False + m = _INLINE[element].search(line) + rest = line[m.end() :] + if re.match(r"\s*(?:below|above|listed|in the)\b", rest, re.I): + return False + block = [rest.lstrip(" \t:,;—–-(*`_")] + fenced = False + for nxt in following: + if _FENCE.match(nxt): + fenced = not fenced + if _HEADING.match(nxt) and not fenced: + break + if not nxt.strip(): + # Cases often run over several paragraphs: a list, a note, then a table of + # expected values. Other elements end at the first blank line. + if element != "companion_tests" and any(b.strip() for b in block[1:]): + break + continue + if element == "companion_tests" and _LABEL.match(nxt): + break + block.append(nxt) + if any(_TODO.search(b) for b in block): + return False + text = " ".join(b.strip() for b in block).strip() + if element == "companion_tests": + return len(text) >= 40 and _states_outcome(block) + # Outputs: a named output ("`a`, `b`") or a sentence describing them. + return bool(re.search(r"`[^`\n]{2,}`", text)) or len(text) >= 20 + + +def _snippet(text: str, n: int = 90) -> str: + text = " ".join(text.split()) + return text if len(text) <= n else text[: n - 1] + "…" + + +_YAML_KEY = re.compile(r"^(?P<indent>\s*)-?\s*review_finding:\s*(?P<style>[|>][-+]?)?\s*(?P<inline>.*)$") + + +_FINDING_NEGATED = re.compile( + r"\b(?:no|not|none|without|missing|lacks?|needs?|absent|empty|blank)\b|n't\b|\bn/a\b", re.IGNORECASE +) +# "review_finding will be supplied later:", "review_finding is unavailable:". +_FINDING_DEFERRED = re.compile( + r"\b(?:later|pending|unavailable|forthcoming|awaiting|awaited|to follow|to come|to be|coming|soon|tbd|tbc|todo|" + r"placeholder|outstanding|omitted|withheld|when (?:it is |it's )?(?:written|ready|available|drafted|done))\b", + re.IGNORECASE, +) +_FINDING_HANDOFF = re.compile( + r"\bpaste(?:able)?\b|\buse (?:this|the following)\b|\bwith this\b|\bappend\b|\badd(?:ition|endum)?\b|\bpass it\b", + re.IGNORECASE, +) +# A verb after the field name makes the cue a statement about the finding +# ("review_finding comes next week:"), not a label for it. +_FINDING_STATEMENT = re.compile( + r"\b(?:is|are|was|were|will|shall|has|have|had|comes?|goes|remains?|must|should|can|cannot|may|might|follows?)\b", + re.IGNORECASE, +) +_FIELD = re.compile(r"review[_\s-]?finding", re.IGNORECASE) +# A finding that opens by saying there isn't one yet: "unavailable until the encoder owner approves it." +_OPENS_DEFERRED = re.compile( + r"^\W*(?:\w+\W+){0,3}?(?:unavailable|pending|forthcoming|awaiting|awaited|tbd|tbc|todo|none|n/a|" + r"not (?:yet|available|written|ready)|to follow|to come|to be (?:written|supplied|added|confirmed))\b", + re.IGNORECASE, +) + + +def _introduces_finding(line: str) -> bool: + """An inline cue that hands over a finding: "Paste this as `review_finding`:". + + Only the sentence that names the field is judged, so a "not" or "need" + earlier in a long paragraph doesn't cancel a cue at its end. + """ + sentences = re.split(r"(?<=[.!?])\s+", line.strip()) + cue = [s for s in sentences if _FIELD.search(s)] + if not cue or _FINDING_NEGATED.search(cue[-1]) or _FINDING_DEFERRED.search(cue[-1]): + return False + if _FINDING_HANDOFF.search(cue[-1]): + return True + # A bare label: "`review_finding`:" or "Corrected item 2 for the review_finding:". + after = re.sub(r"\([^()]*\)", "", cue[-1][_FIELD.search(cue[-1]).end() :]) + ends_line = cue[-1] is sentences[-1] and cue[-1].rstrip(" *_`").endswith(":") + return ends_line and not _FINDING_STATEMENT.search(after) + + +_FINDING_FENCE = re.compile(r"^\s*(```|~~~)\s*review[_-]?finding\b", re.IGNORECASE) + + +def _yaml_value(lines: list[str], i: int) -> tuple[str, int]: + """The scalar under a ``review_finding:`` key at ``lines[i]``, and the index after it.""" + y = _YAML_KEY.match(lines[i]) + if not y.group("style"): + return y.group("inline").strip().strip("'\""), i + 1 + indent = len(y.group("indent")) + block, end = [], i + 1 + for n, nxt in enumerate(lines[i + 1 :], start=i + 1): + if nxt.strip() and len(nxt) - len(nxt.lstrip()) <= indent: + break + block.append(nxt.strip()) + end = n + 1 + return " ".join(t for t in block if t).strip(), end + + +def _names_finding(line: str) -> bool: + """Is the line a bold label or an inline cue for a review_finding of its own?""" + if not _FIELD.search(line) or _YAML_KEY.match(line): + return False + label = _LABEL.match(line) + return bool(label and _FIELD.search(label.group("title"))) or _introduces_finding(line) + + +def _finding_at(lines: list[str], i: int) -> tuple[str | None, int]: + """The finding that ``lines[i]`` introduces, and the index after the lines it used.""" + line = lines[i] + fence = _FINDING_FENCE.match(line) + if fence: + # A code block tagged as the finding: ```review_finding + block, end = [], i + 1 + for n, nxt in enumerate(lines[i + 1 :], start=i + 1): + end = n + 1 + if nxt.strip().startswith(fence.group(1)): + break + block.append(nxt) + text = "\n".join(block).strip() + return (text, end) if len(text) >= 80 and not _TODO.search(text) else (None, end) + if _YAML_KEY.match(line): + text, end = _yaml_value(lines, i) + return (text, end) if len(text) >= 80 and not _TODO.search(text) else (None, i + 1) + if not _FIELD.search(line): + return None, i + 1 + heading = _HEADING.match(line) + label = _LABEL.match(line) if not heading else None + if heading and not _FIELD.search(heading.group("title")): + return None, i + 1 + titled = bool(heading) or bool(label and _FIELD.search(label.group("title"))) + inline = "" # text on a label's own line that starts the finding itself + if titled: + title = (heading or label).group("title") + after = label.group("after").strip() if label else "" + if after.endswith(":") or len(after) <= 40: + # The rest of the label's line is still the label: "**review finding** (pending):", + # "**Review finding:** unavailable until the encoder owner approves:". + title += " " + after + else: + inline = after + # A finding that starts on the label's line is judged by its first sentence: + # "Coming soon when the owner has checked the text." gives no finding, however long. + opening = re.split(r"(?<=[.!?])\s+", inline, maxsplit=1)[0] + if ( + _FINDING_NEGATED.search(title) + or _FINDING_DEFERRED.search(title) + or _OPENS_DEFERRED.match(inline) + or _FINDING_DEFERRED.search(opening) + ): + return None, i + 1 # "## review_finding (pending)" promises a finding; it doesn't give one + elif not _introduces_finding(line): + return None, i + 1 # "No review_finding is supplied." mentions the field; it doesn't give one + quoted, plain, fenced = [], [], False + quoted_end = plain_end = i + 1 + if label and titled and label.group("after").strip(): + plain.append(label.group("after").strip()) + for n, nxt in enumerate(lines[i + 1 : i + 80], start=i + 1): + if inline and (re.match(r"^\s*(```|~~~)", nxt) or _QUOTE.match(nxt)): + break # the label's own line began the finding; a quote below it is something else + if re.match(r"^\s*(```|~~~)", nxt): + if fenced: + quoted_end = n + 1 + break + if quoted: + break # a second block is not part of the first + fenced = True + continue + if fenced: + quoted.append(nxt) + quoted_end = n + 1 + continue + q = _QUOTE.match(nxt) + if q: + quoted.append(q.group(1)) + quoted_end = n + 1 + continue + if _HEADING.match(nxt): + break + if _names_finding(nxt): + break # the next finding's label: what follows it is not this one's + if quoted: + if nxt.strip(): + break + quoted.append("") + continue + if titled and nxt.strip() and not _LABEL.match(nxt): + plain.append(nxt.strip()) + plain_end = n + 1 + elif titled and plain and not nxt.strip() and len(" ".join(plain)) >= 120: + break + elif not titled and nxt.strip(): + break # an inline cue hands over the block right below it, not one further down + block = [q for q in quoted] + while block and not block[0].strip(): + block.pop(0) + if block and _YAML_KEY.match(block[0]): + # A dispatch snippet in the block: the finding is the value, not the key line. + value, _ = _yaml_value(block, 0) + if len(value) >= 80: + block = [value] + for candidate, minimum, end in ( + ("\n".join(block).strip(), 80, quoted_end), + (" ".join(plain).strip(), 120, plain_end), + ): + if len(candidate) >= minimum and not _TODO.search(candidate): + return candidate, end + return None, i + 1 + + +def review_findings(markdown: str) -> list[str]: + """Every pasteable review_finding in the text, in order. + + A finding is: a quote or code block after a heading, bold label or inline + cue that names ``review_finding``; plain paragraphs under such a heading or + label; a code block tagged ``review_finding``; or the YAML form dispatch + snippets use (``review_finding: |-`` and an indented block). A finding with + a TODO in it is unfinished and doesn't count. Lines one finding used are + not read again, so a YAML key inside a quoted block isn't a second finding. + """ + lines = markdown.splitlines() + out: list[str] = [] + i = 0 + while i < len(lines): + text, i = _finding_at(lines, i) + if text: + out.append(text) + return out + + +def review_finding_text(markdown: str) -> str | None: + """The first pasteable review_finding in the text, or None.""" + found = review_findings(markdown) + return found[0] if found else None + + +# An output named in running text, in one of a closed set of declarations: +# "Output Money x_y_z", "outputs `x_y_z`", "Judgment x_y_z", "Add `x_y_z` Money (TaxUnit) = …", +# "Name them x_y_z", "Add … to 9.yaml#x_y". A bare "Add x_y_z" or "a leaf x_y_z" is not one: +# it could as well be an input. +_NOT_OUTPUT = r"(?!\b(?:inputs?|import\w*|from|tests?|cases?)\b)" +_TYPES = r"(?:Judge?ments?|Money|Rates?|Amounts?|Counts?|Integers?|Booleans?|Dates?|Decimals?|Percentages?)" +_IDENT = r"`?\b[a-z][a-z0-9]*(?:_[a-z0-9]+){2,}\b`?" +_NAMED_OUTPUT = re.compile( + rf"\boutputs?\s*:?\s*(?:(?:an?|the|new)\s+)?(?:{_TYPES}\s+)?{_IDENT}" + rf"|\bJudge?ments?\s+{_IDENT}" + rf"|\badd(?:s|ed)?\s+(?:(?:an?|the|new)\s+)*{_IDENT}\s+{_TYPES}\b" + rf"|\bname (?:it|them|each|the outputs?)\b(?:{_NOT_OUTPUT}[^.\n]){{0,80}}?{_IDENT}" + r"|\b(?:add|output|state|change|correct|repair|update|extend)(?:s|ed|es|d)?\b" + rf"(?:{_NOT_OUTPUT}[^.\n]){{0,120}}?(?:\.yaml|[a-z0-9])#[a-z][a-z0-9_]+", + re.IGNORECASE, +) + + +# What follows a name can make it an input after all: "a_b_c as a new input", "a_b_c, a leaf input". +_DESIGNATED_INPUT = re.compile( + r"^`?\s*(?:[,(]\s*)?(?:(?:as|is|are|being|to be|will be|to|among|in|into)\s+)?(?:(?:an?|the)\s+)?" + r"(?:[\w-]+\s+){0,3}inputs?\b", # "as a new input", "as a scoped leaf input", "as a Date input" + re.IGNORECASE, +) + + +def _named_output(finding: str) -> re.Match[str] | None: + """The first declaration of an output in the finding, or None. + + A declaration is skipped when its clause speaks of an input or an import + before the name ("Add an input rule a_b_c", "the rule imported from …"), or + when the words right after the name make it an input ("Add a rule a_b_c as + a new input"). + """ + for m in _NAMED_OUTPUT.finditer(finding): + clause = re.split(r"[.!?;:]\s|\n", finding[: m.start()])[-1] + m.group(0) + if re.search(r"\binputs?\b|\bimport\w*\b", clause, re.IGNORECASE): + continue + if _DESIGNATED_INPUT.match(finding[m.end() :]): + continue + return m + return None + + +def lint_texts(texts: list[tuple[str, str]]) -> LintResult: + """Lint an issue given (source label, markdown) pairs: body, then comments.""" + result = LintResult() + sections: list[Section] = [] + for source, text in texts: + sections.extend(_sections(text or "", source, labels=False)) + for source, text in texts: + sections.extend(s for s in _sections(text or "", source) if s.title) + joined = expand_braces("\n".join(t or "" for _, t in texts)) + + m = _MODULE.search(joined) + if m: + result.found["module_path"] = m.group(0) + for m in _CORPUS_SINGULAR.finditer(joined): + if not _ARTIFACT.search(m.group(0)): + result.found["corpus_citation"] = m.group(0) + break + + # The review_finding element is whatever the drain could paste: one extractor + # decides both, so a lint-ready issue always has dispatch input. + for source, text in texts: + finding = review_finding_text(text or "") + if finding: + result.found["review_finding"] = f"{source}: “{_snippet(finding, 70)}”" + break + + for element in ("verbatim_law", "required_outputs", "companion_tests"): + title_re = _TITLES[element] + for sec in sections: + if not sec.title or not title_re.search(sec.title): + continue + # A review_finding section is not the verbatim law, and vice versa. + if element == "verbatim_law" and _TITLES["review_finding"].search(sec.title): + continue + if element == "required_outputs" and not re.search(r"required|outputs?\b", sec.title, re.I): + continue + if _has_content(element, sec.lines): + result.found[element] = f"{sec.source}: section “{_snippet(sec.title, 60)}”" + break + if element in result.found: + continue + # Inline cue followed by content, within the same section. + for sec in sections: + if element == "verbatim_law" and _TITLES["review_finding"].search(sec.title): + continue # "verbatim proof excerpts" inside a review_finding is not the law + lines = sec.lines + for i, line in enumerate(lines): + if not _INLINE[element].search(line): + continue + if element == "verbatim_law" and re.search(r"proof|review_finding", line, re.I): + continue + window = [line, *lines[i + 1 : i + 16]] + if element == "verbatim_law": + ok = _quoted_chars(_finished(window)) >= 60 and not any(_TODO.search(w) for w in window) + else: + ok = _introduces_content(element, line, lines[i + 1 : i + 16]) + if ok: + result.found[element] = f"{sec.source}: “{_snippet(line)}”" + break + if element in result.found: + break + + if "required_outputs" not in result.found: + # The finding itself may name the outputs: "Add a Judgment x_holds_for_year", + # "54.yaml outputs `person_earned_income`". + for source, text in texts: + m = next((m for f in review_findings(text or "") if (m := _named_output(f))), None) + if m: + result.found["required_outputs"] = f"{source}: review_finding names “{_snippet(m.group(0), 70)}”" + break + + result.missing = [e for e in ELEMENTS if e not in result.found] + return result + + +def lint_issue(issue: dict, comments: list[dict] | None = None) -> LintResult: + texts = [("body", issue.get("body") or "")] + for c in comments or []: + texts.append((f"comment {c.get('id', '')}".strip(), c.get("body") or "")) + return lint_texts(texts) diff --git a/axiom-parity/src/axiom_parity/na.py b/axiom-parity/src/axiom_parity/na.py new file mode 100644 index 0000000..0556261 --- /dev/null +++ b/axiom-parity/src/axiom_parity/na.py @@ -0,0 +1,137 @@ +"""Decide whether an ``axiom: n/a`` reason is an allowed category. + +The rule (PolicyEngine CLAUDE.md, 2026-09-26; CONTRIBUTING "Mirror policy +changes in Axiom") makes infrastructure, data, UI, microsimulation-only and +emulator-mapping changes n/a. Three more categories cover PRs that touch +policy paths without changing any provision of law: documentation and tests, +reference metadata, and contributed reforms or other text that is not law. + +"Axiom doesn't encode this yet" is never an n/a reason: that is exactly the +case ``queued`` exists for. + +Authors write ``axiom: n/a: <category>: <reason>``. A free-text reason is +accepted when one category's keywords match it. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +CATEGORIES: dict[str, dict] = { + "infra": { + "label": "infrastructure (CI, build, packaging, caching, simulation plumbing)", + "keywords": r"infra|infrastructure|\bci\b|build|packag|cache|caching|branch|plumbing|refactor|performance|" + r"speed|lifecycle|dependency|dependencies|typing|lint|guard", + }, + "data": { + "label": "data (datasets, calibration, uprating of survey data, data contracts)", + "keywords": r"\bdata\b|dataset|calibrat|survey|microdata|imputation|uprating code|data-only|data contract|" + r"targets?\b", + }, + "ui": { + "label": "UI or display only", + "keywords": r"\bui\b|display|front-?end|app copy|chart", + }, + "microsim-only": { + "label": "microsimulation-only (model structure, inputs, aggregates, behavioural responses)", + "keywords": r"microsim|microsimulation|simulation-only|simulation[- ]coverage|simulation flag|cliff|model[- ]structure|model input|" + r"input plumbing|model aggregate|aggregate|labou?r supply|behaviou?ral|household role|role inference|" + r"presumption|projected years|modelling fix|modeling fix|statistical measure|not a (?:legal|statutory) " + r"provision|not a legislated provision|period-range|split", + }, + "emulator-mapping": { + "label": "emulator mapping (TAXSIM and other emulator variable mappings)", + "keywords": r"emulator|taxsim mapping|variable mapping|mapping only", + }, + "docs-tests": { + "label": "documentation and tests only", + "keywords": r"documentation|docs?\b|docstring|tests? only|test fixtures?|tests and|and tests\b|readme", + }, + "metadata": { + "label": "reference metadata only (labels, citations, units)", + "keywords": r"metadata|reference-only|references? only|citations?|labels?\b|reference urls?|hrefs?", + }, + "not-law": { + "label": "not law (contributed reforms, proposals, unenacted announcements)", + "keywords": r"contrib|proposal|proposed|not (?:yet )?(?:enacted|legislated)|unenacted|hypothetical|" + r"consultation|policy paper|announced|draft bill|reform \(not", + }, +} + +# Categories that claim the PR changes no computed value. +NO_BEHAVIOUR_CATEGORIES = {"docs-tests", "metadata"} + +_AXIOM_LACKS = re.compile( + r"(?:does not|doesn't|do not|don't) (?:yet )?encode|not (?:yet )?(?:encoded|in (?:axiom|rulespec))|" + r"no (?:rulespec|axiom) module|encodes no|lacks?\b|not yet in|follow-?up|to do|todo|later|pending|missing from", + re.IGNORECASE, +) +# Phrases that say the change is not a provision of law at all. They beat a +# mention that Axiom has no module for it ("a statistical measure, not a +# legislated provision; rulespec-uk encodes no poverty lines"). +_NOT_LAW = re.compile( + r"not an? (?:legal|statutory|legislated) (?:provision|rule)|not (?:enacted )?law\b|not enacted|not legislated|" + r"statistical measure|contrib(?:uted)? reform|model aggregate|not a provision", + re.IGNORECASE, +) +_EXPLICIT = re.compile( + r"^\s*\(?\s*(?P<cat>[a-z][a-z\- ]{1,24}?)\s*\)?\s*[:—–-]\s+(?P<reason>.+)$", re.IGNORECASE | re.DOTALL +) +_ALIASES = { + "infrastructure": "infra", + "ci": "infra", + "microsim": "microsim-only", + "microsimulation": "microsim-only", + "microsimulation-only": "microsim-only", + "emulator": "emulator-mapping", + "docs": "docs-tests", + "tests": "docs-tests", + "documentation": "docs-tests", + "reference-only": "metadata", + "references": "metadata", + "contrib": "not-law", + "proposal": "not-law", +} + + +@dataclass +class NaVerdict: + ok: bool + category: str | None + message: str + explicit: bool = False + + +def _canonical(cat: str) -> str | None: + cat = cat.strip().lower().replace(" ", "-") + if cat in CATEGORIES: + return cat + return _ALIASES.get(cat) + + +def classify(reason: str | None) -> NaVerdict: + reason = (reason or "").strip().strip("()").strip() + if not reason: + return NaVerdict(False, None, "n/a needs a category and a reason: `axiom: n/a: <category>: <reason>`") + m = _EXPLICIT.match(reason) + if m and _canonical(m.group("cat")): + cat = _canonical(m.group("cat")) + return NaVerdict(True, cat, f"n/a ({cat})", explicit=True) + if _AXIOM_LACKS.search(reason) and not _NOT_LAW.search(reason): + # The reason says Axiom lacks the provision. That is queued, unless the + # author names a category explicitly because the change is not law. + return NaVerdict( + False, + None, + "the reason says Axiom does not encode this yet, which is what `queued` is for: file a " + "dispatch-ready pe-parity issue and write `axiom: <rulespec issue> queued`. If the change is " + "not a provision of law, name the category: `axiom: n/a: <category>: <reason>`.", + ) + hits = [name for name, spec in CATEGORIES.items() if re.search(spec["keywords"], reason, re.IGNORECASE)] + if hits: + return NaVerdict(True, hits[0], f"n/a ({hits[0]}, inferred from the reason)") + allowed = ", ".join(f"`{k}`" for k in CATEGORIES) + return NaVerdict( + False, None, f"the n/a reason matches no allowed category ({allowed}); write `axiom: n/a: <category>: <reason>`" + ) diff --git a/axiom-parity/src/axiom_parity/pe_yaml.py b/axiom-parity/src/axiom_parity/pe_yaml.py new file mode 100644 index 0000000..29de4ce --- /dev/null +++ b/axiom-parity/src/axiom_parity/pe_yaml.py @@ -0,0 +1,27 @@ +"""YAML loading for PolicyEngine and RuleSpec files. + +PolicyEngine parameters use dates such as ``0000-01-01``, which PyYAML's +timestamp resolver rejects. This loader keeps every date as a string. +""" + +from __future__ import annotations + +from typing import Any + +import yaml + + +class _Loader(yaml.SafeLoader): + pass + + +_Loader.yaml_implicit_resolvers = { + ch: [(tag, regexp) for tag, regexp in resolvers if tag != "tag:yaml.org,2002:timestamp"] + for ch, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items() +} + +YAMLError = yaml.YAMLError + + +def load(text: str) -> Any: + return yaml.load(text, Loader=_Loader) # noqa: S506 - a SafeLoader subclass diff --git a/axiom-parity/src/axiom_parity/policy_paths.py b/axiom-parity/src/axiom_parity/policy_paths.py new file mode 100644 index 0000000..b457b0c --- /dev/null +++ b/axiom-parity/src/axiom_parity/policy_paths.py @@ -0,0 +1,42 @@ +"""Which changed files make a PR a policy PR. + +Policy lives under ``<package>/parameters``, ``<package>/variables`` and +``<package>/reforms``. Documentation files there don't count. Tests, data, +docs and CI live elsewhere and never trigger the rule. +""" + +from __future__ import annotations + +import fnmatch +from dataclasses import dataclass + +DEFAULT_POLICY_DIRS = ("parameters", "variables", "reforms") +DEFAULT_EXCLUDE = ("*.md", "*.MD", "*.rst", "*/README*", "*.ipynb") + + +@dataclass(frozen=True) +class PolicyPaths: + package: str + dirs: tuple[str, ...] = DEFAULT_POLICY_DIRS + exclude: tuple[str, ...] = DEFAULT_EXCLUDE + + def is_policy(self, path: str) -> bool: + if not any(path.startswith(f"{self.package}/{d}/") for d in self.dirs): + return False + return not any( + fnmatch.fnmatch(path, pat) or fnmatch.fnmatch(path.rsplit("/", 1)[-1], pat) for pat in self.exclude + ) + + def policy_files(self, changed: list[str]) -> list[str]: + return [p for p in changed if self.is_policy(p)] + + +def package_for_repo(repo: str) -> str: + """``PolicyEngine/policyengine-uk`` -> ``policyengine_uk``.""" + return repo.rsplit("/", 1)[-1].replace("-", "_") + + +def country_for_repo(repo: str) -> str: + """``PolicyEngine/policyengine-canada`` -> ``canada``.""" + name = repo.rsplit("/", 1)[-1] + return name.split("policyengine-", 1)[-1] diff --git a/axiom-parity/src/axiom_parity/refs.py b/axiom-parity/src/axiom_parity/refs.py new file mode 100644 index 0000000..bf94324 --- /dev/null +++ b/axiom-parity/src/axiom_parity/refs.py @@ -0,0 +1,276 @@ +"""Parse the references an ``axiom:`` claim cites. + +Three kinds of reference appear in claims: + +- rulespec issue and PR references (``TheAxiomFoundation/rulespec-us#1416``, + ``rulespec-uk#430`` or a github.com URL); +- legal ids and module paths that name a RuleSpec module + (``us:statutes/26/62#rule``, ``uk/regulations/uksi/2013/376/62.yaml``, or a + corpus citation path such as ``uk/regulation/uksi/2013/376/32``); +- companion test references (``32.test.yaml``, ``path.test.yaml::case`` or a + case name). + +Parsing is purely syntactic. Whether a reference exists is decided by +``resolve.py`` against the repository. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +AXIOM_OWNER = "TheAxiomFoundation" + +# Module roots used by RuleSpec repos, and the singular forms corpus citation +# paths use for the same kind of source. +MODULE_KINDS = ("statutes", "regulations", "policies", "legislation", "programs") +CORPUS_KINDS = { + "statute": "statutes", + "regulation": "regulations", + "policy": "policies", + "guidance": "policies", + "legislation": "legislation", +} + +# A jurisdiction is a two-letter country code with an optional subdivision, +# such as "us", "us-md", "uk" or "uk-bath-and-north-east-somerset". +_JURIS = r"[a-z]{2}(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?" +_PATH_CHARS = r"[A-Za-z0-9_.:\-/]" +_RULE = r"[A-Za-z_][A-Za-z0-9_]*" + +_RULESPEC_URL = re.compile( + r"https?://github\.com/(?P<owner>[A-Za-z0-9-]+)/(?P<repo>rulespec-[a-z0-9-]+)" + r"/(?P<kind>issues|pull)/(?P<number>\d+)(?:#issuecomment-(?P<comment>\d+))?", + re.IGNORECASE, +) +_RULESPEC_SHORT = re.compile( + r"(?<![\w/.-])(?:(?P<owner>[A-Za-z0-9-]+)/)?(?P<repo>rulespec-[a-z0-9-]+)#(?P<number>\d+)", + re.IGNORECASE, +) + +# juris:kind/path[#rule] (a RuleSpec legal id) +_LEGAL_ID = re.compile( + rf"(?<![\w/:.-])(?P<juris>{_JURIS}):(?P<kind>{'|'.join(MODULE_KINDS)})/" + rf"(?P<rest>{_PATH_CHARS}+)(?:#(?P<rule>{_RULE}))?" +) +# juris/kind/path[.yaml][#rule] (a module path, or a corpus citation path) +_PATH_REF = re.compile( + rf"(?<![\w/:.-])(?P<juris>{_JURIS})/(?P<kind>" + rf"{'|'.join(MODULE_KINDS)}|{'|'.join(CORPUS_KINDS)})/" + rf"(?P<rest>{_PATH_CHARS}+)(?:#(?P<rule>{_RULE}))?" +) +_TEST_PATH = re.compile( + rf"(?<![\w/])(?P<path>(?:\.\.\./)?{_PATH_CHARS}*?[A-Za-z0-9_\-:]+\.test\.yaml)" + rf"(?:::(?P<case>{_RULE}))?" +) +_BACKTICKED = re.compile(r"`([^`\n]{3,200})`") +_QUOTED = re.compile(r"[\"“]([^\"”\n]{8,200})[\"”]") +_SNAKE = re.compile(r"(?<![\w/:#.-])([a-z][a-z0-9]*(?:_[a-z0-9]+){2,})(?![\w/.#-])") +_RELATIVE = re.compile(r"(?:(?<=[\s,(`])|^)/(?P<seg>[A-Za-z0-9][A-Za-z0-9.\-]*)(?=[\s,`)]|$)") + + +@dataclass(frozen=True) +class RulespecRef: + """A reference to an issue or pull request in a rulespec repository.""" + + owner: str + repo: str + number: int + kind: str # "issue", "pull" or "unknown" (short form) + comment_id: int | None + raw: str + start: int = 0 + + @property + def slug(self) -> str: + return f"{self.owner}/{self.repo}#{self.number}" + + +@dataclass(frozen=True) +class LegalRef: + """A reference that should name a RuleSpec module.""" + + raw: str + jurisdiction: str + kind: str # module kind, plural ("statutes", ...) + rest: str # path below the kind, without ".yaml" + rule: str | None + form: str # "legal-id", "module-path", "corpus-path" or "relative" + start: int = 0 + + @property + def module_path(self) -> str: + return f"{self.jurisdiction}/{self.kind}/{self.rest}.yaml" + + @property + def legal_id(self) -> str: + return f"{self.jurisdiction}:{self.kind}/{self.rest}" + + @property + def explicit(self) -> bool: + """True when the author wrote the module's own id, file path, or a sibling of one (``/22``).""" + return self.form in ("legal-id", "module-path", "relative") + + +@dataclass(frozen=True) +class TestRef: + """A companion test reference: a test file, a case name, or both.""" + + raw: str + path: str | None + case: str | None + + +_DASHES = str.maketrans({"\u2010": "-", "\u2011": "-", "\u2012": "-", "\u2013": "-", "\u2014": "-", "\u2212": "-"}) + + +def norm_dashes(text: str) -> str: + """Corpus paths sometimes carry an en dash (``42/1437c–1``); URLs and people write a hyphen.""" + return text.translate(_DASHES) + + +def _strip_trailing(text: str) -> str: + return text.rstrip(".,;:)]}'\"`*") + + +def parse_rulespec_refs(text: str) -> list[RulespecRef]: + """Return rulespec issue/PR references in order of appearance, deduplicated.""" + found: dict[tuple[str, int], RulespecRef] = {} + spans: list[tuple[int, int]] = [] + for m in _RULESPEC_URL.finditer(text): + kind = "pull" if m.group("kind").lower() == "pull" else "issue" + comment = int(m.group("comment")) if m.group("comment") else None + ref = RulespecRef( + AXIOM_OWNER if m.group("owner").lower() == AXIOM_OWNER.lower() else m.group("owner"), + m.group("repo").lower(), + int(m.group("number")), + kind, + comment, + m.group(0), + m.start(), + ) + spans.append(m.span()) + found.setdefault((ref.repo, ref.number), ref) + for m in _RULESPEC_SHORT.finditer(text): + if any(a <= m.start() < b for a, b in spans): + continue + owner = m.group("owner") or AXIOM_OWNER + if owner.lower() == AXIOM_OWNER.lower(): + owner = AXIOM_OWNER + ref = RulespecRef( + owner, m.group("repo").lower(), int(m.group("number")), "unknown", None, m.group(0), m.start() + ) + found.setdefault((ref.repo, ref.number), ref) + return sorted(found.values(), key=lambda r: r.start) + + +def _split_rest(rest: str) -> tuple[str, str | None, bool]: + """Split a matched path into (path without .yaml, rule, had_yaml).""" + rest = _strip_trailing(rest) + rule = None + if "#" in rest: + rest, rule = rest.split("#", 1) + rule = re.match(_RULE, rule).group(0) if re.match(_RULE, rule) else None + had_yaml = False + if rest.endswith(".test.yaml"): + return "", None, False + if rest.endswith(".yaml"): + rest = rest[: -len(".yaml")] + had_yaml = True + return rest.strip("/"), rule, had_yaml + + +def parse_legal_refs(text: str) -> list[LegalRef]: + """Return module references in order of appearance. + + A ``juris/kind/...`` path whose kind is singular is a corpus citation + path. Its kind is mapped to the module root that holds encodings of that + kind of source. + """ + refs: list[LegalRef] = [] + spans: list[tuple[int, int]] = [] + for m in _LEGAL_ID.finditer(text): + rest, rule, _ = _split_rest(m.group("rest") + (f"#{m.group('rule')}" if m.group("rule") else "")) + if not rest: + continue + refs.append(LegalRef(m.group(0), m.group("juris"), m.group("kind"), rest, rule, "legal-id", m.start())) + spans.append(m.span()) + for m in _PATH_REF.finditer(text): + if any(a <= m.start() < b for a, b in spans): + continue + raw_rest = m.group("rest") + (f"#{m.group('rule')}" if m.group("rule") else "") + if ".test.yaml" in raw_rest: + continue + rest, rule, _ = _split_rest(raw_rest) + if not rest: + continue + kind = m.group("kind") + if kind in MODULE_KINDS: + # A module root (plural kind), with or without ".yaml", names the + # module itself. + form, module_kind = "module-path", kind + else: + form, module_kind = "corpus-path", CORPUS_KINDS[kind] + refs.append( + LegalRef(m.group(0).rstrip(".,;:)]}'\"`*"), m.group("juris"), module_kind, rest, rule, form, m.start()) + ) + spans.append(m.span()) + refs.sort(key=lambda r: r.start) + return _add_relative_refs(text, refs) + + +def _add_relative_refs(text: str, refs: list[LegalRef]) -> list[LegalRef]: + """Expand ``/22`` after a module reference into a sibling module reference.""" + if not refs: + return refs + out = list(refs) + for m in _RELATIVE.finditer(text): + base = None + for r in refs: + if r.start < m.start(): + base = r + if base is None or "/" not in base.rest: + continue + # Skip slashes that are part of a longer path already matched. + if any(r.start <= m.start() < r.start + len(r.raw) for r in refs): + continue + parent = base.rest.rsplit("/", 1)[0] + seg = _strip_trailing(m.group("seg")) + if seg.endswith(".yaml"): + seg = seg[: -len(".yaml")] + out.append(LegalRef(m.group(0), base.jurisdiction, base.kind, f"{parent}/{seg}", None, "relative", m.start())) + out.sort(key=lambda r: r.start) + return out + + +def parse_test_refs(text: str) -> list[TestRef]: + """Return test files and candidate case names cited in ``text``. + + Case names are only candidates: a backticked or quoted phrase, or a + snake_case token with at least three parts. The resolver keeps the ones + that exist in a companion test file. + """ + refs: list[TestRef] = [] + seen: set[tuple[str | None, str | None]] = set() + + def add(raw: str, path: str | None, case: str | None) -> None: + key = (path, case) + if key not in seen: + seen.add(key) + refs.append(TestRef(raw, path, case)) + + for m in _TEST_PATH.finditer(text): + add(m.group(0), m.group("path").lstrip("`"), m.group("case")) + for m in _BACKTICKED.finditer(text): + inner = m.group(1).strip() + if inner.endswith(".test.yaml") or "/" in inner or ":" in inner: + continue + if re.fullmatch(r"[A-Za-z0-9_ ,'()\-.]+", inner) and (" " in inner or "_" in inner): + add(m.group(0), None, inner) + for m in _QUOTED.finditer(text): + inner = m.group(1).strip() + if len(inner.split()) >= 3: + add(m.group(0), None, inner) + for m in _SNAKE.finditer(text): + add(m.group(0), None, m.group(1)) + return refs diff --git a/axiom-parity/src/axiom_parity/render.py b/axiom-parity/src/axiom_parity/render.py new file mode 100644 index 0000000..00567ca --- /dev/null +++ b/axiom-parity/src/axiom_parity/render.py @@ -0,0 +1,66 @@ +"""Render a check report as a job summary and workflow annotations.""" + +from __future__ import annotations + +from .check import ERROR, NOTICE, WARNING, Report + +GUIDE = "https://github.com/PolicyEngine/.github/blob/main/CONTRIBUTING.md#mirror-policy-changes-in-axiom" + + +def verdict(report: Report) -> str: + if not report.applies: + return "Not required: this PR changes no parameters, variables or reforms." + if report.would_fail and report.mode == "warn": + return "Would fail (warn-only rollout: this check does not block merging yet)." + if report.would_fail: + return "Fail." + return "Pass." + + +def markdown(report: Report) -> str: + out = ["## Axiom parity", "", f"**{verdict(report)}**", ""] + if report.policy_files: + n = len(report.policy_files) + shown = ", ".join(f"`{p}`" for p in report.policy_files[:8]) + more = f" and {n - 8} more" if n > 8 else "" + out += [f"Policy files changed ({n}): {shown}{more}.", ""] + for f in report.findings: + out.append(f"- **{f.level}** ({f.code}): {f.message}") + if report.findings: + out.append("") + if report.claims: + out += ["| Claim | Result | Evidence and problems |", "|---|---|---|"] + for c in report.claims: + text = c.claim.text.replace("|", "\\|") + if len(text) > 160: + text = text[:159] + "…" + if c.blocking: + result = "would fail" if report.mode == "warn" else "fails" + elif any(f.level == WARNING for f in c.findings): + result = "ok, with warnings" + else: + result = "ok" + notes = [e.replace("|", "\\|") for e in c.evidence] + notes += [f"**{f.level}**: {f.message}".replace("|", "\\|") for f in c.findings] + out.append(f"| `{c.claim.status}`: {text} | {result} | {'<br>'.join(notes) or '-'} |") + out.append("") + out.append(f"How to write the line: [CONTRIBUTING, Mirror policy changes in Axiom]({GUIDE}).") + return "\n".join(out) + "\n" + + +def _escape(msg: str) -> str: + return msg.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def annotations(report: Report) -> list[str]: + """GitHub workflow commands (``::error::...``) for each finding.""" + out = [] + level_cmd = {ERROR: "error", WARNING: "warning", NOTICE: "notice"} + for f in report.findings: + out.append(f"::{level_cmd[f.level]} title=Axiom parity ({f.code})::{_escape(f.message)}") + for c in report.claims: + for f in c.findings: + out.append( + f"::{level_cmd[f.level]} title=Axiom parity ({f.code})::{_escape(c.claim.status + ': ' + f.message)}" + ) + return out diff --git a/axiom-parity/src/axiom_parity/rulespec.py b/axiom-parity/src/axiom_parity/rulespec.py new file mode 100644 index 0000000..73ceef7 --- /dev/null +++ b/axiom-parity/src/axiom_parity/rulespec.py @@ -0,0 +1,301 @@ +"""Read RuleSpec modules and companion tests, from GitHub or a local checkout. + +Module resolution is deliberately forgiving about *how* a module is named +(legal id, module path, corpus citation path, or a subsection below the +module's own path) and strict about whether it exists. +""" + +from __future__ import annotations + +import subprocess +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Protocol + +from . import pe_yaml +from .github import GitHub, NotFound +from .refs import AXIOM_OWNER, LegalRef, TestRef, norm_dashes + +# PolicyEngine repo name suffix -> rulespec repo suffix. +COUNTRY_TO_RULESPEC = { + "uk": "uk", + "us": "us", + "canada": "ca", + "ca": "ca", + "il": "il", + "ng": "ng", + "nz": "nz", +} + + +def rulespec_repo_for_country(country: str) -> str: + suffix = COUNTRY_TO_RULESPEC.get(country, country) + return f"{AXIOM_OWNER}/rulespec-{suffix}" + + +def rulespec_repo_for_jurisdiction(jurisdiction: str, default: str) -> str: + """us and us-md live in rulespec-us; uk and uk-<council> in rulespec-uk.""" + country = jurisdiction.split("-", 1)[0] + if not country: + return default + return f"{AXIOM_OWNER}/rulespec-{country}" + + +class Source(Protocol): + repo: str + + def paths(self) -> set[str]: ... + + def read(self, path: str) -> str: ... + + +class GitHubSource: + """A rulespec repo read through the REST API at a fixed ref.""" + + def __init__(self, gh: GitHub, repo: str, ref: str = "main") -> None: + self.gh = gh + self.repo = repo + self.ref = ref + self._paths: set[str] | None = None + + def paths(self) -> set[str]: + if self._paths is None: + self._paths = set(self.gh.tree(self.repo, self.ref)) + return self._paths + + def read(self, path: str) -> str: + return self.gh.get_raw(self.repo, path, self.ref) + + +class LocalSource: + """A rulespec repo read from a local clone (``git show <ref>:path``).""" + + def __init__(self, root: str | Path, repo: str, ref: str = "HEAD") -> None: + self.root = Path(root) + self.repo = repo + self.ref = ref + self._paths: set[str] | None = None + + def paths(self) -> set[str]: + if self._paths is None: + out = subprocess.run( + ["git", "-C", str(self.root), "ls-tree", "-r", "--name-only", self.ref], + check=True, + capture_output=True, + text=True, + ).stdout + self._paths = set(out.splitlines()) + return self._paths + + def read(self, path: str) -> str: + res = subprocess.run( + ["git", "-C", str(self.root), "show", f"{self.ref}:{path}"], capture_output=True, text=True + ) + if res.returncode != 0: + raise NotFound(path) + return res.stdout + + +@dataclass +class ResolvedModule: + ref: LegalRef + path: str + match: str # "exact" or "ancestor" + rules: list[str] = field(default_factory=list) + legal_id: str = "" + + @property + def test_path(self) -> str: + return self.path[: -len(".yaml")] + ".test.yaml" + + +@dataclass +class ResolvedTest: + ref: TestRef + path: str + case: str | None + exercises: list[str] # legal ids of resolved modules the case/file outputs + + +def legal_id_for_path(path: str) -> str: + """``us/statutes/26/1/h.yaml`` -> ``us:statutes/26/1/h``.""" + stem = path[: -len(".yaml")] if path.endswith(".yaml") else path + juris, _, rest = stem.partition("/") + return f"{juris}:{rest}" + + +def candidate_paths(ref: LegalRef) -> list[tuple[str, str]]: + """Paths that could hold the module ``ref`` names, best first.""" + segs = ref.rest.split("/") + roots = [ref.kind] + out: list[tuple[str, str]] = [] + variants = [segs] + # Federal CFR modules live under us/regulations/<title>-cfr/... + if ref.jurisdiction == "us" and ref.kind == "regulations" and segs and segs[0].isdigit(): + variants.append([f"{segs[0]}-cfr", *segs[1:]]) + # A CFR section written as 273.9 is the path 273/9. + if ref.kind == "regulations" and len(segs) >= 2 and "." in segs[-1] and segs[-1].replace(".", "").isdigit(): + for v in list(variants): + part, sec = v[-1].split(".", 1) + variants.append([*v[:-1], part, sec]) + for root in roots: + for v in variants: + out.append((f"{ref.jurisdiction}/{root}/{'/'.join(v)}.yaml", "exact")) + for v in variants: + for i in range(len(v) - 1, 0, -1): + out.append((f"{ref.jurisdiction}/{root}/{'/'.join(v[:i])}.yaml", "ancestor")) + seen, uniq = set(), [] + for p, kind in out: + if p not in seen: + seen.add(p) + uniq.append((p, kind)) + return uniq + + +def load_yaml(text: str) -> Any: + try: + return pe_yaml.load(text) + except pe_yaml.YAMLError: + return None + + +def module_rules(doc: Any) -> list[str]: + if not isinstance(doc, dict): + return [] + return [r.get("name") for r in doc.get("rules") or [] if isinstance(r, dict) and r.get("name")] + + +def module_citations(doc: Any) -> set[str]: + """Every corpus_citation_path a module declares, at module or atom level.""" + out: set[str] = set() + + def walk(node: Any) -> None: + if isinstance(node, dict): + for k, v in node.items(): + if k == "corpus_citation_path" and isinstance(v, str): + out.add(v.strip()) + else: + walk(v) + elif isinstance(node, list): + for v in node: + walk(v) + + walk(doc) + return out + + +def test_cases(doc: Any) -> list[dict[str, Any]]: + if isinstance(doc, list): + return [c for c in doc if isinstance(c, dict)] + if isinstance(doc, dict) and isinstance(doc.get("cases"), list): + return [c for c in doc["cases"] if isinstance(c, dict)] + return [] + + +def _norm(name: str) -> str: + return " ".join(name.lower().replace("_", " ").split()) + + +class Resolver: + """Resolves module and test references against one or more rulespec repos. + + ``sources`` maps a repo slug to its Source, or is a function returning the + Source for a slug (None when the repo doesn't exist). + """ + + def __init__(self, sources: dict[str, Source] | Callable[[str], Source | None]) -> None: + self._factory = sources.get if isinstance(sources, dict) else sources + self._sources: dict[str, Source | None] = {} + self._docs: dict[tuple[str, str], Any] = {} + + def source(self, repo: str) -> Source | None: + if repo not in self._sources: + self._sources[repo] = self._factory(repo) + return self._sources[repo] + + def doc(self, repo: str, path: str) -> Any: + key = (repo, path) + if key not in self._docs: + src = self.source(repo) + try: + self._docs[key] = load_yaml(src.read(path)) if src else None + except NotFound: + self._docs[key] = None + return self._docs[key] + + def resolve_module(self, ref: LegalRef, repo: str) -> ResolvedModule | None: + src = self.source(repo) + if src is None: + return None + # Compare with any dash written as a hyphen: the module may be "1437c–1.yaml". + by_norm = {norm_dashes(p): p for p in src.paths()} + for path, match in candidate_paths(ref): + real = by_norm.get(norm_dashes(path)) + if real: + rules = module_rules(self.doc(repo, real)) + return ResolvedModule(ref, real, match, rules, legal_id_for_path(real)) + return None + + def resolve_tests( + self, refs: list[TestRef], modules: list[tuple[str, ResolvedModule]], default_repo: str + ) -> list[ResolvedTest]: + """Find cited test files and cases; record which modules each exercises.""" + out: list[ResolvedTest] = [] + repos = {repo for repo, _ in modules} or {default_repo} + module_ids = [m.legal_id for _, m in modules] + # Test files in scope: companions of resolved modules, plus cited files. + files: list[tuple[str, str]] = [] + for repo, m in modules: + if self.source(repo) and m.test_path in self.source(repo).paths(): + files.append((repo, m.test_path)) + for ref in refs: + if not ref.path: + continue + for repo in repos: + for path in self._find_test_file(ref.path, repo, modules): + if (repo, path) not in files: + files.append((repo, path)) + cases = test_cases(self.doc(repo, path)) + if ref.case: + cases = [c for c in cases if c.get("name") == ref.case] + ex = sorted({mid for c in cases for mid in _exercised(c, module_ids)}) + if not ref.case or cases: + out.append(ResolvedTest(ref, path, ref.case, ex)) + for ref in refs: + if ref.path or not ref.case: + continue + want = _norm(ref.case) + for repo, path in files: + for c in test_cases(self.doc(repo, path)): + name = str(c.get("name", "")) + if name == ref.case or _norm(name) == want: + out.append(ResolvedTest(ref, path, name, _exercised(c, module_ids))) + return out + + def _find_test_file(self, cited: str, repo: str, modules: list[tuple[str, ResolvedModule]]) -> list[str]: + src = self.source(repo) + if src is None: + return [] + # Compare with any dash as a hyphen, and return the repository's own spelling. + by_norm = {norm_dashes(p): p for p in src.paths()} + cited = norm_dashes(cited.strip("`")) + if cited.startswith(".../"): + tail = cited[3:] + hits = [real for p, real in by_norm.items() if p.endswith(tail)] + return hits if len(hits) == 1 else [] + if "/" in cited: + return [by_norm[cited]] if cited in by_norm else [] + # A bare file name: look beside each resolved module first. + near = [norm_dashes(f"{m.path.rsplit('/', 1)[0]}/{cited}") for r, m in modules if r == repo] + hits = list(dict.fromkeys(by_norm[p] for p in near if p in by_norm)) + if hits: + return hits # every cited module's own file: the case may be in any of them + hits = [real for p, real in by_norm.items() if p.endswith("/" + cited)] + return hits if len(hits) == 1 else [] + + +def _exercised(case: dict[str, Any], module_ids: list[str]) -> list[str]: + outputs = case.get("output") or case.get("outputs") or {} + keys = list(outputs) if isinstance(outputs, dict) else [] + return [mid for mid in module_ids if any(str(k).startswith(mid + "#") or str(k) == mid for k in keys)] diff --git a/axiom-parity/tests/conftest.py b/axiom-parity/tests/conftest.py new file mode 100644 index 0000000..a223aa2 --- /dev/null +++ b/axiom-parity/tests/conftest.py @@ -0,0 +1,160 @@ +"""An in-memory GitHub for the check's tests.""" + +from __future__ import annotations + +import json +import urllib.parse +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from axiom_parity.github import GitHub + +RS_US = "TheAxiomFoundation/rulespec-us" +RS_UK = "TheAxiomFoundation/rulespec-uk" + +READY_ISSUE_BODY = """## Encoding debt + +Module path: `us/statutes/26/32/d.yaml` (corpus citation `us/statute/26/32/d`). + +## Law (verbatim) + +> (2) Determination of marital status For purposes of this section— (A) In general Except as provided in +> subparagraph (B), marital status shall be determined under section 7703(a). + +## Required outputs + +1. `eitc_separated_spouse_not_treated_as_married`, a Judgment. + +## review_finding (paste as-is) + +> Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml. The separated-spouse rule must be +> stated from its statutory elements. + +## Companion tests + +- separated spouse with a qualifying child, apart for the last six months: holds (statute text) +""" + +MODULE_D = """format: rulespec/v1 +module: + source_verification: + corpus_citation_path: us/statute/26/24/d +rules: + - name: ctc_social_security_tax + kind: derived + - name: ctc_refundable_foreign_income_eligible + kind: derived +""" + +TEST_D = """- name: three_children_social_security_excess + input: + us:statutes/26/24/d#input.wages: 1 + output: + us:statutes/26/24/d#ctc_social_security_tax: 100 +- name: unrelated_case + input: {} + output: + us:statutes/26/1/h#net_capital_gain: 0 +""" + + +@dataclass +class FakeHub: + repos: set[str] = field(default_factory=lambda: {RS_US, RS_UK}) + issues: dict[tuple[str, int], dict[str, Any]] = field(default_factory=dict) + comments: dict[tuple[str, int], list[dict[str, Any]]] = field(default_factory=dict) + pulls: dict[tuple[str, int], dict[str, Any]] = field(default_factory=dict) + pull_files: dict[tuple[str, int], list[str]] = field(default_factory=dict) + files: dict[tuple[str, str], str] = field(default_factory=dict) # (repo, path) -> text at main + calls: list[str] = field(default_factory=list) + + def add_issue( + self, + repo: str, + n: int, + body: str = READY_ISSUE_BODY, + labels=("pe-parity",), + state="open", + state_reason=None, + comments=(), + ) -> None: + self.issues[(repo, n)] = { + "number": n, + "title": f"issue {n}", + "body": body, + "labels": [{"name": lab} for lab in labels], + "state": state, + "state_reason": state_reason, + "comments": len(comments), + "html_url": f"https://github.com/{repo}/issues/{n}", + "created_at": "2026-10-01T00:00:00Z", + } + self.comments[(repo, n)] = [{"id": i + 1, "body": c} for i, c in enumerate(comments)] + + def add_pull( + self, repo: str, n: int, state="open", merged=False, files=(".axiom/encoding-manifests/x.json",) + ) -> None: + self.issues[(repo, n)] = { + "number": n, + "title": f"pr {n}", + "body": "", + "labels": [], + "state": state, + "comments": 0, + "pull_request": {}, + "html_url": f"https://github.com/{repo}/pull/{n}", + } + self.pulls[(repo, n)] = {"number": n, "merged": merged, "draft": False, "state": state} + self.pull_files[(repo, n)] = list(files) + + def add_file(self, repo: str, path: str, text: str) -> None: + self.files[(repo, path)] = text + + def transport(self, url: str, headers: dict[str, str]): + parsed = urllib.parse.urlparse(url) + path = urllib.parse.unquote(parsed.path) + query = urllib.parse.parse_qs(parsed.query) + self.calls.append(path) + parts = path.strip("/").split("/") + if parts[0] != "repos" or len(parts) < 3: + return 404, {}, b"" + repo = f"{parts[1]}/{parts[2]}" + rest = parts[3:] + if not rest: + return (200, {}, json.dumps({"full_name": repo}).encode()) if repo in self.repos else (404, {}, b"") + if rest[0] == "issues" and len(rest) == 2: + item = self.issues.get((repo, int(rest[1]))) + return (200, {}, json.dumps(item).encode()) if item else (404, {}, b"") + if rest[0] == "issues" and len(rest) == 3 and rest[2] == "comments": + page = int(query.get("page", ["1"])[0]) + data = self.comments.get((repo, int(rest[1])), []) if page == 1 else [] + return 200, {}, json.dumps(data).encode() + if rest[0] == "pulls" and len(rest) == 2: + item = self.pulls.get((repo, int(rest[1]))) + return (200, {}, json.dumps(item).encode()) if item else (404, {}, b"") + if rest[0] == "pulls" and len(rest) == 3 and rest[2] == "files": + page = int(query.get("page", ["1"])[0]) + data = [{"filename": f} for f in self.pull_files.get((repo, int(rest[1])), [])] if page == 1 else [] + return 200, {}, json.dumps(data).encode() + if rest[:2] == ["git", "trees"]: + tree = [{"path": p, "type": "blob"} for (r, p) in self.files if r == repo] + return 200, {}, json.dumps({"tree": tree}).encode() + if rest[0] == "contents": + text = self.files.get((repo, "/".join(rest[1:]))) + return (200, {}, text.encode()) if text is not None else (404, {}, b"") + return 404, {}, b"" + + def client(self) -> GitHub: + return GitHub(token="test", transport=self.transport, retries=0) + + +@pytest.fixture +def hub() -> FakeHub: + h = FakeHub() + h.add_file(RS_US, "us/statutes/26/24/d.yaml", MODULE_D) + h.add_file(RS_US, "us/statutes/26/24/d.test.yaml", TEST_D) + h.add_file(RS_US, "us/statutes/26/1/h.yaml", "rules:\n - name: net_capital_gain\n") + h.add_issue(RS_US, 1416) + return h diff --git a/axiom-parity/tests/fixtures/url_vectors.json b/axiom-parity/tests/fixtures/url_vectors.json new file mode 100644 index 0000000..00f3300 --- /dev/null +++ b/axiom-parity/tests/fixtures/url_vectors.json @@ -0,0 +1,2564 @@ +[ + { + "url": "https://www.legislation.gov.uk/ukpga/2002/16/section/1", + "expected_citation_path": "uk/statute/ukpga/2002/16/1", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:16 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2002/16/section/2", + "expected_citation_path": "uk/statute/ukpga/2002/16/2", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:18 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/1992/4/section/141", + "expected_citation_path": "uk/statute/ukpga/1992/4/141", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-05-uk-child-benefit-sscba.jsonl:1 (git blob 231d1afe0fe77e3ca27059b7eec847b119024a85; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-05-uk-child-benefit-sscba.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/1992/4/section/11", + "expected_citation_path": "uk/statute/ukpga/1992/4/11", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:6 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2007/3/section/35", + "expected_citation_path": "uk/statute/ukpga/2007/3/35", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-05-29-uk-pe-parity.jsonl:3 (git blob 61a3f621305398ca09636e3b06948803165f7863; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-05-29-uk-pe-parity.json)." + }, + { + "url": "https://www.legislation.gov.uk/id/ukpga/2007/3/section/10", + "expected_citation_path": "uk/statute/ukpga/2007/3/10", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:44 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2012/5/section/4", + "expected_citation_path": "uk/statute/ukpga/2012/5/4", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-03-uk-universal-credit.jsonl:34 (git blob 631aab9de2edee42887c85cd21bbb850eddb3b51; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-03-uk-universal-credit.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2014/19/section/3", + "expected_citation_path": "uk/statute/ukpga/2014/19/3", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-07-10-uk-rulespec-source-promotion.jsonl:71 (git blob 94678e3a6d244b1868692f19160f7131a467426a; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.legislation.gov.uk/asp/2013/11/section/24", + "expected_citation_path": "uk/statute/asp/2013/11/24", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-07-07-uk-lbtt-scotland.jsonl:1 (git blob 09f1c479922efaa096f104cce8316894fb4299cc; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-07-07-uk-lbtt-scotland.json)." + }, + { + "url": "https://www.legislation.gov.uk/id/asp/2014/8/section/48", + "expected_citation_path": "uk/statute/asp/2014/8/48", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:1 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/id/asp/2014/8/section/49", + "expected_citation_path": "uk/statute/asp/2014/8/49", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:2 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/anaw/2017/1/section/24", + "expected_citation_path": "uk/statute/anaw/2017/1/24", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/statute/2026-07-07-uk-ltt-wales.jsonl:1 (git blob c0c1e746b55edb84afccb6fba40e523c3f5e44ef; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-07-07-uk-ltt-wales.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2013/376/regulation/62", + "expected_citation_path": "uk/regulation/uksi/2013/376/62", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-06-01-uk-frs-microsim.jsonl:915 (git blob 0ea04a9bbc3ec6ac8c71d4739e6bcb721e53760b; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2013/376/regulation/89", + "expected_citation_path": "uk/regulation/uksi/2013/376/89", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-06-01-uk-frs-microsim.jsonl:945 (git blob 0ea04a9bbc3ec6ac8c71d4739e6bcb721e53760b; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/id/uksi/2008/794/regulation/67", + "expected_citation_path": "uk/regulation/uksi/2008/794/67", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-06-01-uk-frs-microsim.jsonl:783 (git blob 0ea04a9bbc3ec6ac8c71d4739e6bcb721e53760b; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ssi/2020/352/regulation/4", + "expected_citation_path": "uk/regulation/ssi/2020/352/4", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-07-uk-devolved-reserved-benefits.jsonl:2 (git blob 2f4fdb0011fa7d3b35fa976ba647e7f980d32346; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-07-uk-devolved-reserved-benefits.json)." + }, + { + "url": "https://www.legislation.gov.uk/ssi/2023/302/regulation/5", + "expected_citation_path": "uk/regulation/ssi/2023/302/5", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-10-uk-rulespec-source-promotion.jsonl:7 (git blob fe734d6d5324c846118092f19268d6c885bd3680; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.legislation.gov.uk/ssi/2012/319/regulation/14", + "expected_citation_path": "uk/regulation/ssi/2012/319/14", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-13-uk-ctr-scp-award-r2026-07-20-datefix.jsonl:1 (git blob 188ad59da535c4df0dcf1795456383b5d035142a; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-13-uk-ctr-scp-award-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/wsi/2013/3029/regulation/22", + "expected_citation_path": "uk/regulation/wsi/2013/3029/22", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-13-uk-ctr-wa-classes-r2026-07-20-datefix.jsonl:1 (git blob 9875cb8b1bc09a3904ee739b8c8e67c15f716f84; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-13-uk-ctr-wa-classes-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/wsi/2013/3029/regulation/23", + "expected_citation_path": "uk/regulation/wsi/2013/3029/23", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-13-uk-ctr-wa-classes-r2026-07-20-datefix.jsonl:2 (git blob 9875cb8b1bc09a3904ee739b8c8e67c15f716f84; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-13-uk-ctr-wa-classes-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/wsi/2013/3029/regulation/24", + "expected_citation_path": "uk/regulation/wsi/2013/3029/24", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-13-uk-ctr-wa-classes-r2026-07-20-datefix.jsonl:3 (git blob 9875cb8b1bc09a3904ee739b8c8e67c15f716f84; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-13-uk-ctr-wa-classes-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/7", + "expected_citation_path": "uk/regulation/uksi/2013/376/schedule/4/paragraph/7", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-06-03-uk-universal-credit.jsonl:43 (git blob 24d53fbe55b7773ab64fde3b71ba00aeaa9e773a; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-03-uk-universal-credit.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/10", + "expected_citation_path": "uk/regulation/uksi/2013/376/schedule/4/paragraph/10", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-06-03-uk-universal-credit.jsonl:28 (git blob 24d53fbe55b7773ab64fde3b71ba00aeaa9e773a; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-03-uk-universal-credit.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2/part/1/paragraph/1", + "expected_citation_path": "uk/regulation/uksi/2012/2885/schedule/2/part/1/paragraph/1", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.jsonl:1 (git blob 4484b3066717fb8b1debc625638a4b77b98cc099; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2/part/2/paragraph/3", + "expected_citation_path": "uk/regulation/uksi/2012/2885/schedule/2/part/2/paragraph/3", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.jsonl:3 (git blob 4484b3066717fb8b1debc625638a4b77b98cc099; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2/part/4", + "expected_citation_path": "uk/regulation/uksi/2012/2885/schedule/2/part/4", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.jsonl:4 (git blob 4484b3066717fb8b1debc625638a4b77b98cc099; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/16", + "expected_citation_path": "uk/regulation/uksi/2012/2886/schedule/paragraph/16", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-14-uk-ctr-end-award-r2026-07-20-datefix.jsonl:2 (git blob ac824252bded2c75992993b36a638899862277b3; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-14-uk-ctr-end-award-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2012/2886/appendix/3/paragraph/1", + "expected_citation_path": "uk/regulation/uksi/2012/2886/appendix/3/paragraph/1", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-14-uk-ctr-end-sch3-r2026-07-20-datefix.jsonl:1 (git blob d4f157e8397bf7bbc7d205864e091be540d114bf; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-14-uk-ctr-end-sch3-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2026/148/article/14/made", + "expected_citation_path": "uk/regulation/uksi/2026/148/article/14", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-06-06-uk-uksi-2026-148-article14.jsonl:1 (git blob bc0a07062e80987786ddcfcf9be84ae3936b7fe1; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-06-uk-uksi-2026-148-article14.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2026/148/article/25/made", + "expected_citation_path": "uk/regulation/uksi/2026/148/article/25", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-07-uk-devolved-reserved-benefits.jsonl:5 (git blob 2f4fdb0011fa7d3b35fa976ba647e7f980d32346; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-07-uk-devolved-reserved-benefits.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2026/148/article/4/made", + "expected_citation_path": "uk/regulation/uksi/2026/148/article/4", + "source": "verified-in-corpus", + "note": "Normalized UK provision form; section/regulation keyword omitted, article retained; uk_legislation.py:232-259. Verified data/corpus/provisions/uk/regulation/2026-07-10-uk-rulespec-source-promotion.jsonl:56 (git blob fe734d6d5324c846118092f19268d6c885bd3680; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/1992/4/section/11/2024-04-06", + "expected_citation_path": "uk/statute/ukpga/1992/4/11", + "source": "verified-in-corpus", + "note": "Dated expression suffix is not citation identity; parsers/clml.py:318-358. Preserve requested date separately for version selection. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:6 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2002/16/section/1/enacted", + "expected_citation_path": "uk/statute/ukpga/2002/16/1", + "source": "verified-in-corpus", + "note": "Enacted suffix changes expression, not provision identity. Current corpus presence does not verify enacted-text equivalence. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:16 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2013/376/regulation/62/made", + "expected_citation_path": "uk/regulation/uksi/2013/376/62", + "source": "verified-in-corpus", + "note": "Made suffix changes expression, not citation identity. Preserve expression state separately. Verified data/corpus/provisions/uk/regulation/2026-06-01-uk-frs-microsim.jsonl:915 (git blob 0ea04a9bbc3ec6ac8c71d4739e6bcb721e53760b; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2002/16/section/1/2", + "expected_citation_path": "uk/statute/ukpga/2002/16/1", + "source": "verified-in-corpus", + "note": "UK corpus builder retains section-level record; subsection 2 is not emitted as another citation segment (uk_legislation.py:232-259). Preserve selector 2 separately. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:16 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2004/12/section/190/2", + "expected_citation_path": "uk/statute/ukpga/2004/12/190", + "source": "verified-in-corpus", + "note": "User-requested section/190/2 form; UK subsection selector coarsens to the stored section 190 record. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:30 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2013/376/regulation/62#regulation-62-2-a", + "expected_citation_path": "uk/regulation/uksi/2013/376/62", + "source": "verified-in-corpus", + "note": "UK subsection fragment selects text within the section/regulation record; store selector alongside coarse corpus identity. Verified data/corpus/provisions/uk/regulation/2026-06-01-uk-frs-microsim.jsonl:915 (git blob 0ea04a9bbc3ec6ac8c71d4739e6bcb721e53760b; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/7#schedule-4-paragraph-7-1", + "expected_citation_path": "uk/regulation/uksi/2013/376/schedule/4/paragraph/7", + "source": "verified-in-corpus", + "note": "Sub-paragraph fragment does not create another emitted UK provision record. Verified data/corpus/provisions/uk/regulation/2026-06-03-uk-universal-credit.jsonl:43 (git blob 24d53fbe55b7773ab64fde3b71ba00aeaa9e773a; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-06-03-uk-universal-credit.json)." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2/part/1/paragraph/1/2024-04-06", + "expected_citation_path": "uk/regulation/uksi/2012/2885/schedule/2/part/1/paragraph/1", + "source": "verified-in-corpus", + "note": "Part-qualified schedule path is retained; date suffix ignored for identity (parsers/clml.py:318-338). Verified data/corpus/provisions/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.jsonl:1 (git blob 4484b3066717fb8b1debc625638a4b77b98cc099; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-14-uk-ctr-enp-applicable-amounts-r2026-07-20-datefix.json)." + }, + { + "url": "https://www.legislation.gov.uk/nia/2002/14/section/2", + "expected_citation_path": "uk/statute/nia/2002/14/2", + "source": "rule", + "note": "Actual PE reference; rule-only classification as statute. No nia record found in all pinned UK provision blobs." + }, + { + "url": "https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made", + "expected_citation_path": "uk/regulation/nisr/2020/53/4", + "source": "rule", + "note": "Actual PE reference; rule-only: nisr is in UK_REGULATION_TYPES (models_uk.py:38-45). No nisr record found in pinned UK provision blobs." + }, + { + "url": "https://www.legislation.gov.uk/nisr/2006/405/regulation/1/made", + "expected_citation_path": "uk/regulation/nisr/2006/405/1", + "source": "rule", + "note": "Actual PE reference; rule-only secondary-legislation form; current corpus has no nisr provision example." + }, + { + "url": "https://www.legislation.gov.uk/asc/2021/4/pdfs/asc_20210004_en.pdf", + "expected_citation_path": "uk/statute/asc/2021/4", + "source": "rule", + "note": "Rule-only primary-legislation instrument PDF; real official source Curriculum and Assessment (Wales) Act 2021 at this URL, verified by legislation.gov.uk search on 2026-10-10. asc/2021/4 also occurs in tests/test_uk_models.py:86. No asc record in pinned UK provision blobs. Broad instrument path, not a page-level citation." + }, + { + "url": "https://www.legislation.gov.uk/ukla/2007/2/section/21/enacted", + "expected_citation_path": "uk/statute/ukla/2007/2/21", + "source": "rule", + "note": "Rule-only UK Local Act section; real official legislation.gov.uk source London Local Authorities Act 2007 section 21, verified by legislation.gov.uk search on 2026-10-10. models_uk.py:30 and corpus/uk_legislation.py:225-259 classify ukla as statute. No ukla record in pinned UK provision blobs." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2002/1792/schedule/IIA", + "expected_citation_path": "uk/regulation/uksi/2002/1792/schedule/IIA", + "source": "verified-in-corpus", + "note": "Actual PE reference with Roman schedule label. Direct promoted corpus alias is verified; current numeric schedule regex does not generically support Roman labels. Verified data/corpus/provisions/uk/regulation/2026-07-10-uk-rulespec-source-promotion.jsonl:13 (git blob fe734d6d5324c846118092f19268d6c885bd3680; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.legislation.gov.uk/wsi/2018/128/schedule/2026-06-30", + "expected_citation_path": "uk/regulation/wsi/2018/128/schedule/2026", + "source": "verified-in-corpus", + "note": "Verified direct source_url alias only: known corpus parser anomaly consumes the date year as schedule number. Preserve this compatibility alias with an anomaly flag; do not generalize it into a date-to-schedule rule. Verified data/corpus/provisions/uk/regulation/2026-07-07-uk-ltt-rates.jsonl:1 (git blob 2bfd93f651cb61d9f25a19a82999a4174a5f09fe; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-07-uk-ltt-rates.json)." + }, + { + "url": "https://www.legislation.gov.uk/ssi/2015/126/schedule", + "expected_citation_path": "uk/regulation/ssi/2015/126", + "source": "verified-in-corpus", + "note": "Verified direct source_url alias for a legacy bare schedule record. Modern uk_citation_path would retain /schedule; do not generalize this legacy root alias. Verified data/corpus/provisions/uk/regulation/2026-07-07-uk-lbtt-rates.jsonl:1 (git blob ab26db72c37874852d341e5569dba67217d37ef9; SHA256 checked against .axiom/corpus-locks/uk/regulation/2026-07-07-uk-lbtt-rates.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2002/16/section/1?view=plain", + "expected_citation_path": "uk/statute/ukpga/2002/16/1", + "source": "verified-in-corpus", + "note": "Known presentation query view=plain does not change provision identity. Discard only allowlisted presentation query keys. Verified data/corpus/provisions/uk/statute/2026-06-01-uk-frs-microsim.jsonl:16 (git blob 29dd7d766d4887c76c0d483a5cc8c3495ff80d4f; SHA256 checked against .axiom/corpus-locks/uk/statute/2026-06-01-uk-frs-microsim.json)." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2002/21/contents", + "expected_citation_path": "uk/statute/ukpga/2002/21", + "source": "rule", + "note": "Contents selects an entire instrument. Rule-only broad instrument path; normalized corpus may hold descendants without an instrument root record. Retrieve descendant candidates, preserving broad scope." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2006/213/data.pdf#page=212", + "expected_citation_path": "uk/regulation/uksi/2006/213", + "source": "rule", + "note": "Official instrument PDF selects the whole instrument; rule-only broad instrument citation, not invented page 212 provision identity. Descendant modules remain candidates; retain PDF page as selector." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2018/146/pdfs/uksi_20180146_en.pdf#:~:text=Coming%20into%20force%20,into%20force%20on%201st%20April", + "expected_citation_path": "uk/regulation/uksi/2018/146", + "source": "rule", + "note": "Actual PE /pdfs/ instrument-text form: file name uksi_ identifies the actual instrument, giving a broad instrument citation. Text fragment is a selector. No corresponding corpus root record found." + }, + { + "url": "https://www.gov.uk/repaying-your-student-loan", + "expected_citation_path": "uk/guidance/govuk/student-loan-repayments", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-06-05-uk-student-loan-repayments.jsonl:1 (git blob 7b55fb48995eadcf0181be257a6986bdc25b98b7; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-06-05-uk-student-loan-repayments.json)." + }, + { + "url": "https://www.gov.uk/repaying-your-student-loan/what-you-pay", + "expected_citation_path": "uk/guidance/govuk/student-loan-repayments/what-you-pay", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-06-05-uk-student-loan-repayments.jsonl:2 (git blob 7b55fb48995eadcf0181be257a6986bdc25b98b7; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-06-05-uk-student-loan-repayments.json)." + }, + { + "url": "https://www.gov.uk/repaying-your-student-loan/getting-a-refund", + "expected_citation_path": "uk/guidance/govuk/student-loan-repayments/getting-a-refund", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-06-05-uk-student-loan-repayments.jsonl:3 (git blob 7b55fb48995eadcf0181be257a6986bdc25b98b7; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-06-05-uk-student-loan-repayments.json)." + }, + { + "url": "https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions", + "expected_citation_path": "uk/guidance/govuk/national-insurance-rates/rates-and-allowances", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-05-uk-national-insurance-rates.jsonl:1 (git blob a6decc474c5a0121ce3b5e846350e9b3a27172d7; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-05-uk-national-insurance-rates.json)." + }, + { + "url": "https://www.gov.uk/carers-allowance/eligibility", + "expected_citation_path": "uk/guidance/govuk/carers-allowance/eligibility", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:1 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.gov.uk/disability-living-allowance-children", + "expected_citation_path": "uk/guidance/govuk/disability-living-allowance-children", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:2 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.gov.uk/disability-living-allowance-children/rates", + "expected_citation_path": "uk/guidance/govuk/disability-living-allowance-children/rates", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:3 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.gov.uk/new-state-pension/your-national-insurance-record-and-your-state-pension", + "expected_citation_path": "uk/guidance/govuk/new-state-pension/eligibility", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:6 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.gov.uk/new-state-pension/what-youll-get", + "expected_citation_path": "uk/guidance/govuk/new-state-pension/what-youll-get", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:7 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.gov.uk/pension-credit/eligibility", + "expected_citation_path": "uk/guidance/govuk/pension-credit/eligibility", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:8 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.gov.uk/pension-credit/what-youll-get", + "expected_citation_path": "uk/guidance/govuk/pension-credit/what-youll-get", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:9 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.mygov.scot/carer-support-payment/if-you-work", + "expected_citation_path": "uk/guidance/mygovscot/carer-support-payment/if-you-work", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-10-uk-rulespec-source-promotion.jsonl:13 (git blob 12e5e850501376727ad7fd1130c88db5af46159c; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-10-uk-rulespec-source-promotion.json)." + }, + { + "url": "https://www.gov.scot/publications/scottish-rate-resolution-2026-2027-draft-motion-explanatory-note/pages/2/", + "expected_citation_path": "uk/guidance/scottish-government/scottish-rate-resolution-2026-27/draft-motion", + "source": "verified-in-corpus", + "note": "Use curated source_url-to-citation reverse lookup. URL slug is not a universal citation-path rule (documents.py:3810-3824). Choose document root before block-N descendants. Verified data/corpus/provisions/uk/guidance/2026-07-05-uk-scottish-income-tax.jsonl:1 (git blob 5b288ad034d7bfc16975e7110f13d1d120237a31; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-07-05-uk-scottish-income-tax.json)." + }, + { + "url": "https://www.gov.uk/repaying-your-student-loan/what-you-pay#plan-1", + "expected_citation_path": "uk/guidance/govuk/student-loan-repayments/what-you-pay", + "source": "verified-in-corpus", + "note": "Presentation fragment remains a selector; root citation uses verified source_url alias, not fabricated /plan-1 path. Verified data/corpus/provisions/uk/guidance/2026-06-05-uk-student-loan-repayments.jsonl:2 (git blob 7b55fb48995eadcf0181be257a6986bdc25b98b7; SHA256 checked against .axiom/corpus-locks/uk/guidance/2026-06-05-uk-student-loan-repayments.json)." + }, + { + "url": "https://assets.publishing.service.gov.uk/media/67e3ec2df356a2dc0e39b488/E03274109_HMT_Spring_Statement_Mar_25_Web_Accessible_.pdf#page=48", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/dfe/education_spending.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain assets.publishing.service.gov.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://assets.publishing.service.gov.uk/media/5ab270baed915d4f2d097124/Government_response_FSM_and_EY_entitlements_under_Universal_Credit.pdf#page=5", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/income_limit/universal_credit.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain assets.publishing.service.gov.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/boe/base_rate.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain obr.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://obr.uk/efo/economic-and-fiscal-outlook-march-2025/", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/dft/rail/fare_index.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain obr.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://researchbriefings.files.parliament.uk/documents/CBP-8054/CBP-8054.pdf#page=43", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain researchbriefings.files.parliament.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://researchbriefings.files.parliament.uk/documents/CBP-9439/CBP-9439.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/dwp/attendance_allowance/higher.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain researchbriefings.files.parliament.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.iser.essex.ac.uk/files/projects/UKMOD/EUROMOD_country_report.pdf#page=38", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/dwp/ESA/income/amount_18_24.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain iser.essex.ac.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.iser.essex.ac.uk/files/projects/UKMOD/EUROMOD_country_report.pdf#page=24", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/dwp/JSA/contrib/amount_18_24.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain iser.essex.ac.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.ons.gov.uk/employmentandlabourmarket/peopleinwork/earningsandworkinghours/timeseries/kac3/lms", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/economic_assumptions/statutory_uprating_inputs/awe_total_pay_may_july.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain ons.gov.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.ons.gov.uk/economy/inflationandpriceindices/articles/revisionsandcorrectionoferrorspoliciesforconsumerpriceinflationstatistics/december2025", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/economic_assumptions/statutory_uprating_inputs/cpi_september.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain ons.gov.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.ofgem.gov.uk/publications/ofgem-annual-report-and-accounts-2021-22", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/ofgem/energy_price_cap.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain ofgem.gov.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.ofgem.gov.uk/sites/default/files/2023-07/Ofgem%20ARA%202023.pdf#page=29", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/ofgem/energy_price_cap.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain ofgem.gov.uk; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.gov.scot/publications/scottish-budget-2026-2027/pages/7/", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/contrib/scotland/scottish_child_payment/in_effect.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain gov.scot; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.gov.scot/publications/non-domestic-rates-income-statistics/", + "expected_citation_path": null, + "source": "rule", + "note": "Actual PE reference (policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml); unsupported citation form and no normalized source_url alias in pinned corpus provision index. Domain gov.scot; null means this conservative mapper abstains, not a claim that future corpus ingestion is impossible." + }, + { + "url": "https://www.gov.uk/government/news/bbc-and-governments-joint-statement-on-delaying-the-tv-licence-fee-for-over-75s", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://github.com/PolicyEngine/microcosm/pull/954", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://www.legislation.gov.uk/uksi/2025/162/pdfs/uksiem_20250162_en_001.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2021/32/pdfs/ukpgaen_20210032_en.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2003/14/part/4", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/2/crossheading/personal-allowances", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative abstention: news/PR, publication landing page, explanatory memorandum/notes, or top-level part/chapter/crossheading has no verified normalized provision alias. Explanatory material is not the instrument text; top-level part/chapter hierarchy is unsupported by the normalized UK builder. A planned host-qualified manifest entry alone is not evidence of normalized provision identity." + }, + { + "url": "https://www.law.cornell.edu/cfr/text/26/1.36B-1#g", + "expected_citation_path": "us/regulation/26/1/36B-1", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359; paragraph fragment retained as locator only: builder emits section records. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-07-24-1401-coordination-repair-title-26-part-1-r2026-09-13-closure-sections-consolidated.jsonl contains us/regulation/26/1/36B-1 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.law.cornell.edu/cfr/text/45/155.20", + "expected_citation_path": "us/regulation/45/155/20", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359; paragraph fragment retained as locator only: builder emits section records. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-10-06-w6-federal-ecfr-closure-title-45-part-155.jsonl contains us/regulation/45/155/20 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.law.cornell.edu/uscode/text/26/36B#c_1", + "expected_citation_path": "us/statute/26/36B/c/1", + "source": "verified-in-corpus", + "note": "USC corpus/usc.py:125,142,158-163,188; legal labels become slash segments. Nonlegal notes/tab fragments do not identify provisions. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/36B/c/1 (paragraph); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://law.justia.com/codes/alabama/title-40/chapter-18/article-1/section-40-18-14-2/", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://law.justia.com/codes/alabama/title-40/chapter-18/article-1/section-40-18-14/", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://law.justia.com/codes/alabama/title-40/chapter-18/article-1/section-40-18-21/", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.michigan.gov/sos/-/media/Project/Websites/sos/BSC-Announcements/Invest-in-MI-Kids-Petition.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.michigan.gov/mdhhs/-/media/Project/Websites/mdhhs/Inside-MDHHS/Reports-and-Statistics---Human-Services/State-Plans-and-Federal-Regulations/TANF_State_Plan_2023.pdf", + "expected_citation_path": "us-mi/policy/mdhhs/tanf/state-plans-and-federal-regulations-a", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-mi/policy/2026-10-06-w6-tanf-ccdf-docs-mi.jsonl contains us-mi/policy/mdhhs/tanf/state-plans-and-federal-regulations-a (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.michigan.gov/-/media/Project/Websites/mdhhs/Folder1/Folder33/TANF_State_Plan_2017.pdf#page=7", + "expected_citation_path": "us-mi/policy/mdhhs/tanf/tanf-state-plan-2017", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-mi/policy/2026-10-06-w6-tanf-ccdf-docs-mi.jsonl contains us-mi/policy/mdhhs/tanf/tanf-state-plan-2017 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.tax.ny.gov/pdf/2022/printable-pdfs/inc/it201i-2022.pdf#page=25", + "expected_citation_path": "us-ny/form/tax/ty2022/it-201-i", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ny/form/2026-10-06-w6-income-tax-forms-ny-r2.jsonl contains us-ny/form/tax/ty2022/it-201-i (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.tax.ny.gov/pdf/2023/printable-pdfs/inc/it201i-2023.pdf#page=27", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.tax.ny.gov/pit/file/vita.htm", + "expected_citation_path": "us-ny/guidance/tax/vita-and-tce", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ny/guidance/2026-10-06-w6-income-tax-guidance-ny.jsonl contains us-ny/guidance/tax/vita-and-tce (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.revenue.state.mn.us/sites/default/files/2023-12/m1m-23.pdf#page=3", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.revenue.state.mn.us/sites/default/files/2024-01/m1m-22.pdf#page=3", + "expected_citation_path": "us-mn/form/department-of-revenue/ty2022/schedule-m1m", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-mn/form/2026-10-06-w6-income-tax-forms-mn.jsonl contains us-mn/form/department-of-revenue/ty2022/schedule-m1m (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.revenue.state.mn.us/sites/default/files/2024-12/m1m-24.pdf#page=3", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://revenue.iowa.gov/sites/default/files/2022-01/IA1040%2841-001%29.pdf#page=1", + "expected_citation_path": "us-ia/form/idr/ty2021/ia-1040", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ia/form/2026-10-06-w6-income-tax-forms-ia.jsonl contains us-ia/form/idr/ty2021/ia-1040 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://revenue.iowa.gov/sites/default/files/2023-01/2021%20Expanded%20Instructions_010323.pdf#page=54", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://revenue.iowa.gov/sites/default/files/2023-01/2022IA1040%2841001%29.pdf#page=1", + "expected_citation_path": "us-ia/form/idr/ty2022/ia-1040", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ia/form/2026-10-06-w6-income-tax-forms-ia.jsonl contains us-ia/form/idr/ty2022/ia-1040 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/pub/irs-soi/21in11si.xls", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.irs.gov/statistics/soi-tax-stats-individual-statistical-tables-by-size-of-adjusted-gross-income", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.irs.gov/pub/irs-drop/rp-25-25.pdf", + "expected_citation_path": "us/guidance/irs/rev-proc-2025-25", + "source": "verified-in-corpus", + "note": "Current document ingestion uses manifest-selected full-year IDs via corpus/documents.py:3810-3817; legacy models_guidance.py:81 rp-YY-NN is not current provision identity. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-06-01-irs-rev-proc-2025-25-irs-rev-proc-2025-25.jsonl contains us/guidance/irs/rev-proc-2025-25 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.maine.gov/dhhs/sites/maine.gov.dhhs/files/inline-files/CCAP%20Full%20Rule%208.18.2025_1.pdf#page=7", + "expected_citation_path": "us-me/regulation/dhhs/ocfs/10-148-cmr-chapter-6", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-me/regulation/2026-09-15-ccdf-subsidy-rules.jsonl contains us-me/regulation/dhhs/ocfs/10-148-cmr-chapter-6 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.maine.gov/dhhs/sites/maine.gov.dhhs/files/inline-files/CCAP%20Full%20Rule%208.18.2025_1.pdf#page=8", + "expected_citation_path": "us-me/regulation/dhhs/ocfs/10-148-cmr-chapter-6", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-me/regulation/2026-09-15-ccdf-subsidy-rules.jsonl contains us-me/regulation/dhhs/ocfs/10-148-cmr-chapter-6 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.maine.gov/dhhs/sites/maine.gov.dhhs/files/inline-files/CCAP%20Full%20Rule%208.18.2025_1.pdf#page=10", + "expected_citation_path": "us-me/regulation/dhhs/ocfs/10-148-cmr-chapter-6", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-me/regulation/2026-09-15-ccdf-subsidy-rules.jsonl contains us-me/regulation/dhhs/ocfs/10-148-cmr-chapter-6 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.mass.gov/doc/fy-2023-liheap-income-eligibility-and-benefit-level-chart-october-2022/download", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.mass.gov/doc/fy-2025-heap-income-eligibility-benefit-chart-may-8-2025/download", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.mass.gov/doc/fy2025-heap-state-plan-draft/download#page=8", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://tax.colorado.gov/individual-income-tax-guide", + "expected_citation_path": "us-co/guidance/cdor/individual-income-tax-guide", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-co/guidance/2026-10-06-w6-income-tax-guidance-co.jsonl contains us-co/guidance/cdor/individual-income-tax-guide (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.colorado.gov/sites/tax/files/documents/Book0104_2023.pdf#page=5", + "expected_citation_path": "us-co/form/cdor/ty2023/dr-0104-book", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-co/form/2026-10-06-w6-income-tax-forms-co.jsonl contains us-co/form/cdor/ty2023/dr-0104-book (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.colorado.gov/sites/tax/files/documents/Book104_2025.pdf#page=8", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://klvg4oyd4j.execute-api.us-west-2.amazonaws.com/prod/PublicFiles/34821a9573ca43e7b06dfad20f5183fd/0558a902-6362-47ca-8e3b-7cca3bc69b9d/2025%20PIT%20Packet_Final.pdf#page=86", + "expected_citation_path": "us-nm/form/trd/ty2025/pit-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-nm/form/2026-10-06-w6-income-tax-forms-nm.jsonl contains us-nm/form/trd/ty2025/pit-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://klvg4oyd4j.execute-api.us-west-2.amazonaws.com/prod/PublicFiles/34821a9573ca43e7b06dfad20f5183fd/1afc56af-ea90-4d48-82e5-1f9aeb43255a/PITbook2022.pdf#page=73", + "expected_citation_path": "us-nm/form/trd/ty2022/pit-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-nm/form/2026-10-06-w6-income-tax-forms-nm.jsonl contains us-nm/form/trd/ty2022/pit-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://klvg4oyd4j.execute-api.us-west-2.amazonaws.com/prod/PublicFiles/34821a9573ca43e7b06dfad20f5183fd/608063af-0c4c-4f28-ba42-cf40e04557ca/PITbook2021.pdf#page=63", + "expected_citation_path": "us-nm/form/trd/ty2021/pit-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-nm/form/2026-10-06-w6-income-tax-forms-nm.jsonl contains us-nm/form/trd/ty2021/pit-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://portal.ct.gov/dss/-/media/departments-and-agencies/dss/fact-sheets-and-issue-briefs/fact-sheets/dss-program-standards-chart-effective-010126.pdf", + "expected_citation_path": "us-ct/policy/dss/program-standards/2026-01-01", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ct/policy/2026-07-02-ct-ssp-upm-and-standards.jsonl contains us-ct/policy/dss/program-standards/2026-01-01 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://portal.ct.gov/dss/-/media/departments-and-agencies/dss/fact-sheets-and-issue-briefs/fact-sheets/dss-program-standards-chart.pdf", + "expected_citation_path": "us-ct/guidance/dss/program-standards-chart", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ct/guidance/2026-10-06-w6-benefits-guidance-ct.jsonl contains us-ct/guidance/dss/program-standards-chart (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://portal.ct.gov/dss/-/media/departments-and-agencies/dss/state-plans-and-federal-reports/tanf-state-plan/ct-tanf-state-plan-2024---2026---41524-amendment.pdf#page=10", + "expected_citation_path": "us-ct/policy/acf/tanf-plan/fy2024-2026", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ct/policy/2026-09-13-tanf-state-plan.jsonl contains us-ct/policy/acf/tanf-plan/fy2024-2026 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ftb.ca.gov/forms/2021/2021-540-ca-instructions.html", + "expected_citation_path": "us-ca/form/official-forms/ftb.ca.gov/forms/2021/2021-540-ca-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ca/form/2026-10-06-w6-income-tax-forms-ca.jsonl contains us-ca/form/official-forms/ftb.ca.gov/forms/2021/2021-540-ca-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ftb.ca.gov/forms/2022/2022-540-ca-instructions.html", + "expected_citation_path": "us-ca/form/official-forms/ftb.ca.gov/forms/2022/2022-540-ca-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ca/form/2026-10-06-w6-income-tax-forms-ca.jsonl contains us-ca/form/official-forms/ftb.ca.gov/forms/2022/2022-540-ca-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ftb.ca.gov/forms/2024/2024-540-ca-instructions.html", + "expected_citation_path": "us-ca/form/ftb/2024/schedule-ca-540-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ca/form/2026-10-06-w6-income-tax-forms-ca.jsonl contains us-ca/form/ftb/2024/schedule-ca-540-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.vermont.gov/sites/tax/files/documents/IN-153-2021.pdf#page=1", + "expected_citation_path": "us-vt/form/vdt/ty2021/in-153", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-vt/form/2026-10-06-w6-income-tax-forms-vt.jsonl contains us-vt/form/vdt/ty2021/in-153 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.vermont.gov/sites/tax/files/documents/IN-153-2022.pdf#page=1", + "expected_citation_path": "us-vt/form/vdt/ty2022/in-153", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-vt/form/2026-10-06-w6-income-tax-forms-vt.jsonl contains us-vt/form/vdt/ty2022/in-153 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.vermont.gov/sites/tax/files/documents/IN-153-2023.pdf#page=1", + "expected_citation_path": "us-vt/form/vdt/ty2023/in-153", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-vt/form/2026-10-06-w6-income-tax-forms-vt.jsonl contains us-vt/form/vdt/ty2023/in-153 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.oregon.gov/dor/forms/FormsPubs/combined-payroll_211-155-2_2026.pdf#page=5", + "expected_citation_path": "us-or/form/tax-forms/oregon.gov/dor/forms/formspubs/combined-payroll-211-155-2-2026", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-or/form/2026-10-06-w6-income-tax-forms-or.jsonl contains us-or/form/tax-forms/oregon.gov/dor/forms/formspubs/combined-payroll-211-155-2-2026 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.oregon.gov/dor/programs/businesses/Pages/Lane-County-Transit-District-Payroll-tax.aspx", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.oregon.gov/dor/programs/businesses/Pages/TriMet-Transit.aspx", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.revenue.wi.gov/TaxForms2021/2021-ScheduleWDf.pdf#page=2", + "expected_citation_path": "us-wi/form/tax-forms/revenue.wi.gov/taxforms2021/2021-schedulewdf", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-wi/form/2026-10-06-w6-income-tax-forms-wi.jsonl contains us-wi/form/tax-forms/revenue.wi.gov/taxforms2021/2021-schedulewdf (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.revenue.wi.gov/TaxForms2022/2022-ScheduleWDf.pdf#page=2", + "expected_citation_path": "us-wi/form/tax-forms/revenue.wi.gov/taxforms2022/2022-schedulewdf", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-wi/form/2026-10-06-w6-income-tax-forms-wi.jsonl contains us-wi/form/tax-forms/revenue.wi.gov/taxforms2022/2022-schedulewdf (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.revenue.wi.gov/TaxForms2023/2023-ScheduleWDf.pdf#page=2", + "expected_citation_path": "us-wi/form/tax-forms/revenue.wi.gov/taxforms2023/2023-schedulewdf", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-wi/form/2026-10-06-w6-income-tax-forms-wi.jsonl contains us-wi/form/tax-forms/revenue.wi.gov/taxforms2023/2023-schedulewdf (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://azdor.gov/forms/individual/itemized-deduction-adjustments-form", + "expected_citation_path": "us-az/form/individual-income-tax-forms/azdor.gov/forms/individual/itemized-deduction-adjustments-form", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-az/form/2026-10-06-w6-income-tax-forms-az.jsonl contains us-az/form/individual-income-tax-forms/azdor.gov/forms/individual/itemized-deduction-adjustments-form (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://azdor.gov/forms/tax-credits-forms/credit-contributions-qualifying-foster-care-charitable-organizations", + "expected_citation_path": "us-az/form/azdor/web/credit-contributions-qualifying-foster-care-charitable-organizations-page", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-az/form/2026-10-06-w6-income-tax-forms-az.jsonl contains us-az/form/azdor/web/credit-contributions-qualifying-foster-care-charitable-organizations-page (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://azdor.gov/sites/default/files/document/FORMS_INDIVIDUAL_2025_140i.pdf#page=24", + "expected_citation_path": "us-az/form/azdor/ty2025/form-140-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-az/form/2026-09-15-income-tax-forms-ty2025.jsonl contains us-az/form/azdor/ty2025/form-140-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.dfa.arkansas.gov/wp-content/uploads/2022_Final_AdditionalTaxCreditforQualifiedIndividuals_FI.pdf", + "expected_citation_path": "us-ar/form/dfa/ty2022/additional-tax-credit-for-qualified-individuals", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ar/form/2026-10-06-w6-income-tax-forms-ar.jsonl contains us-ar/form/dfa/ty2022/additional-tax-credit-for-qualified-individuals (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.dfa.arkansas.gov/wp-content/uploads/2023_Final_AdditionalTaxCreditforQualifiedIndividuals_FI.pdf", + "expected_citation_path": "us-ar/form/dfa/ty2023/additional-tax-credit-for-qualified-individuals", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ar/form/2026-10-06-w6-income-tax-forms-ar.jsonl contains us-ar/form/dfa/ty2023/additional-tax-credit-for-qualified-individuals (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.dfa.arkansas.gov/wp-content/uploads/2024_Final_AdditionalTaxCreditforQualifiedIndividuals_FI.pdf", + "expected_citation_path": "us-ar/form/dfa/ty2024/additional-tax-credit-for-qualified-individuals", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ar/form/2026-10-06-w6-income-tax-forms-ar.jsonl contains us-ar/form/dfa/ty2024/additional-tax-credit-for-qualified-individuals (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://casetext.com/statute/arkansas-code-of-1987/title-26-taxation/subtitle-5-state-taxes/chapter-51-income-taxes/subchapter-5-tax-credits-generally/section-26-51-501-personal-tax-credits", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://casetext.com/statute/arkansas-code-of-1987/title-26-taxation/subtitle-5-state-taxes/chapter-51-income-taxes/subchapter-5-tax-credits-generally/section-26-51-503-effective-for-tax-years-beginning-on-or-after-january-1-2022-support-of-a-child-with-a-developmental-disability-definitions", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://casetext.com/statute/arkansas-code-of-1987/title-26-taxation/subtitle-5-state-taxes/chapter-51-income-taxes/subchapter-3-exemptions-and-reduced-tax-rates/section-26-51-307-retirement-or-disability-benefits-definition", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/current/511-Pkt.pdf#page=17", + "expected_citation_path": "us-ok/form/otc/ty2025/form-511-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ok/form/2026-09-10-tax-state-forms-ty2025.jsonl contains us-ok/form/otc/ty2025/form-511-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/past-year/2021/511-Pkt-2021.pdf#page=17", + "expected_citation_path": "us-ok/form/otc/ty2021/form-511-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ok/form/2026-10-06-w6-income-tax-forms-ok.jsonl contains us-ok/form/otc/ty2021/form-511-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/past-year/2022/511-Pkt-2022.pdf#page=17", + "expected_citation_path": "us-ok/form/otc/ty2022/form-511-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ok/form/2026-10-06-w6-income-tax-forms-ok.jsonl contains us-ok/form/otc/ty2022/form-511-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://files.hawaii.gov/tax/forms/2022/n15ins.pdf#page=28", + "expected_citation_path": "us-hi/form/dotax/ty2022/n-15-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-hi/form/2026-10-06-w6-income-tax-forms-hi.jsonl contains us-hi/form/dotax/ty2022/n-15-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://files.hawaii.gov/tax/forms/2024/n11_i.pdf#page=3", + "expected_citation_path": "us-hi/form/individual-income-tax-forms/files.hawaii.gov/tax/forms/2024/n11-i", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-hi/form/2026-10-06-w6-income-tax-forms-hi.jsonl contains us-hi/form/individual-income-tax-forms/files.hawaii.gov/tax/forms/2024/n11-i (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://files.hawaii.gov/tax/forms/current/n11ins.pdf#page=33", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.tax.virginia.gov/sites/default/files/vatax-pdf/2021-760-instructions.pdf#page=32", + "expected_citation_path": "us-va/form/tax-forms/tax.virginia.gov/sites/default/files/vatax-pdf/2021-760-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-va/form/2026-10-06-w6-income-tax-forms-va.jsonl contains us-va/form/tax-forms/tax.virginia.gov/sites/default/files/vatax-pdf/2021-760-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.tax.virginia.gov/sites/default/files/vatax-pdf/2022-760-instructions.pdf#page=32", + "expected_citation_path": "us-va/form/tax-forms/tax.virginia.gov/sites/default/files/vatax-pdf/2022-760-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-va/form/2026-10-06-w6-income-tax-forms-va.jsonl contains us-va/form/tax-forms/tax.virginia.gov/sites/default/files/vatax-pdf/2022-760-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.tax.virginia.gov/sites/default/files/vatax-pdf/2023-760-instructions.pdf#page=31", + "expected_citation_path": "us-va/form/tax-forms/tax.virginia.gov/sites/default/files/vatax-pdf/2023-760-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-va/form/2026-10-06-w6-income-tax-forms-va.jsonl contains us-va/form/tax-forms/tax.virginia.gov/sites/default/files/vatax-pdf/2023-760-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://revenue.nebraska.gov/sites/default/files/doc/tax-forms/2024/f_Individual_Income_Tax_Booklet.pdf#page=21", + "expected_citation_path": "us-ne/form/dor/ty2024/individual-income-tax-booklet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ne/form/2026-10-06-w6-income-tax-forms-ne.jsonl contains us-ne/form/dor/ty2024/individual-income-tax-booklet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://revenue.nebraska.gov/sites/default/files/doc/tax-forms/2025/f_Individual_Income_Tax_Booklet.pdf#page=26", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://revenue.nebraska.gov/sites/revenue.nebraska.gov/files/doc/2022_Ne_Individual_Income_Tax_Booklet_8-307-2022_final_8.pdf#page=3", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ssa.gov/oact/STATS/table4a3.html#fn1", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ssa.gov/policy/docs/statcomps/supplement/2023/5a.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ssa.gov/policy/docs/statcomps/supplement/2023/7a.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://tax.wv.gov/Documents/PIT/2024/it140.PersonalIncomeTaxFormsAndInstructions.2024.pdf#page=17", + "expected_citation_path": "us-wv/form/individual-income-tax-forms/tax.wv.gov/documents/pit/2024/it140.personalincometaxformsandinstructions.2024", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-wv/form/2026-10-06-w6-income-tax-forms-wv.jsonl contains us-wv/form/individual-income-tax-forms/tax.wv.gov/documents/pit/2024/it140.personalincometaxformsandinstructions.2024 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.wv.gov/Documents/PIT/2025/it140.PersonalIncomeTaxFormsAndInstructions.2025.pdf#page=17", + "expected_citation_path": "us-wv/form/tax/ty2025/it-140-forms-and-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-wv/form/2026-09-15-income-tax-forms-ty2025.jsonl contains us-wv/form/tax/ty2025/it-140-forms-and-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.wv.gov/Documents/PIT/2022/PersonalIncomeTaxFormsAndInstructions.2022.pdf#page=14", + "expected_citation_path": "us-wv/form/individual-income-tax-forms/tax.wv.gov/documents/pit/2022/personalincometaxformsandinstructions.2022", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-wv/form/2026-10-06-w6-income-tax-forms-wv.jsonl contains us-wv/form/individual-income-tax-forms/tax.wv.gov/documents/pit/2022/personalincometaxformsandinstructions.2022 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.marylandtaxes.gov/forms/22_forms/Resident_Booklet.pdf#page=18", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.marylandtaxes.gov/forms/24-forms/Resident-Booklet.pdf#page=20", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.marylandtaxes.gov/forms/current_forms/Resident-Booklet.pdf#page=19", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.revisor.mn.gov/bills/94/2026/0/HF/4890/versions/0/pdf/#page=3", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.revisor.mn.gov/bills/94/2026/0/HF/4890/versions/0/pdf/#page=2", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.revisor.mn.gov/bills/94/2026/0/HF/4890/versions/0/pdf/#page=1", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.nysenate.gov/legislation/bills/2025/S2238", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.nysenate.gov/legislation/bills/2025/A6774", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.nysenate.gov/legislation/bills/2025/S4487", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://mtrevenue.gov/wp-content/uploads/dlm_uploads/2023/12/Form_2_2023_Instructions.pdf#page=6", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://mtrevenue.gov/wp-content/uploads/dlm_uploads/2022/12/Form-2-2022-Instructions.pdf#page=22", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://mtrevenue.gov/wp-content/uploads/mdocs/form%202%202021%20instructions.pdf#page=21", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_08-22-2024.pdf#page=15", + "expected_citation_path": "us-id/form/istc/ty2024/form-39r-efo00088-2024-08-22", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-id/form/2026-10-06-w6-income-tax-forms-id.jsonl contains us-id/form/istc/ty2024/form-39r-efo00088-2024-08-22 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_09-07-2023.pdf#page=15", + "expected_citation_path": "us-id/form/istc/ty2023/form-39r-efo00088-2023-09-07", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-id/form/2026-10-06-w6-income-tax-forms-id.jsonl contains us-id/form/istc/ty2023/form-39r-efo00088-2023-09-07 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_09-23-2021.pdf#page=2", + "expected_citation_path": "us-id/form/istc/ty2021/form-39r-efo00088-2021-09-23", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-id/form/2026-10-06-w6-income-tax-forms-id.jsonl contains us-id/form/istc/ty2021/form-39r-efo00088-2021-09-23 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2022_D-40_Booklet_Final_blk_01_23_23_Ordc.pdf#page=55", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2023_D40_Book_Final_012324.pdf#page=55", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2024_D40_Booklet_011525.pdf#page=55", + "expected_citation_path": "us-dc/form/otr/ty2024/d-40-booklet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-dc/form/2026-10-06-w6-income-tax-forms-dc.jsonl contains us-dc/form/otr/ty2024/d-40-booklet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.nj.gov/treasury/taxation/anchor/calculated.shtml", + "expected_citation_path": "us-nj/guidance/taxation/anchor-how-benefits-are-calculated", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-nj/guidance/2026-10-06-w6-income-tax-guidance-nj-r2.jsonl contains us-nj/guidance/taxation/anchor-how-benefits-are-calculated (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.nj.gov/treasury/omb/publications/27bib/BIB.pdf#page=18", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.nj.gov/governor/news/2026/20260310b.shtml", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://dor.mo.gov/forms/MO-PTC%20Instructions_2025.pdf#page=1", + "expected_citation_path": "us-mo/form/dor/ty2025/form-mo-ptc-instructions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-mo/form/2026-10-06-w6-income-tax-forms-mo.jsonl contains us-mo/form/dor/ty2025/form-mo-ptc-instructions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://dor.mo.gov/forms/MO-PTS_2023.pdf#page=1", + "expected_citation_path": "us-mo/form/dor/ty2023/form-mo-pts", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-mo/form/2026-10-06-w6-income-tax-forms-mo.jsonl contains us-mo/form/dor/ty2023/form-mo-pts (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://dor.mo.gov/forms/MO-PTS_2024.pdf#page=1", + "expected_citation_path": "us-mo/form/dor/ty2024/form-mo-pts", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-mo/form/2026-10-06-w6-income-tax-forms-mo.jsonl contains us-mo/form/dor/ty2024/form-mo-pts (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.ri.gov/sites/g/files/xkgbur541/files/2022-01/2021-ri-schedule-m_w.pdf#page=2", + "expected_citation_path": "us-ri/form/tax/ty2021/schedule-m", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ri/form/2026-10-06-w6-income-tax-forms-ri.jsonl contains us-ri/form/tax/ty2021/schedule-m (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.ri.gov/sites/g/files/xkgbur541/files/2022-12/2022%20RI%20Schedule%20M_w.pdf#page=2", + "expected_citation_path": "us-ri/form/tax/ty2022/schedule-m", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ri/form/2026-10-06-w6-income-tax-forms-ri.jsonl contains us-ri/form/tax/ty2022/schedule-m (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://tax.ri.gov/sites/g/files/xkgbur541/files/2025-12/2025%201040R%20Instructions%20122025.pdf#page=10", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ksrevenue.gov/pdf/ip21.pdf", + "expected_citation_path": "us-ks/form/kdor/ty2021/individual-income-tax-instruction-booklet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ks/form/2026-10-06-w6-income-tax-forms-ks.jsonl contains us-ks/form/kdor/ty2021/individual-income-tax-instruction-booklet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ksrevenue.gov/pdf/ip22.pdf", + "expected_citation_path": "us-ks/form/kdor/ty2022/individual-income-tax-instruction-booklet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ks/form/2026-10-06-w6-income-tax-forms-ks.jsonl contains us-ks/form/kdor/ty2022/individual-income-tax-instruction-booklet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ksrevenue.gov/pdf/ip23.pdf#page=11", + "expected_citation_path": "us-ks/form/kdor/ty2023/individual-income-tax-instruction-booklet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ks/form/2026-10-06-w6-income-tax-forms-ks.jsonl contains us-ks/form/kdor/ty2023/individual-income-tax-instruction-booklet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://dor.sc.gov/forms-site/Forms/IITPacket_2021.pdf#page=13", + "expected_citation_path": "us-sc/form/dor/ty2021/sc1040-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-sc/form/2026-10-06-w6-income-tax-forms-sc.jsonl contains us-sc/form/dor/ty2021/sc1040-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://dor.sc.gov/forms-site/Forms/IITPacket_2023.pdf#page=20", + "expected_citation_path": "us-sc/form/dor/ty2023/sc1040-packet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-sc/form/2026-10-06-w6-income-tax-forms-sc.jsonl contains us-sc/form/dor/ty2023/sc1040-packet (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://dor.sc.gov/forms-site/Forms/SC1040Instr_2024.pdf#page=3", + "expected_citation_path": "us-sc/form/individual-income-tax-forms/dor.sc.gov/forms-site/forms/sc1040instr-2024", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-sc/form/2026-10-06-w6-income-tax-forms-sc.jsonl contains us-sc/form/individual-income-tax-forms/dor.sc.gov/forms-site/forms/sc1040instr-2024 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.marylandcomptroller.gov/content/dam/mdcomp/tax/instructions/2022/Resident_Booklet.pdf#page=22", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.marylandcomptroller.gov/content/dam/mdcomp/tax/instructions/2023/Resident_Booklet.pdf#page=23", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.marylandcomptroller.gov/content/dam/mdcomp/tax/instructions/2024/Resident_Booklet.pdf#page=25", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.cga.ct.gov/2026/TOB/H/PDF/2026HB-05009-R00-HB.PDF", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.cga.ct.gov/2026/TOB/H/PDF/2026HB-05114-R01-HB.PDF#page=2", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.cga.ct.gov/2026/TOB/H/PDF/2026HB-05134-R00-HB.PDF#page=2", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://dor.georgia.gov/document/document/2022-it-511-individual-income-tax-booklet/download#page=16", + "expected_citation_path": "us-ga/form/dor/ty2022/it-511", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ga/form/2026-10-06-w6-income-tax-forms-ga.jsonl contains us-ga/form/dor/ty2022/it-511 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://dor.georgia.gov/document/document/2023-it-511-individual-income-tax-booklet/download#page=16", + "expected_citation_path": "us-ga/form/dor/ty2023/it-511", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ga/form/2026-10-06-w6-income-tax-forms-ga.jsonl contains us-ga/form/dor/ty2023/it-511 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://dor.georgia.gov/document/document/2025-it-511-individual-income-tax-booklet/download#page=21", + "expected_citation_path": "us-ga/form/it-511/2025", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ga/form/2026-07-21-ga-it-511-2025.jsonl contains us-ga/form/it-511/2025 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://oui.doleta.gov/unemploy/docs/reduced_credit_states_2010-2025.xlsx", + "expected_citation_path": "us/guidance/dol/eta/futa-credit-reduction-states-2010-2025", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-10-06-w6-federal-agency-guidance.jsonl contains us/guidance/dol/eta/futa-credit-reduction-states-2010-2025 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://oui.doleta.gov/unemploy/futa_credit.asp", + "expected_citation_path": "us/guidance/dol/eta/futa-credit-reductions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-10-06-w6-federal-agency-guidance.jsonl contains us/guidance/dol/eta/futa-credit-reductions (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://oui.doleta.gov/unemploy/pdf/sigmeasures/sigmeasuitaxsys24.pdf#page=11", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.law.cornell.edu/uscode/text/26/32#i_2", + "expected_citation_path": "us/statute/26/32/i/2", + "source": "verified-in-corpus", + "note": "USC corpus/usc.py:125,142,158-163,188; legal labels become slash segments. Nonlegal notes/tab fragments do not identify provisions. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/32/i/2 (paragraph); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.law.cornell.edu/uscode/text/26/1#h_1_A", + "expected_citation_path": "us/statute/26/1/h/1/A", + "source": "rule", + "note": "USC corpus/usc.py:125,142,158-163,188; legal labels become slash segments. Nonlegal notes/tab fragments do not identify provisions." + }, + { + "url": "https://www.law.cornell.edu/uscode/text/26/36B#c_1_B", + "expected_citation_path": "us/statute/26/36B/c/1/B", + "source": "rule", + "note": "USC corpus/usc.py:125,142,158-163,188; legal labels become slash segments. Nonlegal notes/tab fragments do not identify provisions." + }, + { + "url": "https://www.ecfr.gov/current/title-7/chapter-II/subchapter-C/part-273/section-273.9#p-273.9(d)(5)", + "expected_citation_path": "us/regulation/7/273/9", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-05-10-snap-7-cfr-273-r2026-07-15-self-contained.jsonl contains us/regulation/7/273/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/current/title-7/part-273#p-273.9(d)(5)", + "expected_citation_path": "us/regulation/7/273/9", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-05-10-snap-7-cfr-273-r2026-07-15-self-contained.jsonl contains us/regulation/7/273/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/on/2024-04-06/title-7/part-273/section-273.9", + "expected_citation_path": "us/regulation/7/273/9", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-05-10-snap-7-cfr-273-r2026-07-15-self-contained.jsonl contains us/regulation/7/273/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.law.cornell.edu/cfr/text/7/273.9#d_5", + "expected_citation_path": "us/regulation/7/273/9", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359; paragraph fragment retained as locator only: builder emits section records. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-05-10-snap-7-cfr-273-r2026-07-15-self-contained.jsonl contains us/regulation/7/273/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.law.cornell.edu/cfr/text/26/1.32-2", + "expected_citation_path": "us/regulation/26/1/32-2", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359; paragraph fragment retained as locator only: builder emits section records. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-07-24-1401-coordination-repair-title-26-part-1-r2026-09-13-closure-sections-consolidated.jsonl contains us/regulation/26/1/32-2 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section32&num=0&edition=prelim", + "expected_citation_path": "us/statute/26/32", + "source": "verified-in-corpus", + "note": "USC granule identifier gives title and section; corpus/usc.py:125. Edition identifies a version, not a path segment. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/32 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://uscode.house.gov/view.xhtml?req=granuleid%3AUSC-prelim-title26-section32&num=0&edition=prelim", + "expected_citation_path": "us/statute/26/32", + "source": "verified-in-corpus", + "note": "USC granule identifier gives title and section; corpus/usc.py:125. Edition identifies a version, not a path segment. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/32 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.govinfo.gov/content/pkg/USCODE-2023-title26/html/USCODE-2023-title26-subtitleA-chap1-subchapA-partI-sec32.htm", + "expected_citation_path": "us/statute/26/32", + "source": "verified-in-corpus", + "note": "Govinfo USCODE granule or /link/uscode/title/section identifies the same USC provision via corpus/usc.py:125. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/32 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.govinfo.gov/link/uscode/26/32", + "expected_citation_path": "us/statute/26/32", + "source": "verified-in-corpus", + "note": "Govinfo USCODE granule or /link/uscode/title/section identifies the same USC provision via corpus/usc.py:125. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/32 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.govinfo.gov/content/pkg/CFR-2024-title7-vol4/pdf/CFR-2024-title7-vol4-sec273-9.pdf", + "expected_citation_path": "us/regulation/7/273/9", + "source": "verified-in-corpus", + "note": "Govinfo CFR sec273-9 denotes section273.9; corpus/ecfr.py:350-359 splits part 273 and suffix9. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-05-10-snap-7-cfr-273-r2026-07-15-self-contained.jsonl contains us/regulation/7/273/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/pub/irs-drop/rp-23-34.pdf", + "expected_citation_path": "us/guidance/irs/rev-proc-2023-34", + "source": "verified-in-corpus", + "note": "Current document ingestion uses manifest-selected full-year IDs via corpus/documents.py:3810-3817; legacy models_guidance.py:81 rp-YY-NN is not current provision identity. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-10-06-w6-federal-irs-guidance.jsonl contains us/guidance/irs/rev-proc-2023-34 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/pub/irs-drop/rp-24-40.pdf", + "expected_citation_path": "us/guidance/irs/rev-proc-2024-40", + "source": "rule", + "note": "Current document ingestion uses manifest-selected full-year IDs via corpus/documents.py:3810-3817; legacy models_guidance.py:81 rp-YY-NN is not current provision identity." + }, + { + "url": "https://www.irs.gov/pub/irs-drop/rp-25-32.pdf", + "expected_citation_path": "us/guidance/irs/rev-proc-2025-32", + "source": "verified-in-corpus", + "note": "Current document ingestion uses manifest-selected full-year IDs via corpus/documents.py:3810-3817; legacy models_guidance.py:81 rp-YY-NN is not current provision identity. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-05-02-irs-rev-proc-2025-32-r2026-07-15-self-contained.jsonl contains us/guidance/irs/rev-proc-2025-32 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/pub/irs-drop/n-18-70.pdf", + "expected_citation_path": "us/guidance/irs/notice-2018-70", + "source": "verified-in-corpus", + "note": "Current document ingestion uses manifest-selected full-year IDs via corpus/documents.py:3810-3817; legacy models_guidance.py:81 rp-YY-NN is not current provision identity. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-10-06-w6-federal-irs-guidance.jsonl contains us/guidance/irs/notice-2018-70 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/pub/irs-drop/n-23-75.pdf", + "expected_citation_path": "us/guidance/irs/notice-2023-75", + "source": "verified-in-corpus", + "note": "Current document ingestion uses manifest-selected full-year IDs via corpus/documents.py:3810-3817; legacy models_guidance.py:81 rp-YY-NN is not current provision identity. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-10-06-w6-federal-irs-guidance.jsonl contains us/guidance/irs/notice-2023-75 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/pub/irs-drop/n-24-80.pdf", + "expected_citation_path": "us/guidance/irs/notice-2024-80", + "source": "verified-in-corpus", + "note": "Current document ingestion uses manifest-selected full-year IDs via corpus/documents.py:3810-3817; legacy models_guidance.py:81 rp-YY-NN is not current provision identity. Corpus checkpoint provision index: data/corpus/provisions/us/guidance/2026-10-06-w6-federal-irs-guidance.jsonl contains us/guidance/irs/notice-2024-80 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/publications/p596", + "expected_citation_path": "us/form/irs/ty2025/p596", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/form/2026-09-15-irs-forms-ty2025.jsonl contains us/form/irs/ty2025/p596 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/publications/p501", + "expected_citation_path": "us/form/irs/ty2025/p501", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/form/2026-09-15-irs-forms-ty2025.jsonl contains us/form/irs/ty2025/p501 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/publications/p525", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.irs.gov/pub/irs-pdf/p596.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.irs.gov/pub/irs-pdf/f1040.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.irs.gov/pub/irs-prior/f1040--2021.pdf", + "expected_citation_path": "us/form/irs/ty2021/f1040", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/form/2026-10-06-w6-federal-irs-forms.jsonl contains us/form/irs/ty2021/f1040 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/pub/irs-prior/f1040--2025.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.irs.gov/forms-pubs/about-form-1040", + "expected_citation_path": "us/form/irs/ty2025/f1040", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/form/2026-09-10-tax-irs-forms-ty2025.jsonl contains us/form/irs/ty2025/f1040 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.irs.gov/instructions/i1040gi", + "expected_citation_path": "us/form/irs/ty2025/i1040gi", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/form/2026-09-10-tax-irs-forms-ty2025.jsonl contains us/form/irs/ty2025/i1040gi (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://mgaleg.maryland.gov/mgawebsite/Laws/StatuteText?article=gtg§ion=10-709&enactments=false", + "expected_citation_path": "us-md/statute/gtg/10-709", + "source": "verified-in-corpus", + "note": "Maryland corpus/state_adapters/maryland.py:109-110; native article code gtg and hyphenated section remain distinct segments. Corpus checkpoint provision index: data/corpus/provisions/us-md/statute/2026-09-14-income-tax-chapter-us-md-article-gtg.jsonl contains us-md/statute/gtg/10-709 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://codes.ohio.gov/ohio-revised-code/section-5747.02", + "expected_citation_path": "us-oh/statute/5747.02", + "source": "verified-in-corpus", + "note": "Ohio corpus/states.py:276-277 keeps 5747.02 as one native section segment. Corpus checkpoint provision index: data/corpus/provisions/us-oh/statute/2026-07-13-recovery.jsonl contains us-oh/statute/5747.02 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.nysenate.gov/legislation/laws/TAX/601", + "expected_citation_path": "us-ny/statute/TAX/601", + "source": "verified-in-corpus", + "note": "New York corpus/state_adapters/new_york.py:108-111; law ID retains uppercase TAX/ISC/SOS. Corpus checkpoint provision index: data/corpus/provisions/us-ny/statute/2026-07-06-ny-tax-article22-core-us-ny-sections-tax-601-tax-606-tax-614-tax-615-tax-616.jsonl contains us-ny/statute/TAX/601 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.nysenate.gov/legislation/laws/TAX/606", + "expected_citation_path": "us-ny/statute/TAX/606", + "source": "verified-in-corpus", + "note": "New York corpus/state_adapters/new_york.py:108-111; law ID retains uppercase TAX/ISC/SOS. Corpus checkpoint provision index: data/corpus/provisions/us-ny/statute/2026-07-06-ny-tax-article22-core-us-ny-sections-tax-601-tax-606-tax-614-tax-615-tax-616.jsonl contains us-ny/statute/TAX/606 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.nysenate.gov/legislation/laws/TAX/614", + "expected_citation_path": "us-ny/statute/TAX/614", + "source": "verified-in-corpus", + "note": "New York corpus/state_adapters/new_york.py:108-111; law ID retains uppercase TAX/ISC/SOS. Corpus checkpoint provision index: data/corpus/provisions/us-ny/statute/2026-07-06-ny-tax-article22-core-us-ny-sections-tax-601-tax-606-tax-614-tax-615-tax-616.jsonl contains us-ny/statute/TAX/614 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=17054.&nodeTreePath=3.20.2&lawCode=RTC", + "expected_citation_path": "us-ca/statute/rtc/17054", + "source": "verified-in-corpus", + "note": "California corpus/states.py:515-521: lowercase code and native section; publisher terminal punctuation is not decimal section precision. Corpus checkpoint provision index: data/corpus/provisions/us-ca/statute/2026-07-06-ca-rtc-pit-core-us-ca-sections-rtc-17041-rtc-17043-rtc-17045-rtc-17052-rtc-17054-rtc-17073.5-r2026-09-24-preserve-tables.jsonl contains us-ca/statute/rtc/17054 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://malegislature.gov/Laws/GeneralLaws/PartI/TitleIX/Chapter62/Section6", + "expected_citation_path": "us-ma/statute/62/6", + "source": "verified-in-corpus", + "note": "Massachusetts corpus/state_adapters/massachusetts.py:200-201; part/title URL containers do not enter section path. Corpus checkpoint provision index: data/corpus/provisions/us-ma/statute/2026-07-13-recovery.jsonl contains us-ma/statute/62/6 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.maine.gov/statutes/36/title36sec5111.html", + "expected_citation_path": "us-me/statute/36/5111", + "source": "verified-in-corpus", + "note": "Maine corpus/state_adapters/maine.py:140-141 preserves title and section; upper-case letter suffixes remain significant. Corpus checkpoint provision index: data/corpus/provisions/us-me/statute/2026-07-13-recovery.jsonl contains us-me/statute/36/5111 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.cga.ct.gov/current/pub/chap_229.htm#sec_12-700", + "expected_citation_path": "us-ct/statute/12-700", + "source": "verified-in-corpus", + "note": "Connecticut chapter HTML section anchor supplies native section; corpus/state_adapters/connecticut.py:143-144. Subsequent fragment subdivisions remain locators. Corpus checkpoint provision index: data/corpus/provisions/us-ct/statute/2026-07-13-recovery.jsonl contains us-ct/statute/12-700 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.cga.ct.gov/current/pub/chap_229.htm#sec_12-701", + "expected_citation_path": "us-ct/statute/12-701", + "source": "verified-in-corpus", + "note": "Connecticut chapter HTML section anchor supplies native section; corpus/state_adapters/connecticut.py:143-144. Subsequent fragment subdivisions remain locators. Corpus checkpoint provision index: data/corpus/provisions/us-ct/statute/2026-09-14-income-tax-chapter-title-12-chapter-229.jsonl contains us-ct/statute/12-701 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.vermont.gov/statutes/section/32/151/05811", + "expected_citation_path": "us-vt/statute/32-5811", + "source": "verified-in-corpus", + "note": "Vermont dedicated title32 corpus/state_adapters/vermont.py:570,628 uses a combined title-section token and omits chapter/publisher zero padding. Corpus checkpoint provision index: data/corpus/provisions/us-vt/statute/2026-07-16-pit-central-us-vt-title-32-chapter-151.jsonl contains us-vt/statute/32-5811 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.vermont.gov/statutes/section/32/151/05812", + "expected_citation_path": "us-vt/statute/32-5812", + "source": "verified-in-corpus", + "note": "Vermont dedicated title32 corpus/state_adapters/vermont.py:570,628 uses a combined title-section token and omits chapter/publisher zero padding. Corpus checkpoint provision index: data/corpus/provisions/us-vt/statute/2026-07-16-pit-central-us-vt-title-32-chapter-151.jsonl contains us-vt/statute/32-5812 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.legis.iowa.gov/docs/code/2026/422.12.pdf", + "expected_citation_path": "us-ia/statute/422.12", + "source": "verified-in-corpus", + "note": "Iowa corpus/state_adapters/iowa.py:119-125; PDF filename is a native statutory section, source year is version metadata. Corpus checkpoint provision index: data/corpus/provisions/us-ia/statute/2026-07-13-recovery.jsonl contains us-ia/statute/422.12 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.legis.iowa.gov/docs/code/2026/422.12B.pdf", + "expected_citation_path": "us-ia/statute/422.12B", + "source": "verified-in-corpus", + "note": "Iowa corpus/state_adapters/iowa.py:119-125; PDF filename is a native statutory section, source year is version metadata. Corpus checkpoint provision index: data/corpus/provisions/us-ia/statute/2026-07-13-recovery.jsonl contains us-ia/statute/422.12B (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://law.lis.virginia.gov/vacode/title58.1/chapter3/section58.1-320/", + "expected_citation_path": "us-va/statute/58.1/58.1-320", + "source": "verified-in-corpus", + "note": "Virginia converter uslm_id at converters/us_states/va.py:532 and generic final identity corpus/states.py:7945-7973 retain title and complete section, omitting chapter. Corpus checkpoint provision index: data/corpus/provisions/us-va/statute/2026-07-13-recovery.jsonl contains us-va/statute/58.1/58.1-320 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://law.lis.virginia.gov/vacode/58.1-321/", + "expected_citation_path": "us-va/statute/58.1/58.1-321", + "source": "verified-in-corpus", + "note": "Virginia converter uslm_id at converters/us_states/va.py:532 and generic final identity corpus/states.py:7945-7973 retain title and complete section, omitting chapter. Corpus checkpoint provision index: data/corpus/provisions/us-va/statute/2026-07-13-recovery.jsonl contains us-va/statute/58.1/58.1-321 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-30", + "expected_citation_path": "us-mi/statute/206.30", + "source": "verified-in-corpus", + "note": "Michigan corpus/state_adapters/michigan.py:106-107; MCL section remains one dotted segment. Corpus checkpoint provision index: data/corpus/provisions/us-mi/statute/2026-09-14-income-tax-chapter-us-mi-chapter-206.jsonl contains us-mi/statute/206.30 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://revisor.mo.gov/main/OneSection.aspx?section=143.011&bid=51511", + "expected_citation_path": "us-mo/statute/143.011", + "source": "verified-in-corpus", + "note": "Missouri corpus/state_adapters/missouri.py:178-179; source_id is native dotted section; bid is version/source metadata. Corpus checkpoint provision index: data/corpus/provisions/us-mo/statute/2026-07-16-pit-central-us-mo-143.jsonl contains us-mo/statute/143.011 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.revisor.mn.gov/statutes/cite/290.06#stat.290.06.2c", + "expected_citation_path": "us-mn/statute/290.06", + "source": "verified-in-corpus", + "note": "Minnesota corpus/states.py:328-333; stat.section.subdivision fragment is a locator in the section body. Corpus checkpoint provision index: data/corpus/provisions/us-mn/statute/2026-07-13-recovery.jsonl contains us-mn/statute/290.06 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05", + "expected_citation_path": "us-wi/statute/71.05", + "source": "verified-in-corpus", + "note": "Wisconsin corpus/state_adapters/wisconsin.py:144-145,186-187 uses complete dotted section and semantic subdivision labels; Roman subchapter container is omitted. Corpus checkpoint provision index: data/corpus/provisions/us-wi/statute/2026-07-16-pit-west-chapter-71.jsonl contains us-wi/statute/71.05 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.oregonlegislature.gov/bills_laws/ors/ors316.html#316.085", + "expected_citation_path": "us-or/statute/316.085", + "source": "verified-in-corpus", + "note": "Oregon native section fragment identifies a provision inside chapter HTML; corpus/state_adapters/oregon.py:134-135,858-860. Corpus checkpoint provision index: data/corpus/provisions/us-or/statute/2026-07-13-recovery.jsonl contains us-or/statute/316.085 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://law.justia.com/codes/arkansas/title-26/subtitle-5/chapter-51/subchapter-5/section-26-51-501/", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ilga.gov/legislation/ilcs/fulltext.asp?DocName=003500050K201", + "expected_citation_path": "us-il/statute/35/5/201", + "source": "verified-in-corpus", + "note": "Illinois corpus/state_adapters/illinois.py:84-88: zero-padded DocName encodes chapter, act, and section. Corpus checkpoint provision index: data/corpus/provisions/us-il/statute/2026-07-13-recovery.jsonl contains us-il/statute/35/5/201 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://nmonesource.com/nmos/nmsa/en/4340/1/document.do", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.congress.gov/bill/119th-congress/house-bill/1/text", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.irs.gov/newsroom/fake-tax-credit-announcement", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.law.cornell.edu/uscode/text/26/36B#b_3_A", + "expected_citation_path": "us/statute/26/36B/b/3/A", + "source": "rule", + "note": "USC corpus/usc.py:125,142,158-163,188; legal labels become slash segments. Nonlegal notes/tab fragments do not identify provisions." + }, + { + "url": "https://www.law.cornell.edu/uscode/text/26/1#i", + "expected_citation_path": "us/statute/26/1/i", + "source": "verified-in-corpus", + "note": "USC corpus/usc.py:125,142,158-163,188; legal labels become slash segments. Nonlegal notes/tab fragments do not identify provisions. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/1/i (subsection); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.law.cornell.edu/uscode/text/26/32#b", + "expected_citation_path": "us/statute/26/32/b", + "source": "verified-in-corpus", + "note": "USC corpus/usc.py:125,142,158-163,188; legal labels become slash segments. Nonlegal notes/tab fragments do not identify provisions. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl contains us/statute/26/32/b (subsection); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/current/title-29/subtitle-B/chapter-V/subchapter-A/part-541", + "expected_citation_path": "us/regulation/29/541", + "source": "verified-in-corpus", + "note": "CFR part container corpus/ecfr.py:1250. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-10-06-w6-federal-ecfr-closure-title-29-part-541.jsonl contains us/regulation/29/541 (part); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-A/part-98/subpart-B/section-98.21", + "expected_citation_path": "us/regulation/45/98/21", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-09-11-title-45-part-98.jsonl contains us/regulation/45/98/21 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/current/title-45/section-98.20", + "expected_citation_path": "us/regulation/45/98/20", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-09-11-title-45-part-98.jsonl contains us/regulation/45/98/20 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/current/title-45/subtitle-B/chapter-II/part-233/section-233.20#p-233.20(a)(11)(ii)(D)", + "expected_citation_path": "us/regulation/45/233/20", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-10-06-w6-federal-ecfr-closure-title-45-part-233.jsonl contains us/regulation/45/233/20 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/current/title-7/part-247#p-247.9(b)", + "expected_citation_path": "us/regulation/7/247/9", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-07-13-recovery-r2026-07-17-dedup.jsonl contains us/regulation/7/247/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ecfr.gov/current/title-7/part-247#p-247.9(a)", + "expected_citation_path": "us/regulation/7/247/9", + "source": "verified-in-corpus", + "note": "CFR corpus/ecfr.py:350-359,1355; paragraphs are inside section body, not semantic child paths. Corpus checkpoint provision index: data/corpus/provisions/us/regulation/2026-07-13-recovery-r2026-07-17-dedup.jsonl contains us/regulation/7/247/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://codes.ohio.gov/ohio-administrative-code/rule-5101:1-23-20", + "expected_citation_path": "us-oh/regulation/agency-5101-1/chapter-5101-1-23/rule-5101-1-23-20", + "source": "verified-in-corpus", + "note": "Ohio administrative code corpus/ohio_admin_code.py:70-131,1075-1079: agency/chapter/rule containers, colon and decimal point slugged to hyphens. Corpus checkpoint provision index: data/corpus/provisions/us-oh/regulation/2026-07-16-agency-5101-4-r2026-09-11-5101-1-5122-36-consolidated.jsonl contains us-oh/regulation/agency-5101-1/chapter-5101-1-23/rule-5101-1-23-20 (rule); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://codes.ohio.gov/ohio-revised-code/section-5107.10", + "expected_citation_path": "us-oh/statute/5107.10", + "source": "verified-in-corpus", + "note": "Ohio corpus/states.py:276-277 keeps 5747.02 as one native section segment. Corpus checkpoint provision index: data/corpus/provisions/us-oh/statute/2026-10-06-w6-tanf-ccdf-statute-oh.jsonl contains us-oh/statute/5107.10 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://codes.ohio.gov/ohio-administrative-code/rule-5101:1-23-40", + "expected_citation_path": "us-oh/regulation/agency-5101-1/chapter-5101-1-23/rule-5101-1-23-40", + "source": "verified-in-corpus", + "note": "Ohio administrative code corpus/ohio_admin_code.py:70-131,1075-1079: agency/chapter/rule containers, colon and decimal point slugged to hyphens. Corpus checkpoint provision index: data/corpus/provisions/us-oh/regulation/2026-07-16-agency-5101-4-r2026-09-11-5101-1-5122-36-consolidated.jsonl contains us-oh/regulation/agency-5101-1/chapter-5101-1-23/rule-5101-1-23-40 (rule); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://codes.ohio.gov/ohio-revised-code/section-5107.04", + "expected_citation_path": "us-oh/statute/5107.04", + "source": "verified-in-corpus", + "note": "Ohio corpus/states.py:276-277 keeps 5747.02 as one native section segment. Corpus checkpoint provision index: data/corpus/provisions/us-oh/statute/2026-10-06-w6-tanf-ccdf-statute-oh.jsonl contains us-oh/statute/5107.04 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://codes.ohio.gov/ohio-revised-code/section-5747.054", + "expected_citation_path": "us-oh/statute/5747.054", + "source": "verified-in-corpus", + "note": "Ohio corpus/states.py:276-277 keeps 5747.02 as one native section segment. Corpus checkpoint provision index: data/corpus/provisions/us-oh/statute/2026-09-14-income-tax-chapter-us-oh-title-57.jsonl contains us-oh/statute/5747.054 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=WIC§ionNum=11465", + "expected_citation_path": "us-ca/statute/wic/11465", + "source": "verified-in-corpus", + "note": "California corpus/states.py:515-521: lowercase code and native section; publisher terminal punctuation is not decimal section precision. Corpus checkpoint provision index: data/corpus/provisions/us-ca/statute/2026-10-06-w6-tanf-ccdf-statutes-ca-us-ca-title-wic-r2026-10-07-without-released-sections-consolidated.jsonl contains us-ca/statute/wic/11465 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=10290.&lawCode=WIC", + "expected_citation_path": "us-ca/statute/wic/10290", + "source": "verified-in-corpus", + "note": "California corpus/states.py:515-521: lowercase code and native section; publisher terminal punctuation is not decimal section precision. Corpus checkpoint provision index: data/corpus/provisions/us-ca/statute/2026-10-06-w6-tanf-ccdf-statutes-ca-us-ca-title-wic-r2026-10-07-without-released-sections-consolidated.jsonl contains us-ca/statute/wic/10290 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=201120120SB70", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://leginfo.legislature.ca.gov/faces/billTextClient.xhtml?bill_id=201720180AB99", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=10271.5.&lawCode=WIC", + "expected_citation_path": "us-ca/statute/wic/10271.5", + "source": "verified-in-corpus", + "note": "California corpus/states.py:515-521: lowercase code and native section; publisher terminal punctuation is not decimal section precision. Corpus checkpoint provision index: data/corpus/provisions/us-ca/statute/2026-10-06-w6-tanf-ccdf-statutes-ca-us-ca-title-wic-r2026-10-07-without-released-sections-consolidated.jsonl contains us-ca/statute/wic/10271.5 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?sectionNum=10372.&nodeTreePath=16.4.19&lawCode=WIC", + "expected_citation_path": "us-ca/statute/wic/10372", + "source": "verified-in-corpus", + "note": "California corpus/states.py:515-521: lowercase code and native section; publisher terminal punctuation is not decimal section precision. Corpus checkpoint provision index: data/corpus/provisions/us-ca/statute/2026-10-06-w6-tanf-ccdf-statutes-ca-us-ca-title-wic-r2026-10-07-without-released-sections-consolidated.jsonl contains us-ca/statute/wic/10372 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.nysenate.gov/legislation/bills/2025/S9110", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.nysenate.gov/legislation/laws/TAX/801", + "expected_citation_path": "us-ny/statute/TAX/801", + "source": "rule", + "note": "New York corpus/state_adapters/new_york.py:108-111; law ID retains uppercase TAX/ISC/SOS." + }, + { + "url": "https://www.revisor.mn.gov/statutes/cite/256P.06#stat.256P.06.3", + "expected_citation_path": "us-mn/statute/256P.06", + "source": "verified-in-corpus", + "note": "Minnesota corpus/states.py:328-333; stat.section.subdivision fragment is a locator in the section body. Corpus checkpoint provision index: data/corpus/provisions/us-mn/statute/2026-09-14-income-tax-chapter-us-mn-title-256p.jsonl contains us-mn/statute/256P.06 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.revisor.mn.gov/statutes/cite/142G.16", + "expected_citation_path": "us-mn/statute/142G.16", + "source": "verified-in-corpus", + "note": "Minnesota corpus/states.py:328-333; stat.section.subdivision fragment is a locator in the section body. Corpus checkpoint provision index: data/corpus/provisions/us-mn/statute/2026-09-14-income-tax-chapter-us-mn-title-142g.jsonl contains us-mn/statute/142G.16 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.revisor.mn.gov/statutes/cite/256P.03#stat.256P.03.2", + "expected_citation_path": "us-mn/statute/256P.03", + "source": "verified-in-corpus", + "note": "Minnesota corpus/states.py:328-333; stat.section.subdivision fragment is a locator in the section body. Corpus checkpoint provision index: data/corpus/provisions/us-mn/statute/2026-09-14-income-tax-chapter-us-mn-title-256p.jsonl contains us-mn/statute/256P.03 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://mgaleg.maryland.gov/mgawebsite/Laws/StatuteText?article=gtg§ion=10-208&enession=2024RS", + "expected_citation_path": "us-md/statute/gtg/10-208", + "source": "verified-in-corpus", + "note": "Maryland corpus/state_adapters/maryland.py:109-110; native article code gtg and hyphenated section remain distinct segments. Corpus checkpoint provision index: data/corpus/provisions/us-md/statute/2026-09-14-income-tax-chapter-us-md-article-gtg.jsonl contains us-md/statute/gtg/10-208 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://mgaleg.maryland.gov/2025RS/Chapters_noln/CH_604_hb0352e.pdf#page=163", + "expected_citation_path": "us-md/statute/session-laws/2025/chapter-604", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-md/statute/2026-10-06-w6-income-tax-statute-md.jsonl contains us-md/statute/session-laws/2025/chapter-604 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://mgaleg.maryland.gov/2025RS/Chapters_noln/CH_604_hb0352e.pdf#page=164", + "expected_citation_path": "us-md/statute/session-laws/2025/chapter-604", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-md/statute/2026-10-06-w6-income-tax-statute-md.jsonl contains us-md/statute/session-laws/2025/chapter-604 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://mgaleg.maryland.gov/2025RS/Chapters_noln/CH_604_hb0352e.pdf#page=169", + "expected_citation_path": "us-md/statute/session-laws/2025/chapter-604", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-md/statute/2026-10-06-w6-income-tax-statute-md.jsonl contains us-md/statute/session-laws/2025/chapter-604 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://mgaleg.maryland.gov/2025RS/Chapters_noln/CH_604_hb0352e.pdf#page=168", + "expected_citation_path": "us-md/statute/session-laws/2025/chapter-604", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-md/statute/2026-10-06-w6-income-tax-statute-md.jsonl contains us-md/statute/session-laws/2025/chapter-604 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://mgaleg.maryland.gov/2023RS/fnotes/bil_0007/hb0547.pdf", + "expected_citation_path": "us-md/guidance/dls/fiscal-notes/2023/hb0547", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-md/guidance/2026-10-06-w6-income-tax-guidance-md.jsonl contains us-md/guidance/dls/fiscal-notes/2023/hb0547 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://malegislature.gov/Laws/SessionLaws/Acts/2023/Chapter50", + "expected_citation_path": "us-ma/statute/session-laws/2023/chapter-50", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ma/statute/2026-10-06-w6-income-tax-statute-ma.jsonl contains us-ma/statute/session-laws/2023/chapter-50 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://malegislature.gov/Laws/GeneralLaws/PartI/TitleIX/Chapter62/Section5", + "expected_citation_path": "us-ma/statute/62/5", + "source": "verified-in-corpus", + "note": "Massachusetts corpus/state_adapters/massachusetts.py:200-201; part/title URL containers do not enter section path. Corpus checkpoint provision index: data/corpus/provisions/us-ma/statute/2026-09-23-ma-mgl-chapter-62-us-ma-part-i-title-ix-chapter-62-sections-2-r2026-10-07-chapter-62-without-recovery-sections-consolidated.jsonl contains us-ma/statute/62/5 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://malegislature.gov/Laws/GeneralLaws/PartI/TitleIX/Chapter62/Section3", + "expected_citation_path": "us-ma/statute/62/3", + "source": "verified-in-corpus", + "note": "Massachusetts corpus/state_adapters/massachusetts.py:200-201; part/title URL containers do not enter section path. Corpus checkpoint provision index: data/corpus/provisions/us-ma/statute/2026-07-13-recovery.jsonl contains us-ma/statute/62/3 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://malegislature.gov/Laws/GeneralLaws/PartI/TitleIX/Chapter62/Section9", + "expected_citation_path": "us-ma/statute/62/9", + "source": "verified-in-corpus", + "note": "Massachusetts corpus/state_adapters/massachusetts.py:200-201; part/title URL containers do not enter section path. Corpus checkpoint provision index: data/corpus/provisions/us-ma/statute/2026-09-23-ma-mgl-chapter-62-us-ma-part-i-title-ix-chapter-62-sections-2-r2026-10-07-chapter-62-without-recovery-sections-consolidated.jsonl contains us-ma/statute/62/9 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://malegislature.gov/Bills/191/H86", + "expected_citation_path": "us-ma/statute/session-laws/2019/h86", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-ma/statute/2026-10-06-w6-income-tax-statute-ma.jsonl contains us-ma/statute/session-laws/2019/h86 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.mi.gov/Bills/Bill?ObjectName=2025-HB-4055", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "http://legislature.mi.gov/doc.aspx?mcl-206-272", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "http://legislature.mi.gov/doc.aspx?mcl-206-527a", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "http://legislature.mi.gov/doc.aspx?mcl-206-520", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "http://legislature.mi.gov/doc.aspx?mcl-206-522", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "http://legislature.mi.gov/doc.aspx?mcl-206-30", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://legislature.maine.gov/legis/bills/getPDF.asp?paper=SP0031&item=5&snum=130", + "expected_citation_path": "us-me/statute/session-laws/130/ld23", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-me/statute/2026-10-06-w6-income-tax-statute-me.jsonl contains us-me/statute/session-laws/130/ld23 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.maine.gov/backend/App/services/getDocument.aspx?documentId=120256#page=311", + "expected_citation_path": "us-me/statute/session-laws/132/ld210-committee-amendment-120256", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us-me/statute/2026-10-06-w6-income-tax-statute-me.jsonl contains us-me/statute/session-laws/132/ld210-committee-amendment-120256 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.maine.gov/statutes/36/title36sec5213-A.html", + "expected_citation_path": "us-me/statute/36/5213-A", + "source": "verified-in-corpus", + "note": "Maine corpus/state_adapters/maine.py:140-141 preserves title and section; upper-case letter suffixes remain significant. Corpus checkpoint provision index: data/corpus/provisions/us-me/statute/2026-07-13-recovery.jsonl contains us-me/statute/36/5213-A (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.maine.gov/statutes/36/title36sec5219-KK.html", + "expected_citation_path": "us-me/statute/36/5219-KK", + "source": "verified-in-corpus", + "note": "Maine corpus/state_adapters/maine.py:140-141 preserves title and section; upper-case letter suffixes remain significant. Corpus checkpoint provision index: data/corpus/provisions/us-me/statute/2026-09-14-income-tax-chapter-us-me-title-36.jsonl contains us-me/statute/36/5219-KK (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.maine.gov/statutes/36/title36sec5403.html", + "expected_citation_path": "us-me/statute/36/5403", + "source": "verified-in-corpus", + "note": "Maine corpus/state_adapters/maine.py:140-141 preserves title and section; upper-case letter suffixes remain significant. Corpus checkpoint provision index: data/corpus/provisions/us-me/statute/2026-09-14-income-tax-chapter-us-me-title-36.jsonl contains us-me/statute/36/5403 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://legislature.maine.gov/statutes/36/title36sec5126-A.html", + "expected_citation_path": "us-me/statute/36/5126-A", + "source": "verified-in-corpus", + "note": "Maine corpus/state_adapters/maine.py:140-141 preserves title and section; upper-case letter suffixes remain significant. Corpus checkpoint provision index: data/corpus/provisions/us-me/statute/2026-07-13-recovery.jsonl contains us-me/statute/36/5126-A (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ilga.gov/legislation/ilcs/documents/030500050K5-2.htm", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ilga.gov/documents/legislation/ilcs/documents/030500050K5-2.htm", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ilga.gov/legislation/ilcs/fulltext.asp?DocName=030500050K5-2", + "expected_citation_path": "us-il/statute/305/5/5-2", + "source": "verified-in-corpus", + "note": "Illinois corpus/state_adapters/illinois.py:84-88: zero-padded DocName encodes chapter, act, and section. Corpus checkpoint provision index: data/corpus/provisions/us-il/statute/2026-10-06-w6-health-statute-il-us-il-chapter-305-act-5.jsonl contains us-il/statute/305/5/5-2 (section); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://www.ilga.gov/legislation/ilcs/ilcs3.asp?ActID=1454&ChapterID=31", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ilga.gov/legislation/BillStatus?DocNum=1821&GAID=18&DocTypeID=SB&LegId=160970&SessionID=114", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.ilga.gov/legislation/ilcs/documents/032000300K3.htm", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.govinfo.gov/content/pkg/FR-2012-03-02/pdf/2012-4978.pdf#page=17", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.govinfo.gov/app/details/FR-2025-01-17/2025-01377", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.govinfo.gov/content/pkg/GPO-CPRT-105WPRT37945/html/GPO-CPRT-105WPRT37945-2-7.htm", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.govinfo.gov/content/pkg/FR-2025-07-24/pdf/2025-13879.pdf#page=3", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.govinfo.gov/content/pkg/FR-2025-07-24/pdf/2025-13879.pdf#page=4", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://www.govinfo.gov/content/pkg/PLAW-119publ21/pdf/PLAW-119publ21.pdf", + "expected_citation_path": "us/statute/public-law/119-21", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Corpus checkpoint provision index: data/corpus/provisions/us/statute/2026-10-06-w6-federal-public-laws.jsonl contains us/statute/public-law/119-21 (document); pinned-byte audit passed; see corpus-evidence.json for the artifact, line and SHA256." + }, + { + "url": "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title47-section1752&edition=prelim", + "expected_citation_path": "us/statute/47/1752", + "source": "rule", + "note": "USC granule identifier gives title and section; corpus/usc.py:125. Edition identifies a version, not a path segment." + }, + { + "url": "https://uscode.house.gov/view.xhtml?req=(title:26%20section:63%20edition:prelim)", + "expected_citation_path": null, + "source": "rule", + "note": "Unresolved: no deterministic legal identifier or unique curated document root; do not infer a citation from host, arbitrary PDF basename, news topic, or opaque publisher ID. Missing checkpoint records are not proof of corpus absence." + }, + { + "url": "https://assets.publishing.service.gov.uk/media/69fb3ea22a6137e93226b7ce/universal-credit-deductions-march-2025-to-february-2026.ods", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous assets.publishing.service.gov.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/simulation/uc_deductions/calibration_cap.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://assets.publishing.service.gov.uk/media/69c2902a7e02b81c0d1c7762/hbai-data_tables-2025.zip", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous assets.publishing.service.gov.uk zip route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/household/poverty/absolute_poverty_threshold_ahc.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.bankofengland.co.uk/boeapps/database/Bank-Rate.asp", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous bankofengland.co.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/boe/base_rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://bills.parliament.uk/publications/62123/documents/6889#page=16", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous bills.parliament.uk fragment route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/new_claimant_health_element.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://bills.parliament.uk/publications/62123/documents/6889", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous bills.parliament.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dwp/universal_credit/rebalancing/active.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.childcarechoices.gov.uk/15-and-30-hours-childcare-support/working-families/eligibility", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous childcarechoices.gov.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dfe/weeks_per_year.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.closer.ac.uk/data/television-ownership-in-domestic-households/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous closer.ac.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dcms/bbc/tv_licence/tv_ownership.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://commonslibrary.parliament.uk/research-briefings/cbp-8101/#:~:text=Estimated%20TV%20licence%20evasion%20rate,from%206.57%25%20to%207.25%25.", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous commonslibrary.parliament.uk fragment route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dcms/bbc/tv_licence/evasion_rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://commonslibrary.parliament.uk/research-briefings/cbp-7812/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous commonslibrary.parliament.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/minimum_rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://data.parliament.uk/DepositedPapers/Files/DEP2017-0556/6_Applying_the_minimum_income_floor_V9.0.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous data.parliament.uk pdf route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dwp/universal_credit/means_test/minimum_income_floor/self_employed_national_insurance.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://dataportal.orr.gov.uk/statistics/usage/passenger-rail-usage/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous dataportal.orr.gov.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dft/rail/ridership_index.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://doc.ukdataservice.ac.uk/doc/5828/mrdoc/excel/5828_hbai_2425_harmonised_dataset_variables_guide.xlsx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous doc.ukdataservice.ac.uk xlsx route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/household/poverty/absolute_poverty_threshold_ahc.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://docs.google.com/document/d/1Rhrfrg7A_oZHudmA775otAn1EE4-YthgeyS9nL-PrE8/edit?tab=t.0", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous docs.google.com query route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/contrib/behavioral_responses/employee_salary_sacrifice_reduction_rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://fiscalcommission.scot/wp-content/uploads/2026/01/Scotlands-Economic-and-Fiscal-Forecasts-January-2026-revised-13-01-2026.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous fiscalcommission.scot pdf route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/contrib/scotland/scottish_child_payment/under_one.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://github.com/PolicyEngine/microcosm/blob/4163b819402d9417554e0cb4e9ff890973897c24/packages/microcosm-build/src/microcosm/build/uk_runtime/child_benefit_take_up.py#L629", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous github.com fragment route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/hmrc/child_benefit/opt_out_charge_share.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.gov.uk/tax-free-childcare?step-by-step-nav=d78aeaf6-1747-4d72-9619-f16efb4dd89d", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous gov.uk query route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/hmrc/tax_free_childcare/age/disability.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://ifs.org.uk/publications/tax-private-school-fees-and-state-school-spending", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous ifs.org.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/simulation/private_school_vat/private_school_fees.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.infrastructure-ni.gov.uk/publications/public-transport-statistics-northern-ireland-2024-25", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous infrastructure-ni.gov.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dft/bus/boardings_per_trip.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf", + "expected_citation_path": "uk-kingston-upon-thames/manual/council-tax-reduction-scheme-2026-2027", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf", + "expected_citation_path": "uk-merton/manual/council-tax-reduction-scheme-2026-2027", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.networkrail.co.uk/stories/how-train-fares-set/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous networkrail.co.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dft/rail/fare_index.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026", + "expected_citation_path": "uk-newham/manual/council-tax-reduction-scheme-2026-2027", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.nhs.uk/live-well/alcohol-advice/calculating-alcohol-units/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous nhs.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/household/consumption/alcohol/beer_abv.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://obr.uk/docs/dlm_uploads/CGT-supplementary-release-Jan-2025.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous obr.uk pdf route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/simulation/capital_gains_responses/badr_elasticity.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.ofwat.gov.uk/average-bills-2025-26-press-statement/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous ofwat.gov.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous oxford.gov.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/income_band/maximum_support_rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://policyengine.org/uk/research/salary-sacrifice-cap", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous policyengine.org page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py. This is not a claim of corpus-wide absence." + }, + { + "url": "https://publications.parliament.uk/pa/cm200809/cmbills/055/en/09055x-c.htm#:~:text=160,childcare%20when%20securing%20these%20grants", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous publications.parliament.uk fragment route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dfe/care_to_learn/age_limit.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://questions-statements.parliament.uk/written-questions/detail/2026-01-06/103368", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous questions-statements.parliament.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dft/vehicle_excise_duty/expensive_car_supplement.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.rac.co.uk/drive/advice/fuel-watch/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous rac.co.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.racfoundation.org/data/uk-pump-prices-over-time", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous racfoundation.org page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/household/consumption/fuel/prices/diesel.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.socialsecurity.gov.scot/news-events/news/new-payment-rates-from-april-2026", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous socialsecurity.gov.scot page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/supplement.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://spice-spotlight.scot/2024/07/31/scottish-child-payment-who-gets-it/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous spice-spotlight.scot page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/social_security_scotland/scottish_child_payment/takeup_rate/age_6_and_over.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.transport.gov.scot/publication/scottish-transport-statistics-2025/chapter-2-bus-and-coach-travel/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous transport.gov.scot page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dft/bus/boardings_per_trip.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.tvlicensing.co.uk/reducedfee", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous tvlicensing.co.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/dcms/bbc/tv_licence/discount/aged/discount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.westminster.gov.uk/benefits/guide-housing-benefit-and-council-tax-support/income-and-savings", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous westminster.gov.uk page route: no general citation rewrite or unique exact source_url mapping established. PE uk: policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/capital_limit.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.westminster.gov.uk/sites/default/files/media/documents/council-tax-support-scheme-for-2026-27.pdf", + "expected_citation_path": "uk-westminster/manual/council-tax-reduction-scheme-2026-2027", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://azdor.gov/forms/individual/itemized-deduction-adjustments-form#page=1", + "expected_citation_path": "us-az/form/individual-income-tax-forms/azdor.gov/forms/individual/itemized-deduction-adjustments-form", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://casetext.com/regulation/new-jersey-administrative-code/title-18-treasury-taxation/chapter-35-new-jersey-gross-income-tax/subchapter-2-exclusions-and-deductions/section-1835-29-medical-expenses-deduction#:~:text=Section%2018%3A35%2D2.9%20%2D%20Medical%20expenses%20deduction%20(a,two%20percent%20of%20gross%20income.", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous casetext.com fragment route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/nj/tax/income/deductions/medical_expenses/rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://casetext.com/statute/code-of-maryland/article-tax-general/title-10-income-tax/subtitle-2-maryland-taxable-income-calculations-for-individual/part-ii-maryland-adjusted-gross-income/section-10-208-effective-until-712024-subtractions-from-federal-adjusted-gross-income-state-adjustmentshttps://www.marylandtaxes.gov/forms/21_forms/Resident_Booklet.pdf#page=13https://www.marylandtaxes.gov/forms/21_forms/Resident_Booklet.pdf#page=13", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous casetext.com pdf route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/variables/gov/states/md/tax/income/agi/subtractions/md_dependent_care_subtraction.py. This is not a claim of corpus-wide absence." + }, + { + "url": "https://casetext.com/statute/california-codes/california-revenue-and-taxation-code/division-2-other-taxes/part-10-personal-income-tax/chapter-3-computation-of-taxable-income/article-1-definition-of-gross-income-adjusted-gross-income-taxable-income-etc/section-170735-standard-deduction?searchWithin=true&listingIndexId=california-codes.california-revenue-and-taxation-code.division-2-other-taxes.part-10-personal-income-tax", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous casetext.com query route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/ca/tax/income/deductions/standard/amount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.cga.ct.gov/2024/sup/chap_229.htm", + "expected_citation_path": "us-ct/statute/supplements/2024/chapter-229", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://dor.georgia.gov/document/booklet/2021-it-511-individual-income-tax-booklet/download", + "expected_citation_path": "us-ga/form/dor/ty2021/it-511", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://dor.mo.gov/faq/taxation/individual/capital-gains-subtraction.html", + "expected_citation_path": "us-mo/guidance/dor/faq-capital-gains-subtraction", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.ftb.ca.gov/about-ftb/data-reports-plans/Summary-of-Federal-Income-Tax-Changes/index.html#PL-117-2-9631", + "expected_citation_path": "us-ca/guidance/ftb/summary-of-federal-income-tax-changes", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.ftb.ca.gov/about-ftb/data-reports-plans/California-Earned-Income-Tax-Credit-and-Young-Child-Tax-credit-Report.pdf#page=3", + "expected_citation_path": "us-ca/guidance/ftb/reports/california-eitc-and-yctc-report", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.irs.gov/instructions/i1040sca#en_US_2023_publink10005349", + "expected_citation_path": "us/form/irs/ty2025/i1040sca", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.ksrevenue.gov/faqs-taxii.html", + "expected_citation_path": "us-ks/guidance/revenue/faqs-individual-income-tax", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://law.justia.com/codes/alabama/2022/title-40/chapter-18/article-1/section-40-18-15/https://www.revenue.alabama.gov/ultraviewer/viewer/basic_viewer/index.html?form=2023/01/22f40schabdc_blk.pdf#page=1https://www.revenue.alabama.gov/ultraviewer/viewer/basic_viewer/index.html?form=2022/06/21f40schabdc_blk.pdf#page=1", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous law.justia.com fragment route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/variables/gov/states/al/tax/income/deductions/itemized/al_investment_interest.py. This is not a claim of corpus-wide absence." + }, + { + "url": "https://law.justia.com/codes/new-jersey/2022/title-54a/section-54a-3a-17/https://www.state.nj.us/treasury/taxation/pdf/other_forms/tgi-ee/2021/1040i.pdf#page=25", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous law.justia.com pdf route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/variables/gov/states/nj/tax/income/property_tax/nj_potential_property_tax_deduction.py. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.maine.gov/dhhs/ofi/programs-services/tanf", + "expected_citation_path": "us-me/policy/dhhs/ofi/tanf", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.marylandtaxes.gov/new-tax-year-update.php#:~:text=Standard%20Deduction%20%2D%20The%20tax%20year,spouse%2C%20and%20taxpayers%20filing%20jointly.", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous marylandtaxes.gov fragment route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/md/tax/income/deductions/standard/flat_deduction/amount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.michigan.gov/leo/bureaus-agencies/uia/employers/frequently-asked-questions-for-employers/for-employers/taxable-wage-base", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous michigan.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/mi/tax/payroll/unemployment/taxable_wage_base.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.michigan.gov/taxes/iit/accordion/credits/table-a-2022-home-heating-credit-mi-1040cr-7-standard-allowancehttp://www.legislature.mi.gov/(S(keapvg1h2vndkn25rtmpyyse))/mileg.aspx?page=getObject&objectName=mcl-206-527a", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous michigan.gov query route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/variables/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.py. This is not a claim of corpus-wide absence." + }, + { + "url": "https://mtrevenue.gov/montana-tax-simplification-resource-hub/#1", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous mtrevenue.gov fragment route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/mt/tax/income/exemptions/applies.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://mtrevenue.gov/montana-tax-simplification-resource-hub/", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous mtrevenue.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/federal_income_tax/applies.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.nysenate.gov/legislation/laws/TAX/606#QQQ", + "expected_citation_path": "us-ny/statute/TAX/606", + "source": "verified-in-corpus", + "note": "New York corpus/state_adapters/new_york.py:108-111; law ID retains uppercase TAX/ISC/SOS. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://oklahoma.gov/oesc/employers/tax/contribution-rates.html", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous oklahoma.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/ok/tax/payroll/unemployment/default_rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2023_D40_Book_Final_012324#page=59", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous otr.cfo.dc.gov fragment route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/dc/tax/income/credits/kccatc/max_amount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://otr.cfo.dc.gov/release/district-columbia-tax-changes-take-effect-october1-24", + "expected_citation_path": "us-dc/guidance/otr/releases/tax-changes-take-effect-october-1-2024", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://portal.ct.gov/dol/knowledge-base/articles/unemployment-taxes/tax-rates-and-taxable-wage-base", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous portal.ct.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/ct/tax/payroll/unemployment/default_rate.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://revenue.iowa.gov/media/4093/download?inline#page=2", + "expected_citation_path": "us-ia/form/idr/ty2024/41-026a-tax-calculation-worksheet-non-joint", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://revenue.iowa.gov/2023-changes-iowa-individual-income-tax", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous revenue.iowa.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/ia/tax/income/rates/combined.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://revenue.iowa.gov/media/2746/download?inline", + "expected_citation_path": "us-ia/form/idr/ty2023/ia-1040", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://revenue.nebraska.gov/about/2023-nebraska-legislative-changes", + "expected_citation_path": "us-ne/guidance/dor/2023-nebraska-legislative-changes", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.revenue.state.mn.us/education-savings-account-contribution-subtraction", + "expected_citation_path": "us-mn/guidance/department-of-revenue/education-savings-account-contribution-subtraction", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.revenue.wi.gov/Pages/FAQS/pcs-taxrates.aspx", + "expected_citation_path": "us-wi/guidance/dor/tax-rates", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.ssa.gov/OACT/COLA/QC.html", + "expected_citation_path": "us/guidance/ssa/quarter-of-coverage/2026", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://tax.idaho.gov/governance/statutes/irc/", + "expected_citation_path": "us-id/guidance/istc/irc-conformity", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.tax.ny.gov/forms/current-forms/it/it215i.htm#nyc-eic-table", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous tax.ny.gov fragment route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/local/ny/nyc/tax/income/credits/eitc/percent.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://tax.ri.gov/media/18021/download?language=en#page=1", + "expected_citation_path": "us-ri/guidance/tax/adv-2021-53", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://tax.vermont.gov/document/2022-property-tax-credit-calculator", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous tax.vermont.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us: policyengine_us/parameters/gov/states/vt/tax/income/credits/renter/fair_market_rent.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.tax.virginia.gov/deductions", + "expected_citation_path": "us-va/guidance/tax/ty2026/deductions", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://app.leg.wa.gov/RCW/default.aspx?cite=43.216.135", + "expected_citation_path": "us-wa/statute/43/43.216/43.216.135", + "source": "verified-in-corpus", + "note": "Washington RCW corpus/states.py:457-458 includes title, complete chapter, complete section number. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://codes.ohio.gov/ohio-revised-code/section-5747.01#A_31", + "expected_citation_path": "us-oh/statute/5747.01", + "source": "verified-in-corpus", + "note": "Ohio corpus/states.py:276-277 keeps 5747.02 as one native section segment. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://codes.ohio.gov/assets/laws/administrative-code/authenticated/5180/2/16/5180$2-16-01_20221211.pdf", + "expected_citation_path": "us-oh/regulation/agency-5180-2/chapter-5180-2-16/rule-5180-2-16-01--effective-2022-12-11", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://content.dcf.ks.gov/ees/keesm/current/keesm5000.htm#5110", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous content.dcf.ks.gov fragment route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/variables/gov/states/ks/dcf/tanf/eligibility/ks_tanf_resources_eligible.py. This is not a claim of corpus-wide absence." + }, + { + "url": "https://content.dcf.ks.gov/ees/KEESM/Implem_Memo/2026_05_01_ccfpl_increase.html", + "expected_citation_path": "us-ks/manual/dcf/keesm/implementation-memo/2026-05-01-ccfpl-increase", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://content.dcf.ks.gov/ees/KEESM/Appendix/F-1MonthlyFamilyIncomeandFamilyShareDeductionSchedule.pdf", + "expected_citation_path": "us-ks/manual/dcf/keesm/appendix/f-1-monthly-family-income-and-family-share-deduction-schedule", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://content.dcf.ks.gov/ees/KEESM/Intranet/CSFPSitesbyJurisdiction.xlsx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous content.dcf.ks.gov xlsx route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ks/dcf/csfp/counties.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://dssmanuals.mo.gov/december-1973-eligibility-requirements/1035-000-00/1035-005-00/", + "expected_citation_path": "us-mo/manual/dss/december-1973-eligibility-requirements/1035-005-00", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://dssmanuals.mo.gov/wp-content/uploads/2018/10/appendix_k.pdf#page=3", + "expected_citation_path": "us-mo/manual/dss/medicaid/mhabd/appendix-k", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.fns.usda.gov/snap/eligibility/elderly-disabled-special-rules#Who%20is%20elderly?", + "expected_citation_path": "us/guidance/usda/fns/snap-elderly-disabled-special-rules", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.fns.usda.gov/csfp/additional-caseload-assignments-2021-caseload-cycle", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous fns.usda.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/usda/csfp/amount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.fns.usda.gov/sites/default/files/media/file/FY21-COVID%20ARPA-Maximum-Allotments-Deductions.pdf", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous fns.usda.gov pdf route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/usda/snap/max_allotment.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.fns.usda.gov/sites/default/files/resource-files/2024-09-30-SUA-Table-FY25-508-Update.xlsx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous fns.usda.gov xlsx route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/usda/snap/income/deductions/utility/limited/active.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://liheapch.acf.gov/docs/2025/benefits-matricies/NY_BenefitMatrix_2025.docx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous liheapch.acf.gov docx route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ny/otda/heap/payment/base.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://liheapch.acf.gov/tables/subsidize.htm#MA", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous liheapch.acf.gov fragment route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ma/doer/liheap/eligibility/rent_threshold_subsidized_housing.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://liheapch.acf.gov/profiles/Kansas.htm", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous liheapch.acf.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ks/dcf/liheap/payment/min_benefit.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://liheapch.acf.gov/docs/2023/benefits-matricies/NY_BenefitMatrix_2023.pdf#page=1", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous liheapch.acf.gov pdf route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ny/otda/heap/payment/tier_one_supplement.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://liheapch.acf.gov/docs/2025/benefits-matricies/KY_BenefitMatrix_2025.xlsx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous liheapch.acf.gov xlsx route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ky/dcbs/liheap/fpg_year_lag.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.maine.gov/sos/sites/maine.gov.sos/files/content/assets/144c332-appendices-charts.docx", + "expected_citation_path": "us-me/regulation/dhhs/ofi/chapter-332/appendices-charts", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.nj.gov/treasury/taxation/relief.shtml#collapsePAS", + "expected_citation_path": "us-nj/guidance/taxation/property-tax-relief-programs", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.pa.gov/agencies/dhs/resources/cash-assistance/tanf", + "expected_citation_path": "us-pa/guidance/dhs/cash-assistance-tanf", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.pa.gov/content/dam/copapwp-pagov/en/dhs/documents/services/assistance/documents/tanf/tanf-state-plan-effective-october-1-2024.pdf#page=13", + "expected_citation_path": "us-pa/policy/acf/tanf-plan/2024", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://www.tax.nd.gov/prc", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous tax.nd.gov page route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/nd/tax/property/primary_residence_credit/amount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://www.tax.nd.gov/sites/www/files/documents/forms/individual/2021-iit/2021-individual-income-tax-booklet.pdf#page=16", + "expected_citation_path": "us-nd/form/otc/ty2021/individual-income-tax-booklet", + "source": "verified-in-corpus", + "note": "Exact curated document source URL identifies one root via corpus/documents.py:3810-3817; fragment stays a page/HTML locator. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://tax.vermont.gov/sites/tax/files/documents/2024%20Renter%20Credit%20Website%20Calculator.xlsx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous tax.vermont.gov xlsx route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/vt/tax/income/credits/renter/fair_market_rent.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://web.archive.org/web/20230312001925/https://www.maine.gov/sos/cec/rules/10/148/148c006.docx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous web.archive.org docx route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/me/dhhs/ccap/age_limit/special_needs.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://web.archive.org/web/20221020033542/https://www.tax.ny.gov/pit/child-earned-payments.htm#estimate", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous web.archive.org fragment route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ny/tax/income/credits/ctc/additional/amount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "http://web.archive.org/web/20260901174900/https://jobs.utah.gov/Infosource/eligibilitymanual/Tables,_Appendicies,_and_Charts/Tables,_Appendicies,_and_Charts/Table_4_-_Child_Care_Income_Eligibility_and_Co-Payment.htm", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous web.archive.org page route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ut/dwf/ccap/copay/amount.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "http://web.archive.org/web/20090815162830/http://www.arkansas.gov/dfa/income_tax/documents/208-GamblingIncomeExpenses.pdf#page=1", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous web.archive.org pdf route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ar/tax/income/deductions/itemized/sources/individual.yaml. This is not a claim of corpus-wide absence." + }, + { + "url": "https://web.archive.org/web/20220523051049/https://rules.mt.gov/gateway/Subchapterhome.asp?scn=42%2E15%2E2", + "expected_citation_path": "us-mt/regulation/title-42/chapter-42-15/subchapter-42-15-2", + "source": "verified-in-corpus", + "note": "Unwrap archived original URL; Montana legacy gateway scn carries native subchapter number; modern recursive numeric hierarchy is emitted at corpus/montana_admin_rules.py:725. Archive timestamp remains version metadata. Dates/fragments remain separate locators. Curated aliases select a snapshot root rather than inferring a publication's tax year. Record existence is audited in corpus-evidence.json." + }, + { + "url": "https://web.archive.org/web/20221124121428/https://portal.ct.gov/-/media/Departments-and-Agencies/DSS/Fact-Sheets-and-Issue-Briefs/Fact-Sheets/DSS-Program-Standards-Chart-Effective-7-1-2022-1.xlsx", + "expected_citation_path": null, + "source": "rule", + "note": "Conservative null for unsupported or ambiguous web.archive.org xlsx route: no general citation rewrite or unique exact source_url mapping established. PE us-upstream: policyengine_us/parameters/gov/states/ct/dss/ssp/disregard/unearned.yaml. This is not a claim of corpus-wide absence." + } +] diff --git a/axiom-parity/tests/test_axiom_line.py b/axiom-parity/tests/test_axiom_line.py new file mode 100644 index 0000000..3ed0f27 --- /dev/null +++ b/axiom-parity/tests/test_axiom_line.py @@ -0,0 +1,273 @@ +from hypothesis import given, settings +from hypothesis import strategies as st + +from axiom_parity.axiom_line import ( + ENCODED, + ENCODED_CORRECT, + NA, + NEEDED, + QUEUED, + find_lines, + parse_body, + parse_line, +) + + +def statuses(body): + return [c.status for line in parse_body(body) for c in line.claims] + + +def test_each_canonical_form(): + assert statuses("axiom: TheAxiomFoundation/rulespec-us#1416 queued") == [QUEUED] + assert statuses("axiom: TheAxiomFoundation/rulespec-us#1500 encoded") == [ENCODED] + assert statuses("axiom: us:statutes/26/24/d#rule encoded-correct (`d.test.yaml::case_one`)") == [ENCODED_CORRECT] + assert statuses("axiom: n/a: data: calibration targets only") == [NA] + assert statuses("axiom: needed") == [NEEDED] + + +def test_markdown_decoration_and_case(): + assert statuses("- **Axiom:** TheAxiomFoundation/rulespec-uk#430 queued") == [QUEUED] + assert statuses("> axiom: rulespec-uk#430 queued") == [QUEUED] + + +def test_template_placeholder_is_a_problem_outside_fences(): + body = "axiom: <legal id> encoded-correct | <rulespec-uk PR> encoded | <rulespec-uk issue> queued | n/a: <reason>" + (line,) = parse_body(body) + assert line.problems and not line.claims + + +def test_template_inside_html_comment_or_fence_is_ignored(): + body = "<!-- axiom: <legal id> encoded-correct -->\n```text\naxiom: <rulespec issue> queued # example\n```\n" + assert parse_body(body) == [] + + +def test_fenced_real_line_counts(): + body = "Notes\n\n```\naxiom: TheAxiomFoundation/rulespec-uk#396 queued (reg 89)\n```\n" + assert statuses(body) == [QUEUED] + + +def test_several_claims_on_one_line(): + rest = ( + "`uk/regulation/uksi/2013/376/32` (1)(b)(i) encoded-correct (`32.test.yaml` `couple_case`); " + "TheAxiomFoundation/rulespec-uk#430 queued (reg 32(2) for the other joint claimant)" + ) + line = parse_line(rest) + assert [c.status for c in line.claims] == [ENCODED_CORRECT, QUEUED] + assert line.claims[1].rulespec_refs[0].number == 430 + assert line.claims[0].rulespec_refs == [] + + +def test_pipe_and_sentence_separated_claims(): + line = parse_line( + "uk:statutes/ukpga/1992/4/15 encoded-correct (`15.test.yaml::case_a`) | rulespec-uk#351 queued (s.15(3))" + ) + assert [c.status for c in line.claims] == [ENCODED_CORRECT, QUEUED] + line = parse_line( + "TheAxiomFoundation/rulespec-uk#366 queued (s150A). uk:regulations/uksi/2026/148/article/6 is " + "already encoded-correct (`6.test.yaml`)." + ) + assert [c.status for c in line.claims] == [QUEUED, ENCODED_CORRECT] + + +def test_a_status_in_prose_is_noted_not_claimed(): + # The status must follow what it is claimed for; prose around it is not read as a claim. + line = parse_line( + "TheAxiomFoundation/rulespec-uk#366 queued (s150A). The in-force rates are already encoded-correct: " + "uk:regulations/uksi/2026/148/article/6 (`6.test.yaml`)." + ) + assert [c.status for c in line.claims] == [QUEUED] + assert any("encoded-correct" in n and "not read as a claim" in n for n in line.notes) + + +def test_statusless_sentence_joins_previous_claim(): + line = parse_line( + "`us:statutes/26/24/d#ctc_social_security_tax` encoded-correct. Its companion test is " + "`three_children_social_security_excess`. TheAxiomFoundation/rulespec-us#1484 queued." + ) + assert [c.status for c in line.claims] == [ENCODED_CORRECT, QUEUED] + assert any(t.case == "three_children_social_security_excess" for t in line.claims[0].test_refs) + + +def test_negated_status_words_are_prose(): + line = parse_line("TheAxiomFoundation/rulespec-us#1497 queued (the amendment is not yet encoded in the pin)") + assert [c.status for c in line.claims] == [QUEUED] + line = parse_line("the 2015-2025 years are not encoded, see rulespec-us#1 queued") + assert [c.status for c in line.claims] == [QUEUED] + + +def test_status_inside_parentheses_is_a_note(): + line = parse_line( + "uk:regulations/uksi/2013/376/66 (exclusion deferred, queued in rulespec-uk#392) encoded-correct (`66.test.yaml`)" + ) + assert [c.status for c in line.claims] == [ENCODED_CORRECT] + + +def test_legacy_debt_means_queued(): + (claim,) = parse_line("TheAxiomFoundation/rulespec-us#1405 debt").claims + assert claim.status == QUEUED and claim.legacy_keyword == "debt" + + +def test_na_with_a_queued_part_keeps_both_claims(): + line = parse_line( + "n/a for the published rates themselves (inputs). The determination rules are queued: " + "TheAxiomFoundation/rulespec-uk#415 queued" + ) + assert [c.status for c in line.claims] == [NA, QUEUED] + assert line.claims[1].rulespec_refs[0].number == 415 + + +def test_na_is_a_claim_wherever_it_sits(): + assert [c.status for c in parse_line("n/a: metadata; rulespec-us#1416 queued").claims] == [NA, QUEUED] + assert [c.status for c in parse_line("rulespec-us#1416 queued; n/a: metadata").claims] == [QUEUED, NA] + assert [c.status for c in parse_line("rulespec-us#1416 queued | n/a: data: targets").claims] == [QUEUED, NA] + + +def test_modal_and_future_status_words_are_prose(): + for prose in ( + "rulespec-us#1 is not actually currently ready to be queued", + "rulespec-us#1 might be queued", + "rulespec-us#1 will be queued once the corpus lands", + "us:statutes/26/1 should be encoded-correct", + ): + assert parse_line(prose).claims == [], prose + assert [c.status for c in parse_line("rulespec-us#1 is queued").claims] == [QUEUED] + + +def test_na_reason_extracted(): + (claim,) = parse_line("n/a: microsim-only: changes labour supply responses").claims + assert claim.na_reason == "microsim-only: changes labour supply responses" + (claim,) = parse_line("n/a — reference metadata only").claims + assert claim.na_reason == "reference metadata only" + + +def test_bullet_continuation_inherits_status(): + body = ( + "axiom: `uk/regulations/uksi/2013/376/18` encoded-correct (`single_claimant_case`). The rest is queued:\n" + "- TheAxiomFoundation/rulespec-uk#355 (addendum) queues reg 3 itself;\n" + "- TheAxiomFoundation/rulespec-uk#428 queues reg 78(2).\n" + "All are dispatch-ready.\n" + ) + (line,) = parse_body(body) + assert [c.status for c in line.claims] == [ENCODED_CORRECT, QUEUED, QUEUED] + assert [c.rulespec_refs[0].number for c in line.claims[1:]] == [355, 428] + + +def test_prose_line_starting_with_axiom_is_a_note_not_a_problem(): + (line,) = parse_body("- **Axiom: reg 6(4)(b) is subject to reg 5(4)-(5).** Now explicit in output 9.") + assert not line.claims and not line.problems and line.notes + + +def test_line_with_a_ref_but_no_status_is_a_problem(): + (line,) = parse_body("axiom: TheAxiomFoundation/rulespec-us#1416") + assert line.problems + + +def test_line_numbers(): + body = "Fixes #1\n\n## Axiom\n\naxiom: rulespec-us#1 queued\n" + assert find_lines(body)[0][0] == 5 + + +# Properties ------------------------------------------------------------ + +REF = st.integers(min_value=1, max_value=99999).map(lambda n: f"TheAxiomFoundation/rulespec-us#{n}") +NOTE = st.text(alphabet="abcdefghij klmnop", min_size=0, max_size=30).map(lambda t: f" ({t})" if t.strip() else "") +CLAIM = st.tuples(REF, st.sampled_from(["queued", "encoded", "debt"]), NOTE) +SEP = st.sampled_from(["; ", " | ", " || "]) + + +@given(st.lists(CLAIM, min_size=1, max_size=6), SEP) +@settings(max_examples=300) +def test_property_claims_round_trip(claims, sep): + """Rendering N claims and parsing them back recovers each status and ref, in order.""" + rest = sep.join(f"{ref} {status}{note}" for ref, status, note in claims) + parsed = parse_line(rest).claims + want = [("queued" if s == "debt" else s) for _, s, _ in claims] + assert [c.status for c in parsed] == want + assert [c.rulespec_refs[0].slug for c in parsed] == [ref for ref, _, _ in claims] + + +@given(st.text(max_size=400)) +@settings(max_examples=500) +def test_property_parser_never_raises_and_is_deterministic(text): + a = parse_body("axiom: " + text) + b = parse_body("axiom: " + text) + assert [(c.status, c.text) for line in a for c in line.claims] == [ + (c.status, c.text) for line in b for c in line.claims + ] + + +@given(st.text(alphabet=st.characters(blacklist_characters="\n"), max_size=200)) +@settings(max_examples=300) +def test_property_text_without_axiom_prefix_has_no_lines(text): + if "axiom" in text.lower(): + return + assert parse_body(text) == [] + + +ASIDE = st.from_regex(r"[a-z]{1,8}( [a-z]{1,8}){0,3}", fullmatch=True).filter( + lambda s: not any(w in s.split() for w in ("queued", "encoded", "debt", "and", "but", "though", "while")) +) + + +@given( + ASIDE, + st.sampled_from(["is not ({a}) queued", "is not, {a}, queued", "isn't ({a}) queued", "is not yet ({a}) queued"]), +) +@settings(max_examples=200) +def test_property_an_aside_does_not_cancel_a_negation(aside, wording): + line = parse_line("rulespec-us#1416 " + wording.format(a=aside)) + assert not line.claims + + +def test_a_denial_anywhere_before_the_status_in_its_piece_means_no_claim(): + # A status is claimed only when it follows its reference and nothing before it denies it. + for line in ( + "rulespec-us#1416 (not yet) queued", + "rulespec-us#1416 (not #1417) queued", + "Not in Axiom yet, rulespec-us#1416 queued", + "Not (yet) in Axiom: rulespec-us#1416 queued", + "rulespec-us#1416 is not (yet) encoded but queued", + ): + assert not parse_line(line).claims, line + # Each status is judged in its own piece, so a denied claim doesn't take its neighbour with it. + assert [c.status for c in parse_line("rulespec-us#9 is not encoded, rulespec-us#1416 queued").claims] == [QUEUED] + claims = parse_line("rulespec-us#9 is not encoded, rulespec-us#1416 queued").claims + assert [r.number for r in claims[0].rulespec_refs] == [1416] + + +def test_bold_na_after_a_claim_is_still_a_claim(): + claims = parse_line("rulespec-us#1416 queued and **n/a**: metadata: update labels").claims + assert [c.status for c in claims] == [QUEUED, NA] + assert claims[1].na_reason == "metadata: update labels" + + +def test_adjective_with_an_adverb_is_not_a_claim(): + claims = parse_line("rulespec-us#1416 queued. The already queued federal rule covers the rest.").claims + assert [c.status for c in claims] == [QUEUED] + + +@given( + ASIDE, + st.sampled_from( + [ + "is not ({a}) **queued**", + "is not **queued**", + "is not, {a} Oct. 10, queued", + "is *not* ({a}) queued", + "will ({a}) be queued", + "may, {a}, be __queued__", + ] + ), +) +@settings(max_examples=200) +def test_property_emphasis_and_dated_asides_do_not_make_a_denial_a_claim(aside, wording): + assert not parse_line("rulespec-us#1416 " + wording.format(a=aside)).claims + + +def test_a_denial_ends_with_its_own_piece(): + assert [c.status for c in parse_line("rulespec-us#9 is not (yet) encoded; rulespec-us#1416 **queued**").claims] == [ + QUEUED + ] + assert not parse_line("rulespec-us#1416 will (per rulespec-us#5) be queued").claims + assert not parse_line("rulespec-us#1416 is not, as of rulespec-us#5, queued").claims + assert [c.status for c in parse_line("It is not, in fact, encoded, rulespec-us#1416 queued").claims] == [QUEUED] diff --git a/axiom-parity/tests/test_behaviour.py b/axiom-parity/tests/test_behaviour.py new file mode 100644 index 0000000..cf36485 --- /dev/null +++ b/axiom-parity/tests/test_behaviour.py @@ -0,0 +1,187 @@ +from hypothesis import given, settings +from hypothesis import strategies as st + +from axiom_parity.behaviour import changes_behaviour + +PARAM = """description: Personal allowance. +values: + 2025-04-06: 12570 +metadata: + unit: currency-GBP + label: Personal allowance + reference: + - title: ITA 2007 s.35 + href: https://www.legislation.gov.uk/ukpga/2007/3/section/35 +""" + +VARIABLE = '''from policyengine_uk.model_api import * + + +class personal_allowance(Variable): + """The personal allowance.""" + + value_type = float + entity = Person + label = "Personal allowance" + reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/35" + definition_period = YEAR + + def formula(person, period, parameters): + return parameters(period).gov.hmrc.income_tax.allowances.personal_allowance.amount +''' + + +def test_metadata_only_parameter_change(): + after = PARAM.replace("ITA 2007 s.35", "Income Tax Act 2007 s.35").replace("Personal allowance.", "The allowance.") + assert not changes_behaviour("p/parameters/gov/x.yaml", PARAM, after) + + +def test_parameter_value_change(): + assert changes_behaviour("p/parameters/gov/x.yaml", PARAM, PARAM.replace("12570", "12571")) + + +def test_parameter_uprating_change_counts(): + after = PARAM.replace(" unit: currency-GBP", " unit: currency-GBP\n uprating: gov.economic_assumptions.cpi") + assert changes_behaviour("p/parameters/gov/x.yaml", PARAM, after) + + +def test_variable_label_reference_and_docstring_changes(): + after = ( + VARIABLE.replace('label = "Personal allowance"', 'label = "PA"') + .replace("section/35", "section/35/2") + .replace('"""The personal allowance."""', '"""Personal allowance under ITA 2007."""') + ) + assert not changes_behaviour("p/variables/gov/x.py", VARIABLE, after) + + +def test_variable_formula_change(): + after = VARIABLE.replace("personal_allowance.amount", "personal_allowance.amount * 2") + assert changes_behaviour("p/variables/gov/x.py", VARIABLE, after) + + +def test_variable_definition_period_change_counts(): + assert changes_behaviour("p/variables/gov/x.py", VARIABLE, VARIABLE.replace("YEAR", "MONTH")) + + +def test_add_delete_and_markdown(): + assert changes_behaviour("p/parameters/gov/new.yaml", None, PARAM) + assert changes_behaviour("p/parameters/gov/old.yaml", PARAM, None) + assert not changes_behaviour("p/parameters/gov/README.md", "a", "b") + + +def test_unparseable_counts_as_change(): + assert changes_behaviour("p/variables/x.py", VARIABLE, "def (:") + + +SCALARS = st.one_of( + st.integers(-(10**6), 10**6), st.floats(allow_nan=False, allow_infinity=False, width=32), st.booleans() +) + + +@given(st.dictionaries(st.dates().map(str), SCALARS, min_size=1, max_size=5), st.text(min_size=1, max_size=40)) +@settings(max_examples=200) +def test_property_descriptive_edits_never_count(values, label): + import yaml + + doc = {"description": "x", "values": values, "metadata": {"label": "a", "unit": "/1"}} + before = yaml.safe_dump(doc) + doc2 = { + "description": label, + "values": values, + "metadata": {"label": label, "unit": "currency-USD", "reference": [{"href": label}]}, + } + assert not changes_behaviour("p/parameters/x.yaml", before, yaml.safe_dump(doc2)) + assert not changes_behaviour("p/parameters/x.yaml", before, before) + + +@given(st.dictionaries(st.dates().map(str), st.integers(0, 10**6), min_size=1, max_size=5), st.integers(1, 10**6)) +@settings(max_examples=200) +def test_property_value_edits_always_count(values, bump): + import yaml + + before = yaml.safe_dump({"values": values}) + key = sorted(values)[0] + after = yaml.safe_dump({"values": {**values, key: values[key] + bump}}) + assert changes_behaviour("p/parameters/x.yaml", before, after) + + +def test_year_zero_dates_and_reference_moves(): + before = "values:\n 0000-01-01: false\n 2018-01-01: true\nmetadata:\n reference:\n - href: a\n" + after = before.replace("href: a", "href: b") + assert not changes_behaviour("p/parameters/x.yaml", before, after) + assert changes_behaviour("p/parameters/x.yaml", before, before.replace("2018-01-01: true", "2018-01-01: false")) + + +def test_reference_moved_into_value_metadata(): + before = "values:\n 2013-01-01:\n value: 3_900\n reference:\n - href: rp-13-15\n" + after = "values:\n 2013-01-01:\n value: 3_900\n metadata:\n reference:\n - href: rp-13-15\n" + assert not changes_behaviour("p/parameters/x.yaml", before, after) + assert not changes_behaviour( + "p/parameters/x.yaml", "values:\n 2013-01-01: 3900\n", "values:\n 2013-01-01:\n value: 3900\n" + ) + + +def test_only_the_value_of_a_dated_entry_counts(): + before = "values:\n 2024-08-01:\n value: 20_600\n refrence:\n - href: a\n" + after = "values:\n 2024-08-01:\n value: 20_600\n metadata:\n reference:\n - href: a\n" + assert not changes_behaviour("p/parameters/x.yaml", before, after) + assert changes_behaviour("p/parameters/x.yaml", before, before.replace("20_600", "20_700")) + + +def test_a_formula_local_named_label_is_code_not_metadata(): + def source(value): + return f"class benefit:\n def formula(person, period, parameters):\n label = {value}\n return label\n" + + assert changes_behaviour("p/variables/gov/x.py", source(0), source(1)) + # The class attribute of the same name is still descriptive. + cls = 'class benefit:\n label = "{}"\n def formula(person, period, parameters):\n return 1\n' + assert not changes_behaviour("p/variables/gov/x.py", cls.format("a"), cls.format("b")) + + +@given(st.integers(-100, 100), st.integers(1, 100), st.sampled_from(["label", "documentation", "reference"])) +def test_property_changed_formula_local_always_counts(n, bump, name): + def source(value): + return f"class benefit:\n def formula(person, period, parameters):\n {name} = {value}\n return {name}\n" + + assert changes_behaviour("p/variables/gov/x.py", source(n), source(n + bump)) + + +def test_bracket_thresholds_keyed_by_date(): + before = "brackets:\n - threshold:\n 2024-08-01:\n value: 20_600\n refrence:\n - href: a\n rate:\n 2024-08-01: 0.22\n" + after = before.replace( + " refrence:\n - href: a\n", " metadata:\n reference:\n - href: a\n" + ) + assert not changes_behaviour("p/parameters/x.yaml", before, after) + assert changes_behaviour("p/parameters/x.yaml", before, before.replace("0.22", "0.25")) + assert changes_behaviour("p/parameters/x.yaml", before, before.replace("20_600", "20_700")) + + +def test_a_bare_reference_dict_in_a_class_body_is_inert(): + before = ( + 'class v(Variable):\n value_type = float\n dict(title="t", href="h")\n defined_for = StateCode.NY\n' + ) + after = before.replace(" dict(", " reference = dict(") + assert not changes_behaviour("p/variables/x.py", before, after) + # A call that could do something still counts. + assert changes_behaviour("p/variables/x.py", before, before.replace('dict(title="t", href="h")', "register(v)")) + + +def test_a_rebound_dict_is_not_assumed_inert(): + def source(n): + return ( + "v = 0\n" + "def dict(**kw):\n global v\n v = kw['n']\n" + f"class benefit:\n dict(n={n})\n def formula():\n return v\n" + ) + + assert changes_behaviour("p/variables/x.py", source(0), source(1)) + + +def test_a_star_import_may_rebind_dict(): + before = "from helpers import *\n\n\nclass v(Variable):\n dict(n=0)\n" + assert changes_behaviour("v.py", before, before.replace("n=0", "n=1")) + plain = "class v(Variable):\n dict(n=0)\n" + assert not changes_behaviour("v.py", plain, plain.replace("n=0", "n=1")) + # The preamble of every PolicyEngine variable file is known not to bind dict. + preamble = "from policyengine_us.model_api import *\n\n\nclass v(Variable):\n dict(n=0)\n" + assert not changes_behaviour("v.py", preamble, preamble.replace("n=0", "n=1")) diff --git a/axiom-parity/tests/test_check.py b/axiom-parity/tests/test_check.py new file mode 100644 index 0000000..ef7c399 --- /dev/null +++ b/axiom-parity/tests/test_check.py @@ -0,0 +1,413 @@ +import copy + +from conftest import RS_US, FakeHub +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from axiom_parity.check import Context, run_check +from axiom_parity.policy_paths import PolicyPaths + +PATHS = PolicyPaths("policyengine_us") +POLICY = ["policyengine_us/parameters/gov/irs/credits/ctc/amount.yaml"] +PARAM = "values:\n 2026-01-01: 2200\nmetadata:\n label: CTC\n" + + +def run(hub, body, files=POLICY, mode="enforce", diff=None): + ctx = Context(gh=hub.client(), default_rulespec=RS_US, read_diff=diff) + return run_check( + repo="PolicyEngine/policyengine-us", pr=1, body=body, changed_files=files, ctx=ctx, paths=PATHS, mode=mode + ) + + +def codes(report): + return {f.code for f in report.findings if f.blocking} | { + f.code for c in report.claims for f in c.findings if f.blocking + } + + +def test_no_policy_files_needs_no_line(hub): + r = run(hub, "Fixes a test.", files=["policyengine_us/tests/x.yaml", "docs/a.md"]) + assert not r.applies and not r.would_fail + + +def test_missing_line_fails(hub): + r = run(hub, "Fixes #1. Updates the CTC.") + assert r.would_fail and codes(r) == {"missing"} + + +def test_queued_ready_issue_passes(hub): + r = run(hub, "axiom: TheAxiomFoundation/rulespec-us#1416 queued") + assert not r.would_fail, codes(r) + + +def test_queued_needs_label_and_readiness(hub): + hub.add_issue(RS_US, 2, labels=()) + hub.add_issue(RS_US, 3, body="## Encoding debt\n\nTODO") + assert codes(run(hub, "axiom: rulespec-us#2 queued")) == {"no-pe-parity-label"} + assert codes(run(hub, "axiom: rulespec-us#3 queued")) == {"not-dispatch-ready"} + + +def test_queued_readiness_can_come_from_comments(hub): + body = hub.issues[(RS_US, 1416)]["body"] + head, _, tail = body.partition("## review_finding") + hub.add_issue(RS_US, 4, body=head, comments=["## review_finding" + tail]) + assert not run(hub, "axiom: rulespec-us#4 queued").would_fail + + +def test_queued_closed_not_planned_fails(hub): + hub.add_issue(RS_US, 5, state="closed", state_reason="not_planned") + assert "closed-not-planned" in codes(run(hub, "axiom: rulespec-us#5 queued")) + + +def test_queued_pull_or_missing_issue_fails(hub): + hub.add_pull(RS_US, 6) + assert codes(run(hub, "axiom: rulespec-us#6 queued")) == {"queued-no-issue"} + assert codes(run(hub, "axiom: rulespec-us#999 queued")) == {"not-found"} + assert codes(run(hub, "axiom: queued")) == {"malformed-line"} or "queued-no-issue" in codes( + run(hub, "axiom: queued") + ) + + +def test_foreign_repo_rejected(hub): + assert "foreign-repo" in codes(run(hub, "axiom: someone/rulespec-us#1416 queued")) + + +def test_encoded(hub): + hub.add_pull(RS_US, 10, merged=True, state="closed") + hub.add_pull(RS_US, 11, state="open") + hub.add_pull(RS_US, 12, state="closed", merged=False) + hub.add_pull(RS_US, 13, merged=True, state="closed", files=["us/statutes/x.yaml"]) + assert not run(hub, "axiom: rulespec-us#10 encoded").would_fail + assert not run(hub, "axiom: rulespec-us#11 encoded").would_fail + assert codes(run(hub, "axiom: rulespec-us#12 encoded")) == {"encoded-closed"} + assert codes(run(hub, "axiom: rulespec-us#1416 encoded")) == {"encoded-no-pr"} + r = run(hub, "axiom: rulespec-us#13 encoded") + assert not r.would_fail + assert any(f.code == "no-encoding-manifest" for c in r.claims for f in c.findings) + + +def test_encoded_correct_passes_with_exercising_case(hub): + body = ( + "axiom: `us:statutes/26/24/d#ctc_social_security_tax` encoded-correct (`three_children_social_security_excess`)" + ) + r = run(hub, body) + assert not r.would_fail, codes(r) + assert any("outputs us:statutes/26/24/d" in e for c in r.claims for e in c.evidence) + + +def test_encoded_correct_with_test_file_and_corpus_form(hub): + body = "axiom: us/statute/26/24/d encoded-correct (rulespec-us `us/statutes/26/24/d.test.yaml`)" + assert not run(hub, body).would_fail + body = "axiom: us/statute/26/24/d/1/A encoded-correct (`d.test.yaml::three_children_social_security_excess`)" + r = run(hub, body) + assert not r.would_fail, codes(r) + assert any("nearest module" in e for c in r.claims for e in c.evidence) + + +def test_encoded_correct_failures(hub): + assert codes( + run(hub, "axiom: us:statutes/26/24/d#nope encoded-correct (`three_children_social_security_excess`)") + ) == {"rule-not-found"} + assert codes(run(hub, "axiom: us:statutes/26/24/d encoded-correct")) == {"encoded-correct-no-test"} + assert codes(run(hub, "axiom: us:statutes/26/24/d encoded-correct (`unrelated_case`)")) == { + "test-does-not-exercise" + } + assert "module-not-found" in codes(run(hub, "axiom: us:statutes/26/9999 encoded-correct (`x_y_z`)")) + assert codes(run(hub, "axiom: 26 USC 24(d) encoded-correct")) == {"encoded-correct-no-module"} + + +def test_na(hub): + assert not run(hub, "axiom: n/a: data: calibration targets").would_fail + assert codes(run(hub, "axiom: n/a: rulespec-us does not encode this yet")) == {"na-category"} + assert codes(run(hub, "axiom: n/a: data: x\naxiom: rulespec-us#1416 queued")) == {"na-combined"} + + +def test_na_metadata_is_checked_against_the_diff(hub): + files = {("base", POLICY[0]): PARAM} + + def diff(path, side): + return files.get((side, path)) + + files[("head", POLICY[0])] = PARAM.replace("label: CTC", "label: Child tax credit") + assert not run(hub, "axiom: n/a: reference-only", diff=diff).would_fail + files[("head", POLICY[0])] = PARAM.replace("2200", "2500") + assert codes(run(hub, "axiom: n/a: reference-only", diff=diff)) == {"na-changes-behaviour"} + + +def test_needed_never_passes(hub): + assert codes(run(hub, "axiom: needed")) == {"needed"} + + +def test_placeholder_fails(hub): + assert codes(run(hub, "axiom: <legal id> encoded-correct | <rulespec-us issue> queued")) == {"malformed-line"} + + +def test_multi_claim_all_must_pass(hub): + hub.add_issue(RS_US, 2, labels=()) + ok = "axiom: rulespec-us#1416 queued; `us:statutes/26/24/d#ctc_social_security_tax` encoded-correct (`three_children_social_security_excess`)" + assert not run(hub, ok).would_fail + assert run(hub, ok + "; rulespec-us#2 queued").would_fail + + +def test_non_policy_pr_with_bad_line_only_warns(hub): + r = run(hub, "axiom: rulespec-us#999 queued", files=["docs/x.md"]) + assert not r.would_fail + assert any(f.level == "warning" for c in r.claims for f in c.findings) + + +# Properties ------------------------------------------------------------ + + +def _hub(): + h = FakeHub() + from conftest import MODULE_D, TEST_D + + h.add_file(RS_US, "us/statutes/26/24/d.yaml", MODULE_D) + h.add_file(RS_US, "us/statutes/26/24/d.test.yaml", TEST_D) + h.add_file(RS_US, "us/statutes/26/1/h.yaml", "rules:\n - name: net_capital_gain\n") + h.add_issue(RS_US, 1416) + h.add_issue(RS_US, 2, labels=()) + h.add_pull(RS_US, 10, merged=True, state="closed") + h.add_pull(RS_US, 12, state="closed") + return h + + +GOOD = [ + "rulespec-us#1416 queued", + "rulespec-us#10 encoded", + "`us:statutes/26/24/d#ctc_social_security_tax` encoded-correct (`three_children_social_security_excess`)", +] +BAD = [ + "rulespec-us#2 queued", + "rulespec-us#12 encoded", + "rulespec-us#999 queued", + # A module no cited test outputs, whatever else shares its line. + "us:statutes/26/1/h encoded-correct", +] +BODIES = st.lists(st.sampled_from(GOOD + BAD), min_size=0, max_size=5) + + +@given(BODIES, st.booleans()) +@settings(max_examples=150, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_property_verdict_is_conjunction_of_claims(claims, one_line): + """A policy PR passes iff it has at least one claim and every claim is a good one.""" + hub = _hub() + body = ("axiom: " + "; ".join(claims)) if one_line else "\n".join(f"axiom: {c}" for c in claims) + r = run(hub, body if claims else "no line") + assert r.would_fail == (not claims or any(c in BAD for c in claims)) + + +@given(BODIES) +@settings(max_examples=100, deadline=None) +def test_property_warn_and_enforce_agree_on_blocking(claims): + """Differential: warn mode changes how findings are shown, never which ones block.""" + body = "\n".join(f"axiom: {c}" for c in claims) + a = run(_hub(), body, mode="enforce") + b = run(_hub(), body, mode="warn") + assert a.would_fail == b.would_fail + assert sorted(codes(a)) == sorted(codes(b)) + assert not b.errors # nothing is shown as an error in warn mode + + +@given(BODIES, st.sampled_from(GOOD)) +@settings(max_examples=100, deadline=None) +def test_property_adding_a_good_claim_never_breaks_a_passing_pr(claims, extra): + body = "\n".join(f"axiom: {c}" for c in claims) + before = run(_hub(), body) + after = run(_hub(), body + f"\naxiom: {extra}") + if not before.would_fail: + assert not after.would_fail + + +@given(BODIES) +@settings(max_examples=60, deadline=None) +def test_property_check_is_deterministic(claims): + body = "\n".join(f"axiom: {c}" for c in claims) + a, b = run(_hub(), body).to_json(), run(_hub(), body).to_json() + a.pop("api_calls"), b.pop("api_calls") + assert a == b + + +def test_reads_are_cached(hub): + run(hub, "axiom: rulespec-us#1416 queued; rulespec-us#1416 queued") + assert hub.calls.count(f"/repos/{RS_US}/issues/1416") == 1 + + +def test_report_json_round_trip(hub): + import json + + r = run(hub, "axiom: rulespec-us#1416 queued") + assert json.loads(json.dumps(r.to_json()))["claims"][0]["status"] == "queued" + copy.deepcopy(r) + + +def test_cli_crash_is_a_warning_in_warn_mode(monkeypatch, tmp_path, capsys): + from axiom_parity import cli + + def boom(args): + raise RuntimeError("API down") + + monkeypatch.setattr(cli, "_check", boom) + summary = tmp_path / "s.md" + args = cli.build_parser().parse_args( + ["check", "--repo", "PolicyEngine/policyengine-us", "--pr", "1", "--mode", "warn", "--summary", str(summary)] + ) + assert cli.cmd_check(args) == 0 + assert "::warning" in capsys.readouterr().out + args.mode = "enforce" + assert cli.cmd_check(args) == 2 + + +def test_every_policy_file_is_checked_for_behaviour(hub): + """No cap: a value change in the 201st file still fails `n/a: metadata`.""" + files = [f"policyengine_us/parameters/gov/p{i}.yaml" for i in range(201)] + + def diff(path, side): + changed = path.endswith("p200.yaml") and side == "head" + return "values:\n 2026-01-01: 2\n" if changed else "values:\n 2026-01-01: 1\n" + + r = run(hub, "axiom: n/a: metadata: update labels", files=files, diff=diff) + assert codes(r) == {"na-changes-behaviour"} + + +def test_changed_formula_requires_parity(hub): + def diff(path, side): + v = 0 if side == "base" else 1 + return f"class benefit:\n def formula(person, period, parameters):\n label = {v}\n return label\n" + + r = run(hub, "axiom: n/a: metadata: update a reference", files=["policyengine_us/variables/gov/test.py"], diff=diff) + assert r.would_fail + + +def test_every_claimed_module_needs_its_own_test(hub): + body = "axiom: us:statutes/26/24/d and us:statutes/26/1/h encoded-correct (`three_children_social_security_excess`)" + r = run(hub, body) + assert codes(r) == {"test-does-not-exercise"} + assert any("us:statutes/26/1/h" in f.message for c in r.claims for f in c.findings) + hub.add_file( + RS_US, + "us/statutes/26/1/h.test.yaml", + "- name: gain_case\n output:\n us:statutes/26/1/h#net_capital_gain: 1\n", + ) + ok = "axiom: us:statutes/26/24/d and us:statutes/26/1/h encoded-correct (`three_children_social_security_excess`, `gain_case`)" + assert not run(hub, ok).would_fail + + +def test_unresolved_relative_module_blocks(hub): + body = "axiom: `us/statutes/26/24/d`, `/zz` encoded-correct (`three_children_social_security_excess`)" + assert "module-not-found" in codes(run(hub, body)) + + +def test_na_cannot_share_a_line_with_other_claims(hub): + for body in ("axiom: n/a: metadata; rulespec-us#1416 queued", "axiom: rulespec-us#1416 queued; n/a: metadata"): + assert "na-combined" in codes(run(hub, body)), body + + +def test_prose_status_words_do_not_satisfy_the_check(hub): + assert run(hub, "axiom: rulespec-us#1416 might be queued").would_fail + assert run(hub, "axiom: rulespec-us#1416 is not actually currently ready to be queued").would_fail + + +# Round 2 review regressions ------------------------------------------------ + + +def test_a_missing_module_blocks_however_it_is_written(hub): + for missing in ("us/statute/26/22", "us:statutes/26/22", "us/statutes/26/22.yaml", "/22"): + body = f"axiom: us:statutes/26/24/d and {missing} encoded-correct (`three_children_social_security_excess`)" + assert "module-not-found" in codes(run(hub, body)), missing + + +def test_a_companion_named_later_on_the_line_counts(hub): + body = ( + "axiom: us:statutes/26/24/d#ctc_social_security_tax encoded-correct | rulespec-us#1416 queued (blocked). " + "The companion test `three_children_social_security_excess` exercises it." + ) + assert not run(hub, body).would_fail + + +def test_same_test_file_name_in_two_directories(hub): + for sub in ("a", "b"): + hub.add_file(RS_US, f"us/statutes/1/{sub}/d.yaml", "rules:\n - name: r\n") + hub.add_file( + RS_US, + f"us/statutes/1/{sub}/d.test.yaml", + f"- name: case_{sub}\n output:\n us:statutes/1/{sub}/d#r: 1\n", + ) + body = ( + "axiom: us:statutes/1/a/d and us:statutes/1/b/d encoded-correct (`d.test.yaml::case_a`, `d.test.yaml::case_b`)" + ) + r = run(hub, body) + assert not r.would_fail, codes(r) + + +def test_different_statuses_in_one_sentence_are_both_kept(hub): + r = run(hub, "axiom: queued: rulespec-us#1416 encoded") + assert r.would_fail and {"queued-no-issue", "encoded-no-pr"} <= codes(r) + + +def test_a_status_must_follow_its_reference(hub): + # "but is queued" doesn't say what is queued, so the line makes no claim and fails. + assert run(hub, "axiom: rulespec-us#1416 is not encoded but is queued").would_fail + assert not run(hub, "axiom: rulespec-us#1416 is not encoded; rulespec-us#1416 is queued").would_fail + + +def test_adjective_use_of_a_status_is_not_a_claim(hub): + body = "axiom: rulespec-us#1416 queued (extended). The queued federal rule explains the rest." + r = run(hub, body) + assert not r.would_fail and len(r.claims) == 1 + + +def test_na_after_and_or_a_full_stop_is_still_a_claim(hub): + for body in ( + "axiom: rulespec-us#1416 queued and n/a: metadata: update labels", + "axiom: rulespec-us#1416 queued. n/a: metadata: update labels", + ): + assert "na-combined" in codes(run(hub, body)), body + + +def test_en_dash_module_and_test_names_resolve(hub): + hub.add_file(RS_US, "us/statutes/42/1437c–1.yaml", "rules:\n - name: r\n") + hub.add_file( + RS_US, "us/statutes/42/1437c–1.test.yaml", "- name: case_one\n output:\n us:statutes/42/1437c–1#r: 1\n" + ) + for body in ( + "axiom: us:statutes/42/1437c–1 encoded-correct (`1437c–1.test.yaml::case_one`)", + "axiom: us:statutes/42/1437c-1 encoded-correct (`1437c-1.test.yaml::case_one`)", + ): + r = run(hub, body) + assert not r.would_fail, (body, codes(r)) + + +def test_the_error_path_never_raises(monkeypatch, tmp_path): + from axiom_parity import cli + + monkeypatch.setattr(cli, "_check", lambda args: (_ for _ in ()).throw(RuntimeError("API down"))) + args = cli.build_parser().parse_args( + ["check", "--repo", "o/r", "--pr", "1", "--mode", "warn", "--summary", str(tmp_path / "no-such-dir" / "s.md")] + ) + assert cli.cmd_check(args) == 0 + + +def test_a_test_named_only_to_rule_it_out_is_not_evidence(hub): + body = ( + "axiom: us:statutes/26/24/d encoded-correct; " + "rulespec-us#1416 queued (not `three_children_social_security_excess`, which covers another rule)" + ) + assert codes(run(hub, body)) == {"encoded-correct-no-test"} + own = "axiom: us:statutes/26/24/d encoded-correct (not `three_children_social_security_excess`)" + assert codes(run(hub, own)) == {"encoded-correct-no-test"} + stated = ( + "axiom: us:statutes/26/24/d encoded-correct " + "(no new test is needed: `three_children_social_security_excess` covers it)" + ) + assert not run(hub, stated).would_fail + + +def test_a_ruled_out_test_stays_ruled_out_inside_brackets(hub): + own = "axiom: us:statutes/26/24/d encoded-correct (not (`three_children_social_security_excess`))" + assert codes(run(hub, own)) == {"encoded-correct-no-test"} + sibling = ( + "axiom: us:statutes/26/24/d encoded-correct; " + "rulespec-us#1416 queued (excluding [`three_children_social_security_excess`])" + ) + assert codes(run(hub, sibling)) == {"encoded-correct-no-test"} diff --git a/axiom-parity/tests/test_drain.py b/axiom-parity/tests/test_drain.py new file mode 100644 index 0000000..9542504 --- /dev/null +++ b/axiom-parity/tests/test_drain.py @@ -0,0 +1,736 @@ +import json +import subprocess + +from conftest import READY_ISSUE_BODY +from hypothesis import given +from hypothesis import strategies as st + +from axiom_parity.corpus_pin import pinned_citations, read_pin +from axiom_parity.drain import in_pin, issue_inputs, plan +from axiom_parity.issue_lint import review_finding_text + + +def test_review_finding_under_heading_and_after_cue(): + assert review_finding_text(READY_ISSUE_BODY).startswith("Encode 26 USC 32(d) as its own module") + cue = "Paste this as `review_finding`:\n\n> " + "Encode the rule from its elements. " * 4 + "\n" + assert review_finding_text(cue).startswith("Encode the rule") + fenced = "## review_finding\n\n```\n" + "Encode x carefully with proof excerpts. " * 3 + "\n```\n" + assert "Encode x" in review_finding_text(fenced) + assert review_finding_text("## review_finding\n\nTBD\n") is None + + +def test_review_finding_after_a_bold_step_label_and_in_yaml(): + md = "**Step 1.** Encode one module. `review_finding` (paste as-is):\n\n> " + "Encode reg 3 from its elements. " * 4 + assert review_finding_text(md).startswith("Encode reg 3") + yml = ( + "```yaml\ncitation: us/statute/26/1\nreview_finding: |-\n " + + "Encode section 1 from its own terms. " * 4 + + "\nopen_pr: true\n```" + ) + assert review_finding_text(yml).startswith("Encode section 1") + assert "open_pr" not in review_finding_text(yml) + + +@given( + st.text(alphabet="abcdefgh ,.`>#*-_:|\n", max_size=400), + st.integers(60, 140).map(lambda n: "x" * n), +) +def test_property_extracted_review_finding_implies_lint_found_it(text, near_threshold): + """Differential: the drain never pastes a review_finding the lint didn't see.""" + from axiom_parity.issue_lint import lint_issue + + bodies = [ + text, + "## review_finding\n\n> " + text, + "review_finding: |-\n " + text.replace("\n", " "), + "review_finding: |-\n " + near_threshold, + "review_finding: " + near_threshold, + "Paste this as `review_finding`:\n\n> " + near_threshold, + ] + for body in bodies: + if review_finding_text(body): + assert "review_finding" in lint_issue({"body": body}).found + + +def test_explicit_target_beats_context_mentions(): + body = ( + READY_ISSUE_BODY + + "\nContext: us/statute/26/461/l is related.\n### Step 1. Encode `us/statutes/26/1/g/7.yaml` (corpus citation `us/statute/26/1/g/7`)\n" + ) + row = issue_inputs("TheAxiomFoundation/rulespec-us", {"number": 1, "body": body}, []) + assert row.targets[:2] == ["us/statute/26/32/d", "us/statute/26/1/g/7"] + assert row.ready and row.review_finding + + +def test_in_pin_counts_descendant_rows(): + pin = {"us/statute/26/32/d/1", "uk/regulation/uksi/2013/376/36"} + assert in_pin("us/statute/26/32/d", pin) + assert in_pin("uk/regulation/uksi/2013/376/36", pin) + assert not in_pin("us/statute/26/32/i", pin) + assert not in_pin("us/statute/26/3", pin) # 26/32 is not below 26/3 + + +def test_plan_groups_by_document_and_marks_readiness(): + a = issue_inputs("R", {"number": 1, "body": READY_ISSUE_BODY}, []) + b = issue_inputs("R", {"number": 2, "body": READY_ISSUE_BODY.replace("32/d", "32/c")}, []) + c = issue_inputs("R", {"number": 3, "body": "## Encoding debt\n\ncorpus citation `us/statute/26/1/h`"}, []) + batches = plan([a, b, c], corpus={"us/statute/26/32/d", "us/statute/26/1/h"}) + by = {x.document: x for x in batches} + assert set(by) == {"us/statute/26/32", "us/statute/26/1"} + assert [i.number for i in by["us/statute/26/32"].issues] == [1, 2] + assert not by["us/statute/26/32"].ready # #2's 32(c) is not in the pin + assert by["us/statute/26/32"].issues[1].blocked == ["not in the pinned corpus release"] + assert not by["us/statute/26/1"].ready # #3 fails the lint + assert batches[-1].document in by + + +@given(st.sets(st.sampled_from(["us/statute/26/32/d", "us/statute/26/32/c", "us/statute/26/1/h"]))) +def test_property_a_batch_is_ready_iff_every_issue_is_lint_ready_and_pinned(pin): + rows = [ + issue_inputs("R", {"number": n, "body": READY_ISSUE_BODY.replace("32/d", sub)}, []) + for n, sub in enumerate(["32/d", "32/c"], 1) + ] + (batch,) = plan(rows, corpus=set(pin)) + assert batch.ready == all(in_pin(t, pin) for r in rows for t in r.targets) + + +def _git(repo, *args): + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) + + +def test_corpus_pin_reads_release_scopes(tmp_path): + corpus = tmp_path / "axiom-corpus" + rules = tmp_path / "rulespec-us" + for repo in (corpus, rules): + repo.mkdir() + _git(repo, "init", "-q", "-b", "main") + _git(repo, "config", "user.email", "t@example.com") + _git(repo, "config", "user.name", "t") + (corpus / "manifests/releases").mkdir(parents=True) + (corpus / "manifests/releases/rel-1.json").write_text( + json.dumps({"scopes": [{"jurisdiction": "us", "document_class": "statute", "version": "v1"}]}) + ) + prov = corpus / "data/corpus/provisions/us/statute" + prov.mkdir(parents=True) + (prov / "v1.jsonl").write_text( + "\n".join( + json.dumps(r) + for r in [ + {"citation_path": "us/statute/26", "body": None}, + {"citation_path": "us/statute/26/32/d", "body": "In the case of an individual who is married"}, + {"citation_path": "us/statute/26/32/e", "body": " "}, + ] + ) + + "\n" + ) + (prov / "unselected.jsonl").write_text(json.dumps({"citation_path": "us/statute/26/99", "body": "x"}) + "\n") + _git(corpus, "add", "-A") + _git(corpus, "commit", "-qm", "c") + sha = subprocess.run(["git", "-C", str(corpus), "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() + (rules / ".axiom").mkdir() + (rules / ".axiom/toolchain.toml").write_text('[toolchain]\naxiom_corpus_release = "rel-1"\n') + (rules / ".axiom/workflow-toolchain.toml").write_text(f'[toolchain]\naxiom_corpus_ref = "{sha}"\n') + _git(rules, "add", "-A") + _git(rules, "commit", "-qm", "r") + pin = read_pin(rules, "HEAD") + assert (pin.release, pin.corpus_ref) == ("rel-1", sha) + assert pinned_citations(corpus, pin) == {"us/statute/26/32/d"} + + +def test_plain_paragraph_review_finding_is_extracted_and_linted_alike(): + from axiom_parity.issue_lint import lint_issue + + body = "## review_finding\n\n" + ( + "Encode the eligibility conditions from the statute and derive externally sourced companion tests. " * 2 + ) + assert review_finding_text(body).startswith("Encode the eligibility") + assert "review_finding" in lint_issue({"body": body}).found + assert ( + review_finding_text("## review_finding\n\nTODO: write the finding for the encoder to paste in, " + "x" * 120) + is None + ) + + +def test_every_citation_in_the_finding_is_a_target(): + paths = [f"us/statute/26/32/{s}" for s in "abcd"] + finding = "> Encode " + ", ".join(paths) + " as four modules, each stated from the statute's own terms with proof." + body = READY_ISSUE_BODY.split("## review_finding")[0].replace("corpus citation `us/statute/26/32/d`", "see below") + body += "## review_finding (paste as-is)\n\n" + finding + "\n\n## Companion tests\n\n- case one: holds\n" + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert set(paths) <= set(row.targets) + (batch,) = plan([row], corpus=set(paths[:3])) + assert not batch.ready # the fourth citation is not in the pin + + +def test_a_batch_without_pasteable_finding_is_not_ready(): + row = issue_inputs("R", {"number": 1, "body": READY_ISSUE_BODY}, []) + row.review_finding = None + (batch,) = plan([row], corpus={"us/statute/26/32/d"}) + assert not batch.ready + + +def test_repealed_rows_are_not_in_the_pin(): + from axiom_parity.corpus_pin import _take + + out: set[str] = set() + _take( + json.dumps( + {"citation_path": "us-ok/statute/68-2355", "body": "A tax is imposed", "metadata": {"status": "repealed"}} + ).encode(), + out, + ) + _take(json.dumps({"citation_path": "us-ok/statute/68-2906", "body": "Any person", "metadata": {}}).encode(), out) + assert out == {"us-ok/statute/68-2906"} + + +def test_uk_style_pin_is_read_from_the_checks_workflow(tmp_path): + rules = tmp_path / "rulespec-uk" + (rules / ".axiom").mkdir(parents=True) + (rules / ".github/workflows").mkdir(parents=True) + _git(rules, "init", "-q", "-b", "main") + _git(rules, "config", "user.email", "t@example.com") + _git(rules, "config", "user.name", "t") + (rules / ".axiom/toolchain.toml").write_text('[toolchain]\naxiom_corpus_release = "uk-rel"\n') + sha = "4" * 40 + (rules / ".github/workflows/repository-checks.yml").write_text( + f"jobs:\n v:\n with:\n axiom-corpus-ref: {sha}\n" + ) + _git(rules, "add", "-A") + _git(rules, "commit", "-qm", "r") + pin = read_pin(rules, "HEAD") + assert (pin.release, pin.corpus_ref, pin.ref_source) == ("uk-rel", sha, "repository-checks.yml") + + +def test_blocked_modules_block_targets_and_imports(tmp_path): + from axiom_parity.drain import load_blocked_modules, runs_needed + + listing = tmp_path / "importers.json" + listing.write_text( + json.dumps({"affected": ["us/statutes/26/25A.yaml"], "direct_importers": ["us/statutes/26/32/d.yaml"]}) + ) + blocked = load_blocked_modules(str(listing)) + assert blocked == {"us/statutes/26/25A.yaml", "us/statutes/26/32/d.yaml"} + target = issue_inputs("R", {"number": 1, "body": READY_ISSUE_BODY}, []) + (batch,) = plan([target], corpus={"us/statute/26/32/d"}, blocked_modules=blocked) + assert not batch.ready and "blocked" in batch.issues[0].blocked[0] + importer = issue_inputs( + "R", + { + "number": 2, + "body": READY_ISSUE_BODY.replace("32/d", "32/c").replace( + "stated from its statutory elements", + "stated by importing us:statutes/26/25A#credit, from its statutory elements", + ), + }, + [], + ) + (batch,) = plan([importer], corpus={"us/statute/26/32/c"}, blocked_modules=blocked) + assert not batch.ready + clean = issue_inputs("R", {"number": 3, "body": READY_ISSUE_BODY.replace("32/d", "32/e")}, []) + (batch,) = plan([clean], corpus={"us/statute/26/32/e"}, blocked_modules=blocked) + assert batch.ready and runs_needed(batch) == 1 + + +def test_an_explicit_target_does_not_hide_what_the_finding_asks_for(): + body = READY_ISSUE_BODY.replace( + "The separated-spouse rule must be", "Also encode us/statute/26/32/e. The separated-spouse rule must be" + ) + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert row.targets == ["us/statute/26/32/d", "us/statute/26/32/e"] + (batch,) = plan([row], corpus={"us/statute/26/32/d"}) + assert not batch.ready + + +def test_review_finding_in_a_tagged_fence_and_after_a_long_paragraph(): + fenced = ( + "```review_finding\n" + "Encode reg 43 saving and the first-child row from the regulation. " * 2 + "\n```\n" + ) + assert review_finding_text(fenced).startswith("Encode reg 43") + para = ( + "**Step 1. Re-encode ARS 43-1072** with a targeted signed run (signed runs need Max's approval, so this is " + "not dispatched here). Paste this as `review_finding`:\n\n> " + "Encode the household income rule. " * 4 + ) + assert review_finding_text(para).startswith("Encode the household income") + denied = "This issue does not carry a `review_finding`:\n\n> " + "Some quoted law text that is long enough. " * 3 + assert review_finding_text(denied) is None + + +def _manual_issue() -> str: + return READY_ISSUE_BODY.replace("us/statute/26/32/d", "us-il/manual/dhs/pm-08-01").replace( + "us/statutes/26/32/d.yaml", "us-il/policies/dhs/pm-08-01.yaml" + ) + + +def test_a_blocked_citation_pattern_blocks_matching_targets(): + manual = issue_inputs("R", {"number": 1, "body": _manual_issue()}, []) + statute = issue_inputs("R", {"number": 2, "body": READY_ISSUE_BODY}, []) + pin = {"us-il/manual/dhs/pm-08-01", "us/statute/26/32/d"} + by = {b.issues[0].number: b for b in plan([manual, statute], corpus=pin, blocked_patterns=["/manual/"])} + assert not by[1].ready and "/manual/" in by[1].issues[0].blocked[0] + assert by[2].ready + + +def test_blocked_citations_block_exactly_or_with_descendants(tmp_path): + from axiom_parity.drain import load_blocked_citations + + f = tmp_path / "blocked.json" + f.write_text( + json.dumps( + { + "blocked": [ + { + "citation": "us/statute/26/32", + "match": "exact-or-descendant", + "kind": "filing-status", + "reason": "r", + }, + {"citation": "us/statute/26/1", "match": "exact", "kind": "w6-B", "reason": "r"}, + ] + } + ) + ) + listed = load_blocked_citations(str(f)) + assert [(b.citation, b.descendants) for b in listed] == [("us/statute/26/32", True), ("us/statute/26/1", False)] + under = issue_inputs("R", {"number": 1, "body": READY_ISSUE_BODY}, []) # targets us/statute/26/32/d + exact_only = issue_inputs("R", {"number": 2, "body": READY_ISSUE_BODY.replace("26/32/d", "26/1/h")}, []) + sibling = issue_inputs("R", {"number": 3, "body": READY_ISSUE_BODY.replace("26/32/d", "26/320/a")}, []) + pin = {"us/statute/26/32/d", "us/statute/26/1/h", "us/statute/26/320/a"} + by = {b.issues[0].number: b for b in plan([under, exact_only, sibling], corpus=pin, blocked_citations=listed)} + assert not by[1].ready and "filing-status" in by[1].issues[0].blocked[0] + assert by[2].ready # 26/1 is blocked exactly; 26/1/h is a different path + assert by[3].ready # 26/320 is not below 26/32 + + +def test_a_repealed_row_blocks_even_when_rows_below_it_have_text(tmp_path): + from axiom_parity.drain import load_pin_file + + f = tmp_path / "pin.txt" + f.write_text("us-mt/statute/15-30-2318/1\nus-mt/statute/15-30-2131/a\n!us-mt/statute/15-30-2131\n") + present, repealed = load_pin_file(str(f)) + assert in_pin("us-mt/statute/15-30-2318", present, repealed) # empty row, text below it + assert not in_pin("us-mt/statute/15-30-2131", present, repealed) # repealed, whatever sits below + + +def test_a_module_named_only_to_defer_it_is_not_an_import(tmp_path): + blocked = {"us/statutes/26/32.yaml"} + base = READY_ISSUE_BODY.replace("us/statute/26/32/d", "us-in/statute/6-3.1-21-6").replace( + "us/statutes/26/32/d.yaml", "us-in/statutes/6-3.1-21-6.yaml" + ) + deferred = base.replace( + "The separated-spouse rule must be", + "Declare a deferred dependency on us:statutes/26/32 (#eitc). Do not import it now. The rule must be", + ) + row = issue_inputs("R", {"number": 1, "body": deferred}, []) + (batch,) = plan([row], corpus={"us-in/statute/6-3.1-21-6"}, blocked_modules=blocked) + assert batch.ready + used = base.replace("The separated-spouse rule must be", "Import us:statutes/26/32#eitc. The rule must be") + row = issue_inputs("R", {"number": 2, "body": used}, []) + (batch,) = plan([row], corpus={"us-in/statute/6-3.1-21-6"}, blocked_modules=blocked) + assert not batch.ready + + +def test_an_explicit_target_must_be_a_corpus_citation(): + body = ( + READY_ISSUE_BODY + + "\nAlso: corpus citation `uk/regulations/uksi/2006/213/75D.yaml` (a module path by mistake).\n" + ) + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert row.targets == ["us/statute/26/32/d"] + + +COUNCILS = ["uk-kingston-upon-thames", "uk-merton", "uk-newham", "uk-westminster"] + + +def _councils_issue(number: int = 401, councils=COUNCILS) -> dict: + cites = [f"{c}/manual/council-tax-reduction-scheme-2026-2027/page-{n}" for n, c in enumerate(councils, 45)] + finding = ( + "> Encode the working-age taper for each council from " + + ", ".join(cites) + + ". State each rate with a proof excerpt." + ) + body = READY_ISSUE_BODY.replace("us/statute/26/32/d", cites[0]).replace( + "us/statutes/26/32/d.yaml", f"{councils[0]}/policies/council-tax-reduction-scheme-2026-2027/page-45.yaml" + ) + head, _, rest = body.partition("## review_finding (paste as-is)") + return { + "number": number, + "body": head + + "## review_finding (paste as-is)\n\n" + + finding + + "\n\n## Companion" + + rest.partition("## Companion")[2], + } + + +def test_a_run_never_mixes_jurisdictions(): + """The encoder refuses a bundle that leaves the primary citation's jurisdiction, so each document is its own run.""" + from axiom_parity.drain import drainable, runs_needed, to_json + + row = issue_inputs("R", _councils_issue(), []) + assert len(row.targets) == 4 and len(row.documents) == 4 + batches = plan([row], corpus=set(row.targets)) + assert len(batches) == 4 and all(b.ready and runs_needed(b) == 1 for b in batches) + for b in batches: + assert {c.split("/")[0] for c in b.citations} == {b.document.split("/")[0]} + summary = to_json(batches, 10)["summary"] + assert (summary["ready_documents"], summary["ready_runs"], summary["ready_issues"], summary["issues"]) == ( + 4, + 4, + 1, + 1, + ) + assert drainable(batches) == [row] + + +def test_an_issue_is_drainable_only_when_all_its_documents_are(): + from axiom_parity.drain import drainable + + spans = issue_inputs("R", _councils_issue(1, COUNCILS[:2]), []) + unready = issue_inputs("R", _councils_issue(2, COUNCILS[1:2]), []) + unready.ready = False + batches = plan([spans, unready], corpus=set(spans.targets)) + by_doc = {b.document.split("/")[0]: b for b in batches} + assert by_doc["uk-kingston-upon-thames"].ready and not by_doc["uk-merton"].ready + assert drainable(batches) == [] + + +@given(st.lists(st.sampled_from(COUNCILS + ["us", "uk"]), min_size=1, max_size=6), st.integers(1, 40)) +def test_property_every_batch_is_one_document_and_one_jurisdiction(jurisdictions, per): + from axiom_parity.drain import MAX_BUNDLE_CITATIONS, runs_needed + from axiom_parity.finder.propose import document_of + + rows = [] + for n, j in enumerate(jurisdictions, 1): + cites = [f"{j}/statute/9/{n}/{k}" for k in range(per)] + row = issue_inputs("R", {"number": n, "body": READY_ISSUE_BODY}, []) + row.targets = cites + rows.append(row) + batches = plan(rows) + assert sorted(c for b in batches for c in b.citations) == sorted({c for r in rows for c in r.targets}) + for b in batches: + assert {document_of(c) for c in b.citations} == {b.document} + assert len({c.split("/")[0] for c in b.citations}) == 1 + assert runs_needed(b) * MAX_BUNDLE_CITATIONS >= len(b.citations) > (runs_needed(b) - 1) * MAX_BUNDLE_CITATIONS + + +def test_a_declared_target_module_is_checked_against_the_block_list(): + body = READY_ISSUE_BODY.replace( + "corpus citation `us/statute/26/32/d`", "corpus citation `us/statute/26/32/d/1`" + ).replace(" at us/statutes/26/32/d.yaml", "") + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert row.targets == ["us/statute/26/32/d/1"] and row.modules == ["us/statutes/26/32/d.yaml"] + (batch,) = plan([row], corpus={"us/statute/26/32/d/1"}, blocked_modules={"us/statutes/26/32/d.yaml"}) + assert not batch.ready and "blocked" in batch.issues[0].blocked[0] + + +def test_a_blocked_module_blocks_the_citations_it_owns(): + body = READY_ISSUE_BODY.replace("us/statute/26/32/d", "us/guidance/irs/rev-proc-2025-32/page-10").replace( + "us/statutes/26/32/d.yaml", "us/policies/irs/rev-proc-2025-32/standard-deduction.yaml" + ) + blocked = {"us/policies/irs/rev-proc-2025-32/income-tax-brackets.yaml"} + owners = {"us/guidance/irs/rev-proc-2025-32/page-10": ["us/policies/irs/rev-proc-2025-32/income-tax-brackets.yaml"]} + pin = {"us/guidance/irs/rev-proc-2025-32/page-10"} + (free,) = plan([issue_inputs("R", {"number": 1, "body": body}, [])], corpus=pin, blocked_modules=blocked) + assert free.ready + (held,) = plan( + [issue_inputs("R", {"number": 1, "body": body}, [])], + corpus=pin, + blocked_modules=blocked, + owners=lambda c: owners.get(c, []), + ) + assert not held.ready + + +def test_modules_the_finding_names_are_targets_or_imports_by_what_it_asks(): + def row(extra: str): + body = READY_ISSUE_BODY.replace( + "stated from its statutory elements.", "stated from its statutory elements. " + extra + ) + return issue_inputs("R", {"number": 1, "body": body}, []) + + # Asked for by module name, in any spelling: a target. + assert row("Also encode us:statutes/26/32/e.").targets == ["us/statute/26/32/d", "us/statute/26/32/e"] + assert row("Re-encode us/statutes/26/32/e.yaml in place.").targets == ["us/statute/26/32/d", "us/statute/26/32/e"] + # Imported, mirrored or deferred: named, but not a target. + assert row("Import us/statutes/26/1/c for the rate.").targets == ["us/statute/26/32/d"] + assert row("Encode it mirroring us/statutes/26/24/d.yaml.").targets == ["us/statute/26/32/d"] + # An import blocks whichever way the module is spelled; a deferral doesn't. + blocked = {"us/statutes/26/1/c.yaml"} + for spelling in ("us/statutes/26/1/c", "us/statutes/26/1/c.yaml", "us:statutes/26/1/c#rate"): + (batch,) = plan([row(f"Import {spelling} for the rate.")], blocked_modules=blocked) + assert not batch.ready, spelling + (batch,) = plan([row("Declare a deferred dependency on us/statutes/26/1/c.yaml.")], blocked_modules=blocked) + assert batch.ready + # A state module's path respells its section, so the path alone gives no citation. + assert row("Also encode us-la/statutes/47/44/1.yaml.").targets == ["us/statute/26/32/d"] + + +def test_findings_in_comments_add_to_the_body_s(): + addendum = "`review_finding` (paste as-is; it adds to the body's):\n\n> " + ( + "Also encode 26 USC 32(e) (corpus us/statute/26/32/e) and state its taxable-year rule with proof excerpts. " + ) + row = issue_inputs("R", {"number": 1, "body": READY_ISSUE_BODY}, [{"id": 1, "body": addendum}]) + assert row.finding_blocks == 2 + assert row.review_finding.startswith("Encode 26 USC 32(d)") and "Also encode 26 USC 32(e)" in row.review_finding + assert row.targets == ["us/statute/26/32/d", "us/statute/26/32/e"] + + +def test_braced_citations_are_all_targets(): + body = READY_ISSUE_BODY.replace( + "corpus citation `us/statute/26/32/d`", "corpus citation `uk/regulation/uksi/2006/213/2`" + ).replace( + "Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml.", + "Encode regs 2, 19 and 13D from corpus uk/regulation/uksi/2006/213/{2,19,13D} at uk/regulations/uksi/2006/213/{2,19,13D}.yaml.", + ) + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert row.targets == [f"uk/regulation/uksi/2006/213/{n}" for n in ("2", "19", "13D")] + assert row.documents == ["uk/regulation/uksi/2006/213"] + + +def test_a_modules_line_lists_targets_and_a_precedent_is_not_one(): + finding = ( + "Encode whose income counts in Scotland and Wales, matching the existing England module " + "uk/regulations/uksi/2012/2885/schedule/1/paragraph/11.yaml. Modules: uk/regulations/ssi/2012/319/21.yaml, " + "uk/regulations/ssi/2021/249/36.yaml and uk/regulations/wsi/2013/3029/schedule/1/paragraph/5.yaml." + ) + body = READY_ISSUE_BODY.replace("corpus citation `us/statute/26/32/d`", "see the finding").replace( + "Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml.", finding + ) + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert row.targets == [ + "uk/regulation/ssi/2012/319/21", + "uk/regulation/ssi/2021/249/36", + "uk/regulation/wsi/2013/3029/schedule/1/paragraph/5", + ] + # A precedent is neither written nor imported, so a block on it doesn't hold the issue. + assert "uk/regulations/uksi/2012/2885/schedule/1/paragraph/11.yaml" not in row.modules + + +def test_sibling_module_names_are_spelled_out(): + from axiom_parity.drain import expand_siblings + + assert expand_siblings("Modules: uk/statutes/ukpga/1992/14/6.yaml, 9.yaml and 77.yaml. Then 3.yaml.") == ( + "Modules: uk/statutes/ukpga/1992/14/6.yaml, uk/statutes/ukpga/1992/14/9.yaml and " + "uk/statutes/ukpga/1992/14/77.yaml. Then 3.yaml." + ) + + +def _with_finding(finding: str, body: str = READY_ISSUE_BODY) -> dict: + head, _, rest = body.partition("## review_finding (paste as-is)") + quoted = "\n".join("> " + line for line in finding.splitlines()) + return { + "number": 1, + "body": head + + "## review_finding (paste as-is)\n\n" + + quoted + + "\n\n## Companion" + + rest.partition("## Companion")[2], + } + + +def test_a_numbered_worklist_of_modules_is_all_targets(): + finding = ( + "Encode these modules from the statutory elements with grounded proof excerpts and companion cases:\n" + "1. `us/statutes/26/32/a.yaml`: expose the lawful output.\n" + "2. `us/statutes/26/32/b.yaml`, `c.yaml`: the same, for the phaseout." + ) + row = issue_inputs("R", _with_finding(finding), []) + assert row.targets == ["us/statute/26/32/d"] + [f"us/statute/26/32/{x}" for x in "abc"] + (batch,) = plan([row], corpus={"us/statute/26/32/d"}) + assert not batch.ready and batch.issues[0].blocked == ["not in the pinned corpus release"] + + +def test_a_deferral_covers_only_its_own_clause(): + blocked = {"us/statutes/26/1/c.yaml"} + both = issue_inputs( + "R", + _with_finding( + "Import us/statutes/26/1/c.yaml, but do not import us/statutes/26/1/h.yaml. " + "Preserve all statutory proof excerpts and pass the provided companion cases." + ), + [], + ) + assert "us/statutes/26/1/c.yaml" in both.modules and "us/statutes/26/1/h.yaml" not in both.modules + (batch,) = plan([both], corpus={"us/statute/26/32/d"}, blocked_modules=blocked) + assert not batch.ready + without = issue_inputs( + "R", + _with_finding( + "Encode us/statutes/26/32/e.yaml without importing us/statutes/26/1/h.yaml, from the " + "text. " * 20 + ), + [], + ) + assert without.targets == ["us/statute/26/32/d", "us/statute/26/32/e"] + assert "us/statutes/26/1/h.yaml" not in without.modules + + +def test_a_precedent_or_a_description_is_neither_work_nor_an_import(): + blocked = {"us/statutes/26/32/c.yaml"} + for wording in ( + "Encode it at us/statutes/26/32/d.yaml, mirroring the existing us/statutes/26/32/c.yaml.", + "Encode it at us/statutes/26/32/d.yaml, as us/statutes/26/32/c.yaml does.", + "us/statutes/26/32/c.yaml currently carries the amount as an input. Encode us/statutes/26/32/d.yaml.", + "Encode us/statutes/26/32/d.yaml. Keep us/statutes/26/32/c.yaml unchanged.", + "Encode us/statutes/26/32/d.yaml, using the relation (us/statutes/26/32/c.yaml) for the total.", + ): + row = issue_inputs("R", _with_finding(wording + " State each element with its proof excerpt. " * 3), []) + assert row.targets == ["us/statute/26/32/d"], wording + (batch,) = plan([row], corpus={"us/statute/26/32/d"}, blocked_modules=blocked) + assert batch.ready, wording + # A request about the same module is work, however it is phrased. + for wording in ( + "us/statutes/26/32/c.yaml needs a rule for the separated spouse.", + "(3) In us/statutes/26/32/c.yaml, compose the deduction into Step 2.", + "Imports: us:statutes/26/1/h#net_capital_gain. Modules: us/statutes/26/32/c.yaml.", + ): + row = issue_inputs("R", _with_finding(wording + " State each element with its proof excerpt. " * 3), []) + assert "us/statute/26/32/c" in row.targets, wording + + +def test_work_on_a_module_with_no_citation_to_be_found_is_held(): + finding = "Encode R.C. 5747.01(A)(10) at us-oh/statutes/5747/01/a/10.yaml from the ingested operative row. " * 2 + body = READY_ISSUE_BODY.replace("us/statute/26/32/d", "us-oh/statute/5747.02").replace( + "us/statutes/26/32/d.yaml", "us-oh/statutes/5747/02.yaml" + ) + row = issue_inputs("R", _with_finding(finding, body), []) + assert row.unresolved == ["us-oh/statutes/5747/01/a/10.yaml"] + (batch,) = plan([row], corpus={"us-oh/statute/5747.02"}) + assert not batch.ready and "us-oh/statutes/5747/01/a/10.yaml" in batch.issues[0].blocked[0] + # The issue's citation for the section above the module resolves it. + part = "Encode the part at us-mt/statutes/15-30-2131/1/g.yaml from the statutory text, with proof excerpts. " * 2 + body = READY_ISSUE_BODY.replace("us/statute/26/32/d", "us-mt/statute/15-30-2131").replace( + "us/statutes/26/32/d.yaml", "us-mt/statutes/15-30-2131.yaml" + ) + row = issue_inputs("R", _with_finding(part, body), []) + assert not row.unresolved and row.targets == ["us-mt/statute/15-30-2131"] + + +def test_lines_that_pair_a_module_with_a_citation_declare_targets(): + pairs = ( + "\nProposed destinations and citations:\n\n" + "- us-nm/statutes/7-2-39.yaml/.test.yaml — us-nm/statute/7-2-39\n" + "- us-nm/statutes/7-2-18/13.yaml/.test.yaml — us-nm/statute/7-2-18.13\n" + "| us-nm/statutes/7-2-18/1.yaml | us-nm/statute/7-2-18.1 |\n" + "\nThe credit follows us-nm/statutes/7-2-18/15.yaml, which cites us-nm/statute/7-2-18.15 today.\n" + ) + body = READY_ISSUE_BODY.replace("us/statute/26/32/d", "us-nm/statute/7-2-39").replace( + "us/statutes/26/32/d.yaml", "us-nm/statutes/7-2-39.yaml" + ) + row = issue_inputs("R", {"number": 1, "body": body + pairs}, []) + assert row.targets == ["us-nm/statute/7-2-39", "us-nm/statute/7-2-18.13", "us-nm/statute/7-2-18.1"] + assert "us-nm/statutes/7-2-18/13.yaml" in row.modules and "us-nm/statutes/7-2-18/15.yaml" not in row.modules + + +def test_backticked_sibling_names_and_incomplete_paths(): + finding = ( + "Modules: `uk/statutes/ukpga/1992/14/6.yaml`, `9.yaml` and `77.yaml`. Northern Ireland is out of scope " + "until rulespec-uk has a uk/regulations/nisr tree. See uk/regulation/uksi/2012/2885/schedule/1/paragraph for the rest." + ) + row = issue_inputs("R", _with_finding(finding + " State each with its proof excerpt." * 3), []) + assert [t for t in row.targets if t.startswith("uk/")] == [f"uk/statute/ukpga/1992/14/{n}" for n in (6, 9, 77)] + + +def test_a_pin_citation_may_hold_spaces(tmp_path): + from axiom_parity.drain import load_pin_file + + pin = tmp_path / "pin.txt" + pin.write_text("us-ak/regulation/aac/title-7/chapter-45/7 AAC 45.010\n!us/statute/26/1/x\n\nus/statute/26/32/d\n") + present, repealed = load_pin_file(str(pin)) + assert present == {"us-ak/regulation/aac/title-7/chapter-45/7 AAC 45.010", "us/statute/26/32/d"} + assert repealed == {"us/statute/26/1/x"} + + +def test_the_encoder_owner_s_dispatch_verdicts_are_holds(tmp_path): + from axiom_parity.drain import load_blocked_citations + + verdicts = tmp_path / "prep-verdicts.json" + verdicts.write_text( + json.dumps( + { + "documents": [ + {"citation": "us/statute/26/32/d", "verdict": "dispatched", "reason": "run 1", "until": ""}, + { + "citation": "us/statute/26/32/e", + "verdict": "blocked-source", + "reason": "needs a form", + "until": "a re-pin", + }, + ] + } + ) + ) + holds = load_blocked_citations(str(verdicts)) + assert [(h.citation, h.kind, h.reason) for h in holds] == [ + ("us/statute/26/32/d", "dispatched", "run 1"), + ("us/statute/26/32/e", "blocked-source", "needs a form (until a re-pin)"), + ] + rows = [ + issue_inputs("R", {"number": n, "body": READY_ISSUE_BODY.replace("32/d", sub)}, []) + for n, sub in enumerate(["32/d", "32/e", "32/f"], 1) + ] + pin = {f"us/statute/26/32/{x}" for x in "def"} + (batch,) = plan(rows, corpus=pin, blocked_citations=holds) + held = {i.number: i.blocked for i in batch.issues} + assert "dispatched" in held[1][0] and "blocked-source" in held[2][0] and held[3] == [] + assert not batch.ready + + +def test_work_on_a_module_the_owner_holds_is_held_and_an_import_from_it_is_not(tmp_path): + from axiom_parity.drain import load_work_holds, markdown, to_json + + listing = tmp_path / "assembled.json" + listing.write_text( + json.dumps( + { + "rule": "Assembled pipelines are edited by hand, not encoded.", + "modules": [ + { + "repo": "rulespec-us", + "module": "us/policies/income_tax/taxable_income_pipeline.yaml", + "evidence": ["pipeline file name"], + } + ], + } + ) + ) + holds = load_work_holds(str(listing)) + assert holds.modules == {"us/policies/income_tax/taxable_income_pipeline.yaml": ("pipeline file name",)} + pin = {"us/statute/26/32/d"} + tail = " State each element with its proof excerpt. " * 3 + edits = issue_inputs( + "R", + _with_finding( + "Encode it at us/statutes/26/32/d.yaml. Then wire it into us/policies/income_tax/taxable_income_pipeline.yaml." + + tail + ), + [], + ) + (batch,) = plan([edits], corpus=pin, work_holds=holds) + (reason,) = batch.issues[0].blocked # the owner's rule replaces the missing-citation reason + assert not batch.ready and "taxable_income_pipeline.yaml [pipeline file name]" in reason + assert "Assembled pipelines are edited by hand" in markdown([batch], 10, holds) + assert to_json([batch], 10, holds)["work_hold_rule"].startswith("Assembled pipelines") + imports = issue_inputs( + "R", + _with_finding( + "Encode it at us/statutes/26/32/d.yaml, importing us:policies/income_tax/taxable_income_pipeline#taxable_income." + + tail + ), + [], + ) + (batch,) = plan([imports], corpus=pin, work_holds=holds) + assert batch.ready + # Without the owner's list, the same request is held for the citation it lacks. + (batch,) = plan( + [ + issue_inputs("R", edits_body, []) + for edits_body in [ + _with_finding( + "Encode it at us/statutes/26/32/d.yaml. Then wire it into us/policies/income_tax/taxable_income_pipeline.yaml." + + tail + ) + ] + ], + corpus=pin, + ) + assert "corpus citation the issue doesn't give" in batch.issues[0].blocked[0] diff --git a/axiom-parity/tests/test_finder.py b/axiom-parity/tests/test_finder.py new file mode 100644 index 0000000..a60ae01 --- /dev/null +++ b/axiom-parity/tests/test_finder.py @@ -0,0 +1,458 @@ +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from axiom_parity.finder.citations import Citation, StateHint, map_url +from axiom_parity.finder.draft import module_path_for +from axiom_parity.finder.index import Index +from axiom_parity.finder.propose import propose +from axiom_parity.finder.references import parameter_references, variable_references +from axiom_parity.issue_lint import lint_issue + +# Expected paths follow corpus_citation_path values that exist in rulespec-us +# and rulespec-uk main (2026-10-10), e.g. us/statute/151/d/5, +# us/regulation/26/1/1401-1/d/2/i, uk/regulation/ssi/2012/319/schedule/1/paragraph/2. +VECTORS = [ + ("https://www.legislation.gov.uk/ukpga/2002/16/section/1", "uk/statute/ukpga/2002/16/1"), + ("https://www.legislation.gov.uk/ukpga/2007/3/section/35/2024-04-06", "uk/statute/ukpga/2007/3/35"), + ("https://www.legislation.gov.uk/uksi/2013/376/regulation/89", "uk/regulation/uksi/2013/376/89"), + ("https://www.legislation.gov.uk/uksi/2013/376/regulation/36/made", "uk/regulation/uksi/2013/376/36"), + ( + "https://www.legislation.gov.uk/ssi/2012/319/schedule/1/paragraph/2", + "uk/regulation/ssi/2012/319/schedule/1/paragraph/2", + ), + ( + "https://www.legislation.gov.uk/ukpga/1995/26/schedule/4/paragraph/1", + "uk/statute/ukpga/1995/26/schedule/4/paragraph/1", + ), + ("https://www.legislation.gov.uk/uksi/2026/148/article/6", "uk/regulation/uksi/2026/148/article/6"), + ("https://www.legislation.gov.uk/anaw/2017/1/section/24", "uk/statute/anaw/2017/1/24"), + ("https://www.legislation.gov.uk/asp/2013/11/schedule/2A", "uk/statute/asp/2013/11/schedule/2A"), + ("https://www.gov.uk/carers-allowance/eligibility", "uk/guidance/govuk/carers-allowance/eligibility"), + ("https://www.law.cornell.edu/uscode/text/26/151#d_5", "us/statute/26/151/d/5"), + ("https://www.law.cornell.edu/uscode/text/26/32", "us/statute/26/32"), + ("https://www.law.cornell.edu/uscode/text/42/1396b#v_4", "us/statute/42/1396b/v/4"), + ( + "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section32&num=0&edition=prelim", + "us/statute/26/32", + ), + ("https://www.law.cornell.edu/cfr/text/7/273.9", "us/regulation/7/273/9"), + ("https://www.law.cornell.edu/cfr/text/26/1.1401-1", "us/regulation/26/1/1401-1"), + ( + "https://www.ecfr.gov/current/title-7/subtitle-B/chapter-II/subchapter-C/part-273/section-273.9#p-273.9(d)(6)", + "us/regulation/7/273/9/d/6", + ), + ("https://www.ecfr.gov/current/title-42/section-435.110", "us/regulation/42/435/110"), + ( + "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17041.", + "us-ca/statute/rtc/17041", + ), + ( + "https://mgaleg.maryland.gov/mgawebsite/Laws/StatuteText?article=gtg§ion=10-709&enactments=false", + "us-md/statute/gtg/10-709", + ), + ("https://codes.ohio.gov/ohio-revised-code/section-5747.02", "us-oh/statute/5747.02"), +] + + +@pytest.mark.parametrize("url,expected", VECTORS) +def test_url_vectors(url, expected): + c = map_url(url) + assert isinstance(c, Citation) and c.path == expected + + +@pytest.mark.parametrize( + "url", + [ + "https://www.gov.uk/government/publications/budget-2025", + "https://www.irs.gov/pub/irs-pdf/f1040.pdf", + "https://www.bbc.co.uk/news/business-1", + "not a url", + "", + ], +) +def test_urls_with_no_corpus_source(url): + assert map_url(url) is None + + +def test_state_hint(): + c = map_url("https://www.azleg.gov/ars/43/01072.htm") + assert c == StateHint("us-az", "01072", "azleg.gov") + + +def test_state_sites_with_exact_rules(): + assert map_url("https://www.nysenate.gov/legislation/laws/TAX/606").path == "us-ny/statute/TAX/606" + assert ( + map_url("https://law.lis.virginia.gov/vacode/title58.1/chapter3/section58.1-320/").path + == "us-va/statute/58.1/58.1-320" + ) + assert ( + map_url("https://www.ilga.gov/legislation/ilcs/fulltext.asp?DocName=003500050K201").path + == "us-il/statute/35/5/201" + ) + assert map_url("https://www.irs.gov/pub/irs-drop/rp-25-32.pdf").path == "us/guidance/irs/rev-proc-2025-32" + assert map_url("https://www.legislation.gov.uk/id/ukpga/2007/3/section/10").path == "uk/statute/ukpga/2007/3/10" + assert map_url("https://www.legislation.gov.uk/ukpga/2004/12/section/190/2").path == "uk/statute/ukpga/2004/12/190" + assert map_url("https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A") is None + assert map_url("https://www.legislation.gov.uk/uksi/2025/162/pdfs/uksiem_20250162_en_001.pdf") is None + + +@given(st.integers(1, 54), st.from_regex(r"[1-9][0-9]{0,4}[a-z]?", fullmatch=True)) +def test_property_cornell_usc(title, section): + c = map_url(f"https://www.law.cornell.edu/uscode/text/{title}/{section}") + assert c.path == f"us/statute/{title}/{section}" + + +@given( + st.sampled_from(["ukpga", "uksi", "ssi", "asp"]), st.integers(1900, 2030), st.integers(1, 3000), st.integers(1, 400) +) +def test_property_legislation_gov_uk(kind, year, number, sec): + unit = "section" if kind in ("ukpga", "asp") else "regulation" + root = "statute" if kind in ("ukpga", "asp") else "regulation" + c = map_url(f"https://www.legislation.gov.uk/{kind}/{year}/{number}/{unit}/{sec}") + assert c.path == f"uk/{root}/{kind}/{year}/{number}/{sec}" + + +def test_module_path_for(): + assert module_path_for("us/statute/26/32/d") == "us/statutes/26/32/d.yaml" + assert module_path_for("us/regulation/7/273/9") == "us/regulations/7-cfr/273/9.yaml" + assert module_path_for("uk/regulation/uksi/2013/376/89") == "uk/regulations/uksi/2013/376/89.yaml" + + +def test_parameter_and_variable_references(): + yaml_text = """description: x +values: + 0000-01-01: 0 + 2025-01-01: 1 +metadata: + reference: + - title: ITA 2007 s.35 + href: https://www.legislation.gov.uk/ukpga/2007/3/section/35 + - https://www.gov.uk/income-tax-rates +""" + refs = parameter_references("policyengine_uk/parameters/gov/hmrc/pa.yaml", yaml_text, "policyengine_uk") + assert [(r.name, r.url) for r in refs] == [ + ("gov.hmrc.pa", "https://www.legislation.gov.uk/ukpga/2007/3/section/35"), + ("gov.hmrc.pa", "https://www.gov.uk/income-tax-rates"), + ] + py = ( + "class pa(Variable):\n" + ' reference = ("https://www.legislation.gov.uk/ukpga/2007/3/section/35", ' + '{"title": "s.36", "href": "https://www.legislation.gov.uk/ukpga/2007/3/section/36"})\n' + ) + py += ( + "class aa(Variable):\n" + " reference = [dict(title='FA 2004 s.227', href='https://www.legislation.gov.uk/ukpga/2004/12/section/227')]\n" + "class ia(Variable):\n" + " reference = dict(title='s.228A', href='https://www.legislation.gov.uk/ukpga/2004/12/section/228A')\n" + ) + refs = variable_references("policyengine_uk/variables/gov/hmrc/pa.py", py) + assert [r.url for r in refs] == [ + "https://www.legislation.gov.uk/ukpga/2007/3/section/35", + "https://www.legislation.gov.uk/ukpga/2007/3/section/36", + "https://www.legislation.gov.uk/ukpga/2004/12/section/227", + "https://www.legislation.gov.uk/ukpga/2004/12/section/228A", + ] + + +@pytest.fixture +def rs_tree(tmp_path): + root = tmp_path / "rulespec-uk" + (root / "uk/statutes/ukpga/2004/12").mkdir(parents=True) + (root / "uk/statutes/ukpga/2004/12/190.yaml").write_text( + "module:\n source_verification:\n corpus_citation_path: uk/statute/ukpga/2004/12/190\nrules:\n - name: relief_limit\n" + ) + (root / "uk/statutes/ukpga/2004/12/190.test.yaml").write_text( + "- name: relief_capped_at_relevant_earnings\n output:\n uk:statutes/ukpga/2004/12/190#relief_limit: 1\n" + "- name: other\n output:\n uk:statutes/x#y: 1\n" + ) + return root + + +def test_index_and_propose(rs_tree): + idx = Index.build("TheAxiomFoundation/rulespec-uk", rs_tree, ["uk"]) + refs = parameter_references( + "policyengine_uk/parameters/gov/hmrc/pensions/relief.yaml", + "metadata:\n reference:\n - href: https://www.legislation.gov.uk/ukpga/2004/12/section/190\n" + " - href: https://www.legislation.gov.uk/ukpga/2004/12/section/227\n", + "policyengine_uk", + ) + queued = { + "number": 367, + "title": "UC", + "html_url": "u", + "body": "Covers uk/statute/ukpga/2004/12/227 (annual allowance charge).", + } + proposals, unmapped = propose( + refs, {"TheAxiomFoundation/rulespec-uk": idx}, {"TheAxiomFoundation/rulespec-uk": [(queued, [])]} + ) + by = {p.citation: p for p in proposals} + s190 = by["uk/statute/ukpga/2004/12/190"] + assert s190.modules[0].module.path == "uk/statutes/ukpga/2004/12/190.yaml" + assert s190.cases["uk/statutes/ukpga/2004/12/190.yaml"] == ["relief_capped_at_relevant_earnings"] + assert ( + s190.line + == "uk:statutes/ukpga/2004/12/190 encoded-correct (`190.test.yaml::relief_capped_at_relevant_earnings`)" + ) + s227 = by["uk/statute/ukpga/2004/12/227"] + assert not s227.modules and s227.issues[0]["number"] == 367 + assert s227.line == "TheAxiomFoundation/rulespec-uk#367 queued" + assert unmapped == [] + + +def test_draft_is_not_ready_until_todos_are_done(rs_tree): + from axiom_parity.finder.draft import draft_issue + from axiom_parity.finder.propose import Proposal, TestCase + from axiom_parity.finder.references import Reference + + ref = Reference( + "f.yaml", "gov.hmrc.sdlt.rates", "parameter", "https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA" + ) + p = [Proposal("uk/statute/ukpga/2003/14/schedule/4ZA", True, [ref])] + tc = TestCase("t.yaml", "additional_dwelling_surcharge", 2026, {"price": 300000}, {"sdlt": 24000}) + body = draft_issue( + p, "PolicyEngine/policyengine-uk", 2241, "Stop charging property stocks", [tc], "main@abc", fetch=False + ) + res = lint_issue({"body": body}) + assert "verbatim_law" in res.missing # TODO left for the law text + assert "uk/statute/ukpga/2003/14/schedule/4ZA" in body and "uk/statutes/ukpga/2003/14/schedule/4ZA.yaml" in body + filled = body.replace( + "TODO: quote the operative text from the corpus or the official source.", + "> Schedule 4ZA applies to a chargeable transaction that is a higher rates transaction, and the rates in " + "the table are increased by three percentage points for that transaction.\n\nquoted", + ) + assert "verbatim_law" not in lint_issue({"body": filled}).missing + + +def test_document_grouping(): + from axiom_parity.finder.propose import Proposal, document_of, group_by_document + + assert document_of("uk/statute/ukpga/2004/12/227") == "uk/statute/ukpga/2004/12" + assert document_of("us/statute/26/32/i/2") == "us/statute/26/32" + assert document_of("us/regulation/7/273/9") == "us/regulation/7/273" + assert document_of("us-ca/statute/rtc/17041") == "us-ca/statute/rtc" + cites = ("uk/statute/ukpga/2004/12/227", "uk/statute/ukpga/2004/12/228A", "uk/statute/ukpga/2003/14/schedule/4ZA") + groups = group_by_document([Proposal(c, True, []) for c in cites]) + assert sorted(groups) == ["uk/statute/ukpga/2003/14", "uk/statute/ukpga/2004/12"] + assert len(groups["uk/statute/ukpga/2004/12"]) == 2 + + +def test_sibling_citation_extends_the_documents_open_issue(rs_tree): + idx = Index.build("TheAxiomFoundation/rulespec-uk", rs_tree, ["uk"]) + refs = parameter_references( + "policyengine_uk/parameters/gov/x.yaml", + "metadata:\n reference:\n - href: https://www.legislation.gov.uk/ukpga/2004/12/section/227\n" + " - href: https://www.legislation.gov.uk/ukpga/2004/12/section/228A\n", + "policyengine_uk", + ) + issue = {"number": 444, "title": "AA", "html_url": "u", "body": "Encode uk/statute/ukpga/2004/12/227."} + proposals, _ = propose( + refs, {"TheAxiomFoundation/rulespec-uk": idx}, {"TheAxiomFoundation/rulespec-uk": [(issue, [])]} + ) + by = {p.citation: p for p in proposals} + assert by["uk/statute/ukpga/2004/12/227"].line == "TheAxiomFoundation/rulespec-uk#444 queued" + assert by["uk/statute/ukpga/2004/12/228A"].line == ( + "TheAxiomFoundation/rulespec-uk#444 queued (extend it to cover uk/statute/ukpga/2004/12/228A)" + ) + from axiom_parity.finder.propose import group_by_document + + assert group_by_document(proposals) == {} + + +def test_issue_mentioning_a_parent_citation_matches(): + from axiom_parity.finder.propose import _ancestors + + assert _ancestors("us/regulation/20/416/1202/a") == ["us/regulation/20/416/1202/a", "us/regulation/20/416/1202"] + assert _ancestors("us/statute/26/32") == ["us/statute/26/32"] + + +def test_dated_value_references_are_read(): + text = ( + "values:\n 2025-01-01:\n value: 10\n metadata:\n reference:\n" + " href: https://www.law.cornell.edu/uscode/text/26/32\n" + " 2026-01-01:\n value: 11\n reference:\n - href: https://www.law.cornell.edu/uscode/text/26/24\n" + ) + refs = parameter_references("policyengine_us/parameters/gov/x.yaml", text, "policyengine_us") + assert [r.url for r in refs] == [ + "https://www.law.cornell.edu/uscode/text/26/32", + "https://www.law.cornell.edu/uscode/text/26/24", + ] + + +def test_dash_variants_match(tmp_path): + root = tmp_path / "rulespec-us" + (root / "us/statutes/42").mkdir(parents=True) + (root / "us/statutes/42/1437c\u20131.yaml").write_text( + "module:\n source_verification:\n corpus_citation_path: us/statute/42/1437c\u20131\nrules:\n - name: r\n" + ) + idx = Index.build("TheAxiomFoundation/rulespec-us", root, ["us"]) + for url in ( + "https://www.law.cornell.edu/uscode/text/42/1437c-1", + "https://www.law.cornell.edu/uscode/text/42/1437c%E2%80%931", + ): + c = map_url(url) + assert c.path == "us/statute/42/1437c-1" + assert idx.match(c.path)[0].module.path.endswith("1437c\u20131.yaml") + + +def test_finder_never_overwrites_a_person_s_comment(): + from axiom_parity.finder.cli import MARKER, upsert_comment + + class Hub: + def __init__(self, comments): + self.comments, self.sent = comments, [] + + def issue_comments(self, repo, pr): + return self.comments + + def send_json(self, method, path, data): + self.sent.append((method, path)) + + human = Hub([{"id": 123, "body": MARKER + " quoting the bot", "user": {"login": "human-reviewer", "type": "User"}}]) + upsert_comment(human, "o/r", 1, MARKER + "\nnew", {"would_fail_enforced": True}) + assert human.sent == [("POST", "/repos/o/r/issues/1/comments")] + bot = Hub([{"id": 9, "body": MARKER + " old", "user": {"login": "github-actions[bot]", "type": "Bot"}}]) + upsert_comment(bot, "o/r", 1, MARKER + "\nnew", {"would_fail_enforced": True}) + assert bot.sent == [("PATCH", "/repos/o/r/issues/comments/9")] + + +def test_finder_never_overwrites_another_bot_s_comment(): + from axiom_parity.finder.cli import MARKER, upsert_comment + + class Hub: + def __init__(self, comments): + self.comments, self.sent = comments, [] + + def issue_comments(self, repo, pr): + return self.comments + + def send_json(self, method, path, data): + self.sent.append((method, path)) + + new = {"would_fail_enforced": True} + # Another bot that quotes the marker, or even opens with it, keeps its comment. + for body in ("Quotation: " + MARKER, MARKER + "\ncopied"): + other = Hub([{"id": 9, "body": body, "user": {"login": "renovate[bot]", "type": "Bot"}}]) + upsert_comment(other, "o/r", 1, MARKER + "\nnew", new) + assert other.sent == [("POST", "/repos/o/r/issues/1/comments")], body + # Our own account quoting the marker mid-comment is not the finder comment either. + quoting = Hub([{"id": 9, "body": "see " + MARKER, "user": {"login": "github-actions[bot]", "type": "Bot"}}]) + upsert_comment(quoting, "o/r", 1, MARKER + "\nnew", new) + assert quoting.sent == [("POST", "/repos/o/r/issues/1/comments")] + # A caller with its own token names its account. + app = Hub([{"id": 7, "body": MARKER + "\nold", "user": {"login": "pe-parity[bot]", "type": "Bot"}}]) + upsert_comment(app, "o/r", 1, MARKER + "\nnew", new, author="pe-parity[bot]") + assert app.sent == [("PATCH", "/repos/o/r/issues/comments/7")] + + +def test_open_issues_come_with_their_comments(monkeypatch): + from axiom_parity.finder.cli import _open_issues + from axiom_parity.github import GitHub + + gh = GitHub(token="t", transport=lambda url, headers: (404, {}, b"")) + issue = {"number": 108, "title": "t", "body": "see comment", "html_url": "u", "created_at": "", "comments": 1} + monkeypatch.setattr( + gh, + "open_issues_with_comments", + lambda repo, label: [(issue, [{"id": 1, "body": "uk/statute/ukpga/2004/12/227"}])], + ) + ((got, comments),) = _open_issues(gh, "TheAxiomFoundation/rulespec-uk") + assert got["number"] == 108 and comments[0]["body"].startswith("uk/statute") + + +def _url_vectors(): + import json + from pathlib import Path + + data = json.loads((Path(__file__).parent / "fixtures" / "url_vectors.json").read_text()) + return data if isinstance(data, list) else data["vectors"] + + +# Corpus quirks no rule reproduces: a date read as a schedule number, and a page filed under another slug. +_KNOWN_QUIRKS = { + "https://www.legislation.gov.uk/wsi/2018/128/schedule/2026-06-30", + "https://www.gov.uk/new-state-pension/your-national-insurance-record-and-your-state-pension", + "https://www.gov.uk/tax-free-childcare?step-by-step-nav=d78aeaf6-1747-4d72-9619-f16efb4dd89d", +} + + +def test_differential_url_mapper_agrees_with_corpus_verified_vectors(): + """The mapper never contradicts a URL→citation pair verified against the corpus. + + The vectors come from an independent pass over axiom-corpus (427 pairs, 248 + of them verified rows). For each, the mapper must return the expected path, + a path below it (a subsection of the verified row), or nothing. It must + return nothing where the vector says the URL names no corpus source. + """ + counts = {"match": 0, "deeper": 0, "none": 0} + for v in _url_vectors(): + if v["url"] in _KNOWN_QUIRKS: + continue + got = map_url(v["url"]) + path = got.path if isinstance(got, Citation) else None + want = v.get("expected_citation_path") + if want is None: + assert path is None, (v["url"], path) + elif path is None: + counts["none"] += 1 + else: + assert path == want or path.startswith(want + "/"), (v["url"], want, path) + counts["match" if path == want else "deeper"] += 1 + # A floor, so a refactor that silently stops mapping a family fails here. + assert counts["match"] >= 125 and counts["match"] + counts["deeper"] >= 135, counts + + +# Round 2 review regressions ------------------------------------------------ + + +def test_a_sibling_issue_is_found_without_another_changed_reference(rs_tree): + """The PR references only s.228A; the open issue cites only s.227 of the same Act.""" + idx = Index.build("TheAxiomFoundation/rulespec-uk", rs_tree, ["uk"]) + refs = parameter_references( + "policyengine_uk/parameters/gov/x.yaml", + "metadata:\n reference:\n - href: https://www.legislation.gov.uk/ukpga/2004/12/section/228A\n", + "policyengine_uk", + ) + issue = {"number": 444, "title": "AA", "html_url": "u", "body": "Encode uk/statute/ukpga/2004/12/227."} + proposals, _ = propose( + refs, {"TheAxiomFoundation/rulespec-uk": idx}, {"TheAxiomFoundation/rulespec-uk": [(issue, [])]} + ) + (p,) = proposals + assert p.line == "TheAxiomFoundation/rulespec-uk#444 queued (extend it to cover uk/statute/ukpga/2004/12/228A)" + + +def test_comments_past_the_first_page_are_read(monkeypatch): + from axiom_parity.github import GitHub + + gh = GitHub(token="t", transport=lambda url, headers: (404, {}, b"")) + page = { + "data": { + "repository": { + "issues": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [ + { + "number": 7, + "title": "t", + "body": "b", + "url": "u", + "createdAt": "", + "comments": { + "totalCount": 101, + "nodes": [{"databaseId": i, "body": f"c{i}"} for i in range(100)], + }, + } + ], + } + } + } + } + monkeypatch.setattr(gh, "send_json", lambda method, path, data: page) + monkeypatch.setattr(gh, "issue_comments", lambda repo, n: [{"id": i, "body": f"c{i}"} for i in range(101)]) + ((issue, comments),) = gh.open_issues_with_comments("o/r", "pe-parity") + assert len(comments) == 101 and comments[-1]["body"] == "c100" and issue["comments"] == 101 + + +def test_house_url_with_an_encoded_dash(): + url = "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title42-section1437c%2D1&num=0" + assert map_url(url).path == "us/statute/42/1437c-1" diff --git a/axiom-parity/tests/test_issue_lint.py b/axiom-parity/tests/test_issue_lint.py new file mode 100644 index 0000000..adbba91 --- /dev/null +++ b/axiom-parity/tests/test_issue_lint.py @@ -0,0 +1,380 @@ +import re + +from conftest import READY_ISSUE_BODY +from hypothesis import given, settings +from hypothesis import strategies as st + +from axiom_parity.issue_lint import ELEMENTS, lint_issue, lint_texts + +SECTIONS = { + "module_and_corpus": "## Module and corpus\n\nTarget `us/statutes/26/32/d.yaml`; corpus citation `us/statute/26/32/d`.\n", + "verbatim_law": ( + "## Law (verbatim)\n\n> (2) Determination of marital status For purposes of this section— (A) In general " + "Except as provided in subparagraph (B), marital status shall be determined under section 7703(a).\n" + ), + "required_outputs": "## Required outputs\n\n1. `separated_spouse_not_treated_as_married` (Judgment)\n", + "review_finding": ( + "## review_finding (paste as-is)\n\n> Encode 26 USC 32(d) as its own module. The separated-spouse rule " + "must be stated from its statutory elements, each with a verbatim proof excerpt.\n" + ), + "companion_tests": "## Companion tests\n\n- apart for the last six months with a qualifying child: holds\n", +} + + +def build(sections): + return "\n".join(SECTIONS[s] for s in sections) + + +def test_ready_issue(): + res = lint_issue({"body": READY_ISSUE_BODY}) + assert res.ready, res.missing + + +def test_content_in_a_comment_counts(): + body = build(["module_and_corpus", "verbatim_law", "required_outputs", "companion_tests"]) + assert lint_issue({"body": body}).missing == ["review_finding"] + res = lint_issue({"body": body}, [{"id": 7, "body": SECTIONS["review_finding"]}]) + assert res.ready + assert res.found["review_finding"].startswith("comment 7") + + +def test_heading_without_content_does_not_count(): + body = ( + build(["module_and_corpus", "verbatim_law", "required_outputs", "companion_tests"]) + + "\n## review_finding\n\nTBD\n" + ) + assert "review_finding" in lint_issue({"body": body}).missing + + +def test_review_finding_quote_is_not_verbatim_law(): + body = build(["module_and_corpus", "required_outputs", "review_finding", "companion_tests"]) + assert lint_issue({"body": body}).missing == ["verbatim_law"] + + +def test_inline_review_finding_cue(): + body = build(["module_and_corpus", "verbatim_law", "required_outputs", "companion_tests"]) + ( + "\nPaste this as `review_finding`:\n\n> Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml " + "with each element of the separated-spouse rule stated from the statute.\n" + ) + assert lint_issue({"body": body}).ready + + +def test_corpus_artifact_is_not_a_citation(): + body = build(["verbatim_law", "required_outputs", "review_finding", "companion_tests"]) + ( + "\nModule `us/statutes/26/32/d.yaml`; source axiom-corpus " + "`us/statute/2026-07-13-recovery-r2026-07-15-self-contained-r2026-07-17-dedup.jsonl` line 283.\n" + ) + assert lint_issue({"body": body}).missing == ["corpus_citation"] + + +def test_state_citation_is_not_matched_inside_us_prefix(): + res = lint_texts([("body", "corpus us-id/statute/63-3022P")]) + assert res.found["corpus_citation"] == "us-id/statute/63-3022P" + + +@given(st.sets(st.sampled_from(sorted(SECTIONS)))) +@settings(max_examples=64, deadline=None) +def test_property_missing_is_exactly_what_was_removed(present): + """For every subset of sections, the lint reports exactly the absent elements.""" + res = lint_issue({"body": build(sorted(present))}) + expected = set() + for sec in SECTIONS: + if sec not in present: + expected |= {"module_path", "corpus_citation"} if sec == "module_and_corpus" else {sec} + assert set(res.missing) == expected + assert res.ready == (not expected) + assert set(res.found) | set(res.missing) == set(ELEMENTS) + + +@given(st.text(max_size=2000)) +@settings(max_examples=200, deadline=None) +def test_property_lint_total(text): + res = lint_issue({"body": text}) + assert set(res.found).isdisjoint(res.missing) + assert set(res.found) | set(res.missing) == set(ELEMENTS) + for evidence in res.found.values(): + assert isinstance(evidence, str) and evidence + + +def test_found_evidence_points_at_text(): + res = lint_issue({"body": READY_ISSUE_BODY}) + assert re.search(r"us/statute/26/32/d", res.found["corpus_citation"]) + + +def test_unfinished_elements_do_not_count(): + body = build(["module_and_corpus", "verbatim_law", "review_finding"]) + ( + "\n## Required outputs\n\n1. TODO\n\n## Companion tests\n\n- TODO: companion cases with expected values.\n" + ) + assert set(lint_issue({"body": body}).missing) == {"required_outputs", "companion_tests"} + + +def test_companion_tests_need_expected_values(): + body = build(["module_and_corpus", "verbatim_law", "required_outputs", "review_finding"]) + ( + "\n## Companion tests\n\n- a separated spouse with a qualifying child\n- a joint filer\n" + ) + assert lint_issue({"body": body}).missing == ["companion_tests"] + + +def test_prose_back_reference_is_not_the_element(): + body = build(["module_and_corpus", "verbatim_law", "companion_tests"]) + ( + "\n## review_finding\n\n> Encode 26 USC 32(d). The module must state each required output below from the " + "provision's own terms, each with a proof excerpt, and pass the companion cases below.\n" + ) + assert lint_issue({"body": body}).missing == ["required_outputs"] + + +def test_natural_output_wording_counts(): + body = build(["module_and_corpus", "verbatim_law", "companion_tests"]) + ( + "\n## review_finding\n\n> Encode 26 USC 32(d). Required statutory outputs are `separated_spouse_not_married` " + "and `joint_return_required`, each with a verbatim excerpt.\n" + ) + assert lint_issue({"body": body}).ready + + +@given(st.integers(1, 12), st.booleans()) +@settings(max_examples=24, deadline=None) +def test_property_a_finder_draft_is_never_ready(n, with_tests): + """Draft → lint: whatever the finder drafts without law text needs a person before it is dispatch-ready.""" + from axiom_parity.finder.draft import draft_issue + from axiom_parity.finder.propose import Proposal, TestCase + from axiom_parity.finder.references import Reference + + ref = Reference("f.yaml", "gov.x.rate", "parameter", "https://www.law.cornell.edu/uscode/text/26/32") + group = [Proposal(f"us/statute/26/32/{chr(100 + i)}", True, [ref] if i % 2 else []) for i in range(n)] + cases = [TestCase("t.yaml", "case_a", 2026, {"income": 1}, {"credit": 2})] if with_tests else [] + body = draft_issue(group, "PolicyEngine/policyengine-us", 1, "Policy change", cases, "main", fetch=False) + res = lint_issue({"body": body}) + assert "TODO" in body and not res.ready + assert "verbatim_law" in res.missing + + +def test_placeholders_in_any_case_are_unfinished(): + from axiom_parity.finder.draft import draft_issue + from axiom_parity.finder.propose import Proposal + + body = draft_issue( + [Proposal("us/statute/26/32/" + c, True, []) for c in "defg"], "o/r", 1, "t", [], "main", fetch=False + ) + assert not lint_issue({"body": body.replace("TODO", "todo")}).ready + assert not lint_issue({"body": body.replace("TODO", "Tbd")}).ready + + +@given(st.integers(1, 1000)) +@settings(max_examples=30, deadline=None) +def test_property_list_numbering_is_not_an_expected_outcome(n): + body = build(["module_and_corpus", "verbatim_law", "required_outputs", "review_finding"]) + body += f"\n## Companion tests\n\n{n}. a joint filer\n" + assert "companion_tests" in lint_issue({"body": body}).missing + + +def test_negative_judgment_outcomes_and_short_output_lists_count(): + base = build(["module_and_corpus", "verbatim_law", "required_outputs", "review_finding"]) + assert lint_issue({"body": base + "\n## Companion tests\n\n- a joint filer: does not hold\n"}).ready + base = build(["module_and_corpus", "verbatim_law", "review_finding", "companion_tests"]) + assert lint_issue({"body": base + "\nRequired outputs: `a`, `b`\n"}).ready + + +def test_a_denied_review_finding_is_not_one(): + from axiom_parity.issue_lint import review_finding_text + + body = build(["module_and_corpus", "required_outputs", "companion_tests"]) + body += "\nNo review_finding is supplied.\n\n" + SECTIONS["verbatim_law"].split("\n\n", 1)[1] + assert review_finding_text(body) is None + assert "review_finding" in lint_issue({"body": body}).missing + + +def test_outputs_named_inside_the_finding_count(): + body = build(["module_and_corpus", "verbatim_law", "companion_tests"]) + ( + "\n## review_finding\n\n> Add a Judgment regulation_10_specifies_amounts_for_tax_year that holds only for " + "the tax year beginning 6 April 2026, using the leaf input tax_year_is_2026_27.\n" + ) + assert lint_issue({"body": body}).ready + + +def _without(section: str, body: str = READY_ISSUE_BODY) -> str: + """The ready body with one "## section" removed.""" + head, _, rest = body.partition(f"## {section}") + tail = rest.partition("\n## ")[2] + return head + ("## " + tail if tail else "") + + +@given( + st.integers(0, 1_000_000), st.sampled_from(["- input income: {n}", "- inputs: wages {n}", "input:\n income: {n}"]) +) +@settings(max_examples=40, deadline=None) +def test_property_an_input_is_not_an_expected_outcome(n, case): + body = READY_ISSUE_BODY.split("## Companion tests")[0] + "## Companion tests\n\n" + case.format(n=n) + "\n" + assert "companion_tests" in lint_issue({"body": body}).missing + + +def test_a_case_with_inputs_and_a_result_still_counts(): + head = READY_ISSUE_BODY.split("## Companion tests")[0] + "## Companion tests\n\n" + for case in ( + "- input income: 0; expect credit 250", + "- inputs: wages 10000, children 2 → credit: 4000", + "```yaml\n- name: a\n input:\n wages: 10000\n output:\n credit: 3400\n```", + "| Inputs | Expected |\n|---|---|\n| wages 10000 | 3400 |", + ): + assert lint_issue({"body": head + case + "\n"}).ready, case + + +IDENT = st.from_regex(r"[a-z]{1,6}(_[a-z0-9]{1,6}){2,4}", fullmatch=True) + + +@given( + IDENT, + st.sampled_from( + [ + "Add an input named {x} to this module.", + "The rule takes {x} as an input.", + "Import {x} from us:statutes/26/1/h.", + ] + ), +) +@settings(max_examples=40, deadline=None) +def test_property_a_named_input_is_not_a_required_output(ident, wording): + finding = wording.format(x=ident) + " Preserve the existing statutory text and its proof excerpts as they are." + body = _without("Required outputs").replace( + "> Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml. The separated-spouse rule must be\n" + "> stated from its statutory elements.", + "> " + finding, + ) + assert "required_outputs" in lint_issue({"body": body}).missing + + +def test_an_output_declared_in_the_finding_counts(): + for finding in ( + "Add `section_199a_net_capital_gain` Money (TaxUnit) = max(0, line 15) + qualified dividends, with proof.", + "Add the secondary percentage's history to uk/statutes/ukpga/1992/4/9.yaml#secondary_percentage, each with proof.", + "State the monthly figures. Name them secondary_threshold_monthly_equivalent and so on, with excerpts.", + ): + body = _without("Required outputs").replace( + "> Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml. The separated-spouse rule must be\n" + "> stated from its statutory elements.", + "> " + finding, + ) + assert lint_issue({"body": body}).ready, finding + + +@given( + st.sampled_from( + [ + "Review_finding will be supplied later:", + "Review_finding is unavailable:", + "review_finding to follow:", + "`review_finding` comes next week:", + "review_finding (pending):", + "The review_finding is still to be written:", + "Review_finding (none):", + "Add the review_finding here when done:", + ] + ) +) +def test_a_deferred_review_finding_cue_does_not_borrow_the_law(cue): + from axiom_parity.issue_lint import review_finding_text + + body = _without("review_finding (paste as-is)").replace("## Law (verbatim)\n", f"## Law (verbatim)\n\n{cue}\n") + assert review_finding_text(body) is None + assert "review_finding" in lint_issue({"body": body}).missing + + +def test_an_inline_cue_takes_the_block_right_below_it_only(): + from axiom_parity.issue_lint import review_finding_text + + quote = "> " + "Encode the rule from its statutory elements, with proof excerpts for each amount. " * 2 + assert review_finding_text(f"`review_finding`:\n\n{quote}\n").startswith("Encode the rule") + assert review_finding_text(f"**`review_finding`:**\n\n{quote}\n").startswith("Encode the rule") + assert review_finding_text(f"`review_finding`:\n\nnot written yet\n\n**Law**\n\n{quote}\n") is None + + +def test_every_finding_in_a_text_is_read_once(): + from axiom_parity.issue_lint import review_findings + + one = "> " + "Encode reg 3 as its own module and state each amount with a proof excerpt from the text. " * 2 + two = "review_finding: >-\n " + "Re-encode reg 4 in place and correct the taper rate it states today. " * 2 + md = f"**Step 1.** `review_finding` (paste as-is):\n\n{one}\n\n**Step 2.** Paste this as `review_finding`:\n\n```yaml\n{two}\n```\n" + found = review_findings(md) + assert [f[:13] for f in found] == ["Encode reg 3 ", "Re-encode reg"] + + +def test_braces_expand_to_paths(): + from axiom_parity.issue_lint import expand_braces + + assert expand_braces( + "corpus uk/regulation/uksi/2006/213/{2,19,13D} at uk/regulations/uksi/2006/213/{2,19}.yaml" + ) == ( + "corpus uk/regulation/uksi/2006/213/2, uk/regulation/uksi/2006/213/19, uk/regulation/uksi/2006/213/13D at " + "uk/regulations/uksi/2006/213/2.yaml, uk/regulations/uksi/2006/213/19.yaml" + ) + assert expand_braces("a set {x, y} of things") == "a set {x, y} of things" + + +@given(st.integers(0, 1_000_000), st.integers(0, 1_000_000)) +@settings(max_examples=50, deadline=None) +def test_property_a_list_of_input_facts_needs_an_output(income, children): + prefix = READY_ISSUE_BODY.split("## Companion tests")[0] + for case in ( + f"- inputs: income: {income}; children: {children}", + f"- income: {income}\n- children: {children}", + f"- income is {income} and there are {children} children", + ): + body = prefix + "## Companion tests\n\n" + case + "\n" + assert "companion_tests" in lint_issue({"body": body}).missing, case + + +def test_a_yaml_case_counts_whatever_the_order_of_its_keys(): + prefix = READY_ISSUE_BODY.split("## Companion tests")[0] + "## Companion tests\n\n" + first = "- input:\n income: 0\n output:\n credit: 0\n name: zero_income\n" + last = "- name: zero_income\n output:\n credit: 0\n input:\n income: 0\n" + assert lint_issue({"body": prefix + first}).ready + assert lint_issue({"body": prefix + last}).ready + only_inputs = "- name: zero_income\n input:\n income: 0\n children: 2\n" + assert "companion_tests" in lint_issue({"body": prefix + only_inputs}).missing + + +def test_an_input_rule_is_not_a_required_output(): + body = _without("Required outputs").replace( + "> Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml. The separated-spouse rule must be\n" + "> stated from its statutory elements.", + "> Add an input rule a_b_c to this module. Preserve the existing statutory text and its proof excerpts as they are.", + ) + assert "required_outputs" in lint_issue({"body": body}).missing + + +@given( + st.sampled_from( + [ + "**Review finding (pending):**", + "**Review finding is unavailable:**", + "## review_finding (to follow)", + "**Review finding: none yet**", + "- **review finding** (pending):", + ] + ) +) +def test_a_deferred_title_does_not_borrow_the_law(title): + from axiom_parity.issue_lint import review_finding_text + + body = _without("review_finding (paste as-is)").replace("## Law (verbatim)\n", f"## Law (verbatim)\n\n{title}\n") + assert review_finding_text(body) is None + assert "review_finding" in lint_issue({"body": body}).missing + + +def test_back_to_back_finding_labels_keep_both_findings(): + from axiom_parity.issue_lint import review_findings + + first = ( + "Encode reg 3 as its own module and state each amount with a proof excerpt from the text of the regulation. " + * 2 + ) + second = "> " + "Re-encode reg 4 in place and correct the taper rate it states today, with its proof excerpt. " * 2 + md = f"**Review finding one**\n{first}\n**Review finding two**\n{second}\n" + assert [f[:13] for f in review_findings(md)] == ["Encode reg 3 ", "Re-encode reg"] + + +def test_several_brace_groups_expand_to_every_path(): + from axiom_parity.issue_lint import expand_braces + + assert expand_braces("uk/regulations/uksi/2006/213/schedule/{6,9}/paragraph/{1,2}.yaml") == ", ".join( + f"uk/regulations/uksi/2006/213/schedule/{s}/paragraph/{p}.yaml" for s in (6, 9) for p in (1, 2) + ) diff --git a/axiom-parity/tests/test_na.py b/axiom-parity/tests/test_na.py new file mode 100644 index 0000000..9003701 --- /dev/null +++ b/axiom-parity/tests/test_na.py @@ -0,0 +1,55 @@ +import pytest + +from axiom_parity.na import CATEGORIES, classify + + +@pytest.mark.parametrize( + "reason,category", + [ + ("data: calibration targets only", "data"), + ("microsim-only: labour supply responses", "microsim-only"), + ("(infra): cache aliasing", "infra"), + ("reference-only", "metadata"), + ("reference metadata only", "metadata"), + ("documentation and test fixtures only; no change to policy logic", "docs-tests"), + ("branch/cache plumbing for reading stored inputs; no policy rule changes", "infra"), + ("calibration target data (IRS SOI statistics), not a policy rule.", "data"), + ("contrib reform (proposed NYC policy, not enacted law; no rulespec module)", "not-law"), + ( + "the HBAI absolute low-income line is a statistical measure, not a legislated provision " + "(rulespec-uk encodes no poverty lines or WRWA 2016 s.4)", + "microsim-only", + ), + ("the surcharge is not yet legislated (Budget 2025 policy paper and MHCLG consultation only)", "not-law"), + ], +) +def test_allowed(reason, category): + v = classify(reason) + assert v.ok, v.message + assert v.category == category + + +@pytest.mark.parametrize( + "reason", + [ + "", + " ", + "rulespec-us does not encode the HHS state median income estimates yet", + "rulespec-us does not encode CA CARE/FERA, EZ-SAVE or SHARE yet", + "(pe-parity follow-up if rulespec-us lacks the worksheet)", + "because", + ], +) +def test_rejected(reason): + assert not classify(reason).ok + + +def test_axiom_lacks_with_explicit_category_is_allowed(): + v = classify("not-law: rulespec has no module because this is a proposal") + assert v.ok and v.category == "not-law" and v.explicit + + +def test_every_category_is_accepted_explicitly(): + for name in CATEGORIES: + v = classify(f"{name}: a reason") + assert v.ok and v.category == name and v.explicit diff --git a/axiom-parity/tests/test_neighbourhoods.py b/axiom-parity/tests/test_neighbourhoods.py new file mode 100644 index 0000000..f78d416 --- /dev/null +++ b/axiom-parity/tests/test_neighbourhoods.py @@ -0,0 +1,596 @@ +"""Property tests over the neighbourhood of each free-text rule. + +Five review rounds each found a phrasing one step away from a fixed one: a +numbered list where a bulleted one worked, triple emphasis where double did. +These tests build the phrasings from their parts, so a rule is tried against +every combination of markers, wrappers and wordings near the ones it was +written for, not only against the examples that prompted it. +""" + +from __future__ import annotations + +import re + +from conftest import READY_ISSUE_BODY, RS_US +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from axiom_parity.axiom_line import QUEUED, parse_line +from axiom_parity.check import Context, run_check +from axiom_parity.drain import DEFERRED, IMPORT, MENTION, WORK, _modules_in, _pair_line, issue_inputs, plan +from axiom_parity.issue_lint import lint_issue, review_finding_text +from axiom_parity.policy_paths import PolicyPaths + +EMPHASIS = st.sampled_from(["", "*", "**", "***", "_", "__", "__*"]) + + +def emphasised(word: str, mark: str) -> str: + return f"{mark}{word}{mark[::-1]}" + + +# --- the axiom line ------------------------------------------------------------------- + +NEGATION = st.sampled_from( + [ + "is not", + "isn't", + "is never", + "is hardly", + "will", + "may", + "should", + "is no longer", + "cannot be", + "has not been", + "won't be", + "is still not", + "was never", + "is yet to be", + ] +) +BETWEEN = st.sampled_from( + [ + "", + "yet", + "(yet)", + ", in fact,", + ", as of Oct. 10,", + "(per rulespec-us#5)", + "really", + "(as of 2026)", + "— for now —", + "- yet -", + ", strictly speaking,", + "(still)", + ] +) + + +@given(NEGATION, BETWEEN, EMPHASIS, EMPHASIS) +@settings(max_examples=400) +def test_a_denied_status_is_never_a_claim(negation, between, on_negation, on_status): + words = negation.split() + words[-1] = emphasised(words[-1], on_negation) + modal = negation.split()[0] in ("will", "may", "should") + tail = "be " if modal and "be" not in negation.split() else "" + line = f"rulespec-us#1416 {' '.join(words)} {between} {tail}{emphasised('queued', on_status)}" + assert not parse_line(line).claims, line + + +@given(EMPHASIS, st.sampled_from(["", " (see the issue)", " — dispatch-ready", ", not yet encoded"])) +def test_an_affirmed_status_is_a_claim_whatever_its_emphasis(mark, note): + line = f"rulespec-us#1416 {emphasised('queued', mark)}{note}" + assert [c.status for c in parse_line(line).claims] == [QUEUED], line + + +@given( + st.sampled_from(["is not queued", "will be queued", "is not (yet) queued", "isn't ***queued***", "may be queued"]), + st.sampled_from(["The rest is queued:", "Queued:", "These are queued:"]), +) +def test_a_bullet_that_denies_a_status_does_not_inherit_one(denial, lead): + line = parse_line(lead, items=[f"rulespec-us#1416 {denial}", "rulespec-us#1417"]) + assert [(c.status, [r.number for r in c.rulespec_refs]) for c in line.claims] == [(QUEUED, [1417])] + + +# --- companion evidence --------------------------------------------------------------- + +PATHS = PolicyPaths("policyengine_us") +POLICY = ["policyengine_us/parameters/gov/irs/credits/ctc/amount.yaml"] +TEST = "`three_children_social_security_excess`" + + +def _codes(hub, body): + ctx = Context(gh=hub.client(), default_rulespec=RS_US) + report = run_check( + repo="PolicyEngine/policyengine-us", pr=1, body=body, changed_files=POLICY, ctx=ctx, paths=PATHS, mode="enforce" + ) + return {f.code for c in report.claims for f in c.findings if f.blocking} + + +@given( + st.sampled_from(["not", "no", "without", "excluding", "except", "never"]), + st.sampled_from( + ["{t}", "({t})", "[{t}]", "(`unrelated_case`, {t})", "(yet) {t}", "((`unrelated_case`), {t})", "the case {t}"] + ), +) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], max_examples=60, deadline=None) +def test_a_test_named_under_a_negation_is_never_evidence(hub, negation, shape): + body = f"axiom: us:statutes/26/24/d encoded-correct ({negation} {shape.format(t=TEST)})" + assert "encoded-correct-no-test" in _codes(hub, body), body + + +@given(st.sampled_from(["({t})", "({t}, not `unrelated_case`)", "(no new test is needed: {t} covers it)", "(see {t})"])) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None) +def test_an_affirmed_test_is_evidence(hub, shape): + body = f"axiom: us:statutes/26/24/d encoded-correct {shape.format(t=TEST)}" + assert not _codes(hub, body), body + + +# --- the dispatch-ready lint ---------------------------------------------------------- + +LAW_QUOTE_ONLY = ( + READY_ISSUE_BODY.split("## review_finding (paste as-is)")[0] + + "## Companion" + + READY_ISSUE_BODY.split("## Companion")[1] +) +DEFERRAL = st.sampled_from( + [ + "pending", + "awaiting review", + "not available", + "coming soon", + "unavailable", + "to follow", + "not yet written", + "TBD", + "none yet", + "unavailable until the encoder owner approves", + ] +) +LABEL = st.sampled_from( + [ + "**Review finding ({d}):**", + "**Review finding:** {d}:", + "**Review finding:** {d}.", + "- **review finding** ({d}):", + "## review_finding ({d})", + "Review_finding is {d}:", + "`review_finding` ({d}):", + "**Review finding:** {d} and it will take a while because the owner is away this week:", + "### Review finding — {d}", + "**review_finding** — {d}:", + "> **Review finding ({d}):**", + ] +) + + +@given(LABEL, DEFERRAL) +@settings(max_examples=120) +def test_a_deferred_finding_label_never_borrows_the_law_quote(label, deferral): + body = LAW_QUOTE_ONLY.replace("## Law (verbatim)\n", f"## Law (verbatim)\n\n{label.format(d=deferral)}\n") + assert review_finding_text(body) is None, label.format(d=deferral) + assert "review_finding" in lint_issue({"body": body}).missing + + +def _finding_only(finding: str) -> str: + """The ready body without its Required outputs section, with ``finding`` as the review_finding.""" + head, _, rest = READY_ISSUE_BODY.partition("## Required outputs") + rest = "## review_finding" + rest.partition("## review_finding")[2] + head2, _, tail = rest.partition("## Companion") + return head + "## review_finding (paste as-is)\n\n> " + finding + "\n\n## Companion" + tail + + +NAME = st.from_regex(r"[a-z]{1,6}(_[a-z0-9]{1,6}){2,3}", fullmatch=True) +PAD = " Preserve the existing statutory text and its proof excerpts as they are, for every amount it states." + + +@given( + NAME, + st.sampled_from( + [ + "Add an input named {x}.", + "Add an input rule {x} to this module.", + "Add a rule {x} as a new input.", + "Add a rule {x}, a new leaf input.", + "Add `{x}` as an input.", + "Add a rule {x} (an input).", + "The rule takes {x} as an input.", + "Import the rule {x} from us:statutes/26/1/h.", + "Add `{x}` to the inputs.", + "Add a rule {x} among the leaf inputs.", + "Declare {x} as input.", + ] + ), +) +@settings(max_examples=120) +def test_a_declared_input_is_never_a_required_output(name, wording): + body = _finding_only(wording.format(x=name) + PAD) + assert "required_outputs" in lint_issue({"body": body}).missing, wording + + +@given( + NAME, + st.sampled_from( + [ + "Output Money {x}.", + "Outputs: `{x}`.", + "Add a Judgment {x} that holds for the year, using the leaf input wages.", + "Add `{x}` Money (TaxUnit) = max(0, wages - 100).", + "Name them {x} and the like.", + "Add the new amount to us/statutes/26/32/d.yaml#{x}.", + ] + ), +) +def test_a_declared_output_is_a_required_output(name, wording): + assert lint_issue({"body": _finding_only(wording.format(x=name) + PAD)}).ready, wording + + +CASES_HEAD = READY_ISSUE_BODY.split("## Companion tests")[0] + "## Companion tests\n\n" + + +@given( + st.integers(0, 99_999), + st.sampled_from( + [ + "- input income: {n}", + "- inputs: income: {n}; children: {n}", + "1. income: {n}", + "- income is {n}", + "input:\n income: {n}\n children: {n}", + "- name: a\n input:\n income: {n}", + "| Case | Inputs | Expected |\n|---|---|---|\n| single filer | income {n} | |", + "| Case | Income |\n|---|---|\n| single filer | input {n} |", + "We expect to add the cases later.", + "- wages: {n}, children: {n}", + "- given income {n} and {n} children", + ] + ), +) +@settings(max_examples=150) +def test_cases_without_a_stated_result_are_not_companion_tests(n, case): + assert "companion_tests" in lint_issue({"body": CASES_HEAD + case.format(n=n) + "\n"}).missing, case + + +@given( + st.integers(0, 99_999), + st.sampled_from( + [ + "- input income: {n}; expect credit {n}", + "- income {n} → credit {n}", + "- name: a\n input:\n income: {n}\n output:\n credit: {n}", + "- output:\n credit: {n}\n input:\n income: {n}", + "| Case | Inputs | Expected |\n|---|---|---|\n| single filer | income {n} | {n} |", + "1. Single filer with pay £40,000 and a loss: relief £{n}; net income £{n}.", + "- a separated spouse apart for six months: holds", + ] + ), +) +@settings(max_examples=100) +def test_cases_with_a_stated_result_are_companion_tests(n, case): + assert lint_issue({"body": CASES_HEAD + case.format(n=n) + "\n"}).ready, case + + +# --- the drain's reading of findings --------------------------------------------------- + +MARKER = st.sampled_from(["- ", "* ", "+ ", "1. ", "2) ", "", "> - ", "| "]) +SEPARATOR = st.sampled_from([" — ", " - ", ": ", " | ", " (corpus citation ", " → "]) + + +@given(MARKER, SEPARATOR) +def test_a_pairing_line_is_read_whatever_its_list_marker(marker, separator): + module, citation = "us-nm/statutes/7-2-18/13.yaml", "us-nm/statute/7-2-18.13" + closing = ")" if "(" in separator else (" |" if marker == "| " else "") + assert _pair_line(f"{marker}{module}{separator}{citation}{closing}") == ([module], [citation]) + + +A = "us/statutes/26/32/a.yaml" +REQUEST = st.sampled_from( + [ + "Encode", + "Re-encode", + "Create", + "Add a rule to", + "Repair", + "Extend", + "Update", + "Correct", + "Complete", + "Then encode the deferred", + ] +) +AFTER = st.sampled_from( + [ + "as a module", + "with a rule", + "in place", + "from the statutory text", + "for 2026", + "as its own module", + "so it states the amount", + "and its companion cases", + ] +) + + +@given(REQUEST, AFTER) +@settings(max_examples=120) +def test_requested_work_is_work_whatever_follows_the_module(request, after): + sentence = f"{request} {A} {after}." + assert _modules_in(sentence) == [(A, WORK)], sentence + body = READY_ISSUE_BODY.replace( + "Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml.", + f"{sentence} State each element with its proof.", + ) + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert "us/statute/26/32/a" in row.targets + (batch,) = plan([row], corpus={"us/statute/26/32/d"}) + assert not batch.ready # the requested provision is not in the pin + + +@given( + st.sampled_from( + [ + ("Add the new amount to the guarantee that {m} composes.", WORK), + ("Compose it into the gains that {m} reads.", WORK), + ("Add a companion case to {m}.", WORK), + ("(3) In {m}, compose the deduction into Step 2.", WORK), + ("Modules: {m}.", WORK), + ("{m} needs a rule for the separated spouse.", WORK), + ("Import {m} for the rate.", IMPORT), + ("Encode it by importing {m}.", IMPORT), + ("Imports: {m}.", IMPORT), + ("Take the amount from {m}.", IMPORT), + ("Do not import {m}.", DEFERRED), + ("Encode it without importing {m}.", DEFERRED), + ("Declare a deferred dependency on {m}.", DEFERRED), + ("Encode it mirroring the existing {m}.", MENTION), + ("{m} currently carries the amount as an input.", MENTION), + ("Keep {m} unchanged.", MENTION), + ("It is defined in {m}.", MENTION), + ] + ) +) +def test_each_reason_for_naming_a_module_is_told_apart(case): + sentence, kind = case + assert _modules_in(sentence.format(m=A)) == [(A, kind)], sentence + + +def test_a_program_module_with_no_citation_is_held(): + finding = "Wire the new element into uk/programs/universal-credit/fy-2026-27.yaml and state its proof excerpt. " * 2 + body = READY_ISSUE_BODY.replace("Encode 26 USC 32(d) as its own module at us/statutes/26/32/d.yaml.", finding) + row = issue_inputs("R", {"number": 1, "body": body}, []) + assert row.unresolved == ["uk/programs/universal-credit/fy-2026-27.yaml"] + (batch,) = plan([row], corpus={"us/statute/26/32/d"}) + assert not batch.ready + + +def test_the_fixture_is_what_these_tests_assume(): + assert lint_issue({"body": READY_ISSUE_BODY}).ready + assert re.search(r"## review_finding \(paste as-is\)", READY_ISSUE_BODY) + + +# --- structural properties: what may stand between a reference and its status -------- + +# Separators and closing marks may also stand there: "rulespec-us#1416 — queued", "(rulespec-us#1416) queued". +ALLOWED = [ + "is", + "are", + "was", + "were", + "now", + "already", + "also", + "both", + "all", + "each", + "fully", + "—", + "-", + ":", + ")", + "]", + "'", + "‘", + "’", +] +OTHER_WORDS = [ + "not", + "never", + "isn't", + "isn’t", + "isnt", + "[yet]", + "(yet)", + "yet", + "soon", + "wrongly", + "hardly", + "but", + "pending", + "maybe", + "still", + "to", + "be", + "will", + "has", + "been", + "planned", + ",", + "(", + "[", + "{", + "}", + "«", + "»", + "/", + "!", + "?", + "no", + "nope", + "un", + "almost", + "nearly", + "&", + "+", +] + + +@given(st.lists(st.sampled_from(ALLOWED + OTHER_WORDS), max_size=4), EMPHASIS) +@settings(max_examples=600) +def test_a_status_is_claimed_only_across_the_allowed_words(between, mark): + """Between a reference and its status, anything but the allowed words means no claim.""" + line = f"rulespec-us#1416 {' '.join(between)} {emphasised('queued', mark)}" + claimed = bool(parse_line(line).claims) + assert claimed == all(w in ALLOWED for w in between), line + + +DENIALS = [ + "not", + "never", + "no", + "neither", + "nor", + "without", + "cannot", + "can't", + "can’t", + "won't", + "isnt", + "will", + "may", + "should", + "must", + "until", + "unless", + "if", + "pending", + "later", + "to be", + "yet to", +] + + +@given(st.sampled_from(DENIALS), st.sampled_from(["{d} ", "{d}, ", "It is {d} so that ", "({d}) ", "[{d}] "])) +@settings(max_examples=200) +def test_a_denial_before_the_reference_means_no_claim(denial, shape): + line = shape.format(d=denial) + "rulespec-us#1416 queued" + assert not parse_line(line).claims, line + + +# --- the lint, over the dimensions round 6 found missing ------------------------------ + +MODIFIERS = st.lists( + st.sampled_from(["scoped", "leaf", "Boolean", "Date", "new", "opaque", "Money", "extra"]), max_size=3 +) + + +@given( + NAME, + MODIFIERS, + st.sampled_from(["as a {m}input", "as an {m}input", "as {m}inputs", ", a {m}input", "(a {m}input)"]), +) +@settings(max_examples=200) +def test_a_name_followed_by_an_input_designation_is_never_an_output(name, modifiers, shape): + designation = shape.format(m="".join(w + " " for w in modifiers)) + body = _finding_only(f"Add `{name}` {designation}." + PAD) + assert "required_outputs" in lint_issue({"body": body}).missing, designation + + +@given( + st.sampled_from(["**Review finding:** ", "**review_finding**: ", "- **Review finding:** "]), + st.sampled_from(["Coming soon", "Pending", "To follow", "Not yet written", "Awaiting review", "TBD"]), + st.integers(0, 200), + st.sampled_from(["", ".", ":", "!"]), +) +@settings(max_examples=200) +def test_a_deferred_finding_on_the_label_line_is_none_whatever_its_length(label, deferral, pad, end): + remainder = f"{deferral} when the encoder owner has checked the corpus text {'x' * pad}{end}" + body = LAW_QUOTE_ONLY.replace("## Law (verbatim)\n", f"## Law (verbatim)\n\n{label}{remainder}\n") + assert review_finding_text(body) is None, remainder[:60] + + +@given(st.integers(0, 99_999), st.sampled_from(["Input income", "Inputs", "Given wages", "Facts"])) +def test_an_input_column_is_not_a_result(n, column): + table = f"| Case | {column} | Expected |\n|---|---|---|\n| single filer | {n} | |\n" + assert "companion_tests" in lint_issue({"body": CASES_HEAD + table}).missing + filled = f"| Case | {column} | Expected |\n|---|---|---|\n| single filer | {n} | {n} |\n" + assert lint_issue({"body": CASES_HEAD + filled}).ready + + +# --- the drain, over layouts, wrappers and modifiers ----------------------------------- + +MODULES = st.sampled_from( + [ + "us/statutes/26/32/a.yaml", + "us/policies/income_tax/taxable_income_pipeline.yaml", + "uk/policies/universal_credit_composed_award_pipeline.yaml", + "uk/programs/universal-credit/fy-2026-27.yaml", + "programs/us-ar/tanf/fy-2026.yaml", + "us-nm/statutes/7-2-18/13.yaml", + "us:statutes/26/32/a", + ] +) +WRAPPER = st.sampled_from(["", "`", "**", "_", "__", "*", "**`", "_`"]) +MODIFIER = st.sampled_from( + [ + "", + "the ", + "the existing ", + "the current ", + "this ", + "a new ", + "the import in ", + "the import to ", + "the companion test of ", + "the rules in ", + ] +) + + +@given(st.sampled_from(["Repair", "Encode", "Extend", "Update", "Fix", "Add a case to"]), MODIFIER, MODULES, WRAPPER) +@settings(max_examples=400) +def test_requested_work_is_work_whatever_wraps_or_modifies_the_module(request, modifier, module, wrapper): + from axiom_parity.drain import normal_module, plain + + sentence = f"{request} {modifier}{wrapper}{module}{wrapper[::-1]} with the new rule." + assert (normal_module(module), WORK) in _modules_in(plain(sentence)), sentence + + +@given(st.sampled_from(["(1) ", "(a) ", "a) ", "i. ", "iv) ", "10. "]), SEPARATOR) +def test_a_pairing_line_is_read_whatever_its_numbering(marker, separator): + module, citation = "us-nm/statutes/7-2-18/13.yaml", "us-nm/statute/7-2-18.13" + closing = ")" if "(" in separator else "" + assert _pair_line(f"{marker}{module}{separator}{citation}{closing}") == ([module], [citation]) + + +@given( + st.sampled_from( + [ + "rulespec-us#1416", + "TheAxiomFoundation/rulespec-us#1416", + "https://github.com/TheAxiomFoundation/rulespec-us/issues/1416", + "`rulespec-us#1416`", + "(rulespec-us#1416)", + ] + ), + st.sampled_from( + [ + "", + " ([addendum](https://github.com/TheAxiomFoundation/rulespec-us/issues/1416#issuecomment-1))", + " (see TheAxiomFoundation/rulespec-us#1416)", + " (reg 89(1)(a): https://github.com/TheAxiomFoundation/rulespec-us/issues/1416#issuecomment-2)", + ] + ), +) +def test_a_reference_repeated_in_the_note_does_not_hide_the_claim(ref, note): + """The parsers fold repeats of one reference together; the claim must survive that.""" + assert [c.status for c in parse_line(f"{ref} queued{note}").claims] == [QUEUED] + + +@given(st.sampled_from(["queued", "**queued**", "queued (still open)"])) +def test_a_markdown_link_to_the_issue_is_its_reference(status): + line = ( + f"[TheAxiomFoundation/rulespec-us#1444](https://github.com/TheAxiomFoundation/rulespec-us/issues/1444) {status}" + ) + claims = parse_line(line).claims + assert [c.status for c in claims] == [QUEUED] and claims[0].rulespec_refs[0].number == 1444 diff --git a/axiom-parity/tests/test_policy_paths.py b/axiom-parity/tests/test_policy_paths.py new file mode 100644 index 0000000..12dade3 --- /dev/null +++ b/axiom-parity/tests/test_policy_paths.py @@ -0,0 +1,19 @@ +from axiom_parity.policy_paths import PolicyPaths, country_for_repo, package_for_repo + + +def test_policy_paths(): + p = PolicyPaths("policyengine_uk") + assert p.is_policy("policyengine_uk/parameters/gov/hmrc/income_tax/rates.yaml") + assert p.is_policy("policyengine_uk/variables/gov/dwp/universal_credit.py") + assert p.is_policy("policyengine_uk/reforms/scotland/reform.py") + assert not p.is_policy("policyengine_uk/parameters/gov/README.md") + assert not p.is_policy("policyengine_uk/tests/policy/baseline/x.yaml") + assert not p.is_policy("policyengine_uk/data/economic_assumptions.yaml") + assert not p.is_policy("docs/book/intro.md") + assert not p.is_policy(".github/workflows/x.yaml") + assert not p.is_policy("policyengine_us/parameters/gov/x.yaml") + + +def test_repo_names(): + assert package_for_repo("PolicyEngine/policyengine-uk") == "policyengine_uk" + assert country_for_repo("PolicyEngine/policyengine-canada") == "canada" diff --git a/axiom-parity/tests/test_refs.py b/axiom-parity/tests/test_refs.py new file mode 100644 index 0000000..25c61c6 --- /dev/null +++ b/axiom-parity/tests/test_refs.py @@ -0,0 +1,112 @@ +from hypothesis import given +from hypothesis import strategies as st + +from axiom_parity.refs import parse_legal_refs, parse_rulespec_refs, parse_test_refs + + +def test_rulespec_ref_forms(): + text = ( + "TheAxiomFoundation/rulespec-us#1416, rulespec-uk#430, " + "https://github.com/TheAxiomFoundation/rulespec-us/pull/1500 and " + "https://github.com/TheAxiomFoundation/rulespec-uk/issues/396#issuecomment-6009163278" + ) + refs = parse_rulespec_refs(text) + assert [(r.repo, r.number, r.kind) for r in refs] == [ + ("rulespec-us", 1416, "unknown"), + ("rulespec-uk", 430, "unknown"), + ("rulespec-us", 1500, "pull"), + ("rulespec-uk", 396, "issue"), + ] + assert refs[3].comment_id == 6009163278 + assert all(r.owner == "TheAxiomFoundation" for r in refs) + + +def test_rulespec_refs_dedupe_and_ignore_other_repos(): + refs = parse_rulespec_refs("rulespec-us#1 and TheAxiomFoundation/rulespec-us#1; axiom-corpus#802; #391") + assert [r.number for r in refs] == [1] + + +def test_foreign_owner_kept_for_the_check_to_reject(): + (ref,) = parse_rulespec_refs("someone/rulespec-us#5") + assert ref.owner == "someone" + + +def test_legal_id_with_rule(): + (ref,) = parse_legal_refs("`us:statutes/26/24/d#ctc_social_security_tax` encoded-correct") + assert (ref.form, ref.module_path, ref.rule) == ("legal-id", "us/statutes/26/24/d.yaml", "ctc_social_security_tax") + assert ref.legal_id == "us:statutes/26/24/d" + + +def test_state_legal_ids_keep_colons_in_paths(): + (ref,) = parse_legal_refs("us-nj:statutes/54a:4-7#age_modified_eligible") + assert ref.module_path == "us-nj/statutes/54a:4-7.yaml" + + +def test_corpus_path_maps_to_module_root(): + (ref,) = parse_legal_refs("uk/regulation/uksi/2013/376/32 encoded-correct") + assert (ref.form, ref.module_path) == ("corpus-path", "uk/regulations/uksi/2013/376/32.yaml") + (ref,) = parse_legal_refs("us/statute/26/32/i/2") + assert ref.module_path == "us/statutes/26/32/i/2.yaml" + + +def test_module_path_with_yaml_and_sentence_punctuation(): + (ref,) = parse_legal_refs("see `us-oh/statutes/5747/02.yaml#individual_tax`.") + assert (ref.form, ref.module_path, ref.rule) == ("module-path", "us-oh/statutes/5747/02.yaml", "individual_tax") + + +def test_test_paths_are_not_module_refs(): + assert parse_legal_refs("uk/regulations/uksi/2001/1004/100.test.yaml::class_4_case") == [] + + +def test_tree_urls_are_not_module_refs(): + assert parse_legal_refs("https://github.com/TheAxiomFoundation/rulespec-us/tree/f468c8d/us/statutes/26") == [] + + +def test_relative_siblings(): + refs = parse_legal_refs("`uk/regulations/uksi/2013/376/18`, `/22`, `/32` and /90") + assert [r.module_path for r in refs] == [ + "uk/regulations/uksi/2013/376/18.yaml", + "uk/regulations/uksi/2013/376/22.yaml", + "uk/regulations/uksi/2013/376/32.yaml", + "uk/regulations/uksi/2013/376/90.yaml", + ] + + +def test_test_refs(): + refs = parse_test_refs( + "(`uk/statutes/ukpga/2002/16/1.test.yaml` case `man_uses_woman_same_birthday_age`; " + "`32.test.yaml::couple_case`; companion cases contributory_esa_counts_in_full)" + ) + pairs = {(r.path, r.case) for r in refs} + assert ("uk/statutes/ukpga/2002/16/1.test.yaml", None) in pairs + assert (None, "man_uses_woman_same_birthday_age") in pairs + assert ("32.test.yaml", "couple_case") in pairs + assert (None, "contributory_esa_counts_in_full") in pairs + + +SEG = st.from_regex(r"[a-z0-9]{1,6}", fullmatch=True) + + +@given( + st.sampled_from(["us", "uk", "us-md", "uk-bury"]), + st.sampled_from(["statutes", "regulations", "policies"]), + st.lists(SEG, min_size=1, max_size=5), + st.one_of(st.none(), st.from_regex(r"[a-z][a-z0-9_]{0,20}", fullmatch=True)), +) +def test_property_legal_id_round_trip(juris, kind, segs, rule): + """A rendered legal id parses back to the same module path and rule.""" + text = f"{juris}:{kind}/{'/'.join(segs)}" + (f"#{rule}" if rule else "") + (ref,) = parse_legal_refs(f"axiom claim `{text}` encoded-correct") + assert ref.module_path == f"{juris}/{kind}/{'/'.join(segs)}.yaml" + assert ref.rule == rule + + +@given(st.sampled_from(["us", "uk"]), st.integers(1, 10**6), st.booleans()) +def test_property_rulespec_ref_round_trip(country, n, url): + text = ( + f"https://github.com/TheAxiomFoundation/rulespec-{country}/issues/{n}" + if url + else f"TheAxiomFoundation/rulespec-{country}#{n}" + ) + (ref,) = parse_rulespec_refs(f"see {text} queued") + assert ref.slug == f"TheAxiomFoundation/rulespec-{country}#{n}" diff --git a/axiom-parity/uv.lock b/axiom-parity/uv.lock new file mode 100644 index 0000000..5c5b2ce --- /dev/null +++ b/axiom-parity/uv.lock @@ -0,0 +1,266 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "axiom-parity" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6" }] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.100" }, + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.168.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a8/bd70d7c2966e561228b9fdc075ee77c0ba577dcbbfbf921edf614db14f6a/hypothesis-6.168.5.tar.gz", hash = "sha256:76b9226962fe11d40858253a967eda95bb65811365286317e0118f4ec8f808c7", size = 511115, upload-time = "2026-10-05T23:26:35.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/0c/7f04c8d277dfc828ba584b7d9d10dbac5e91fce673fa5328f7bd5bf64609/hypothesis-6.168.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ca43a751410a9c6685f029fd5126cc5507664cafaa76017922aa8ae2e17b6620", size = 791539, upload-time = "2026-10-05T23:24:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/660906d83db74eb86feda715d0f2df14836205b14a183332116676733e6f/hypothesis-6.168.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b98707cbe9f430d100a945bbe17612fd3aa44eac1b0ac5299669fe3b8e4128", size = 787208, upload-time = "2026-10-05T23:25:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/36e19492bc4ff354c2be9c8fa7c6ace0c65f9d2c7116656b741680c6ca55/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dde52a0b696c642e7f988a03026c7c29f90daf21e74507b6f865c3ccc9d536e", size = 1115200, upload-time = "2026-10-05T23:25:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/d4/82/3273fb0a3567c09b767bb8fe2824d65e16ae2abb92cf1f43762df723df94/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42f02e4541fe0c17a1320617effc0ab8a8aca2a9af15e3358d4150acf3bbdc00", size = 1145113, upload-time = "2026-10-05T23:25:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/74/59/5c5904555a0bbd4b2898d73ea90c6d03f5be0d8ff0756ac1d519ace6ae66/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf6dd7e537a12763c9afa017f7a6159e5cda608e98670621fa44596a1e8e9288", size = 1139564, upload-time = "2026-10-05T23:25:56.681Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/55654ff9575587a401e304f08ad1d43b7e6318f81c66bd866fdc5ab4665b/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df2c04cd30abf42c52580184216162a75b5508b214a472b86670f6dd50659a3b", size = 1192142, upload-time = "2026-10-05T23:26:06.565Z" }, + { url = "https://files.pythonhosted.org/packages/48/91/4cc9d6e8a950473e07e3ebf00cbb8ee0d76b14d193f94c3de20f1c09e2b1/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278662eb21aaec9eaae71ea4dabd4fe390c2af11ec58a6a0606687cf6d7689b0", size = 1157262, upload-time = "2026-10-05T23:24:59.229Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/4b8aa3be788ea81b9a7bc6b673ed89edd72fd0645c6aa691d4c159ff971a/hypothesis-6.168.5-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:6bcedc4ab8ab92dd0f3af0cfe24dce184d225751d7bc870a9cddb9a557de847f", size = 1113779, upload-time = "2026-10-05T23:24:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/f9/98/2eb4c79d1851195e6a083568b065235680ab984e984bbd472f2a7d02ba33/hypothesis-6.168.5-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b58097cc3b98d8616f635ac73888fc9f859311875f2adc043f1544c40c3c466", size = 1152639, upload-time = "2026-10-05T23:25:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9c/68f7e99b43c6f37c077669a4d3bd88f48c042444ced9e7cff0eaf44bc70a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f8a387d9ee7f804e830b31f2e2e339ab5731665e922cfda4f6f6fbdb05e191b4", size = 1290068, upload-time = "2026-10-05T23:25:28.45Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/d4f87164a0d028ab102cea345b601d9dafb3196358df5448caa88ac3c1e2/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:326f6383fdf2e37ac69773589a8238a3bf396ca8ac8efacb0fb9ed42dd08e426", size = 1418551, upload-time = "2026-10-05T23:24:51.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/6b518a25514f0e643f95610c77e279bfbf0e0b3bd423aac0187d6f039b9a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d33fc74e43bbd7c3a8f6f7161a8b93b676924286e97e70e828c6e0dcee5c01f", size = 1372130, upload-time = "2026-10-05T23:25:32.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/32538e14e63193ca894ba584696805d1eb45cfc27e15fccd47acfb87531c/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1994923cf5e5220ae6bf19645302504b27c0289d83e5d8690df71dcae63d8416", size = 1268950, upload-time = "2026-10-05T23:25:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/e50e7e98af9489aa05203c2ab38c95d891dd8d1ed08fad972dcdb6955332/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:501038fd24d3bc95239cfd093a23cf1151f29dd82382a3554dac5dfdab9729ae", size = 1283907, upload-time = "2026-10-05T23:24:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/41c460a7d2148a04b212b2d594d39992fb52e0b844e13bf6784573fc8dea/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2292ddc24fe6d04b7d30fa6a7e2c9e280ad5078fe671d0bf4aa6df6e143b5ac", size = 1323762, upload-time = "2026-10-05T23:24:18.984Z" }, + { url = "https://files.pythonhosted.org/packages/68/4f/37a7fc1fe445e3589e0f56ff4573c28de1d6e6a03009cba2f99f04e46ffa/hypothesis-6.168.5-cp310-abi3-win32.whl", hash = "sha256:925d67c69b719d416334aa961c0cdfc4a58a471af1ebd2d7101bd515a70f4e5f", size = 677903, upload-time = "2026-10-05T23:25:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/7b25ca7845a60522ebc5f8054f6bba68d47126fb5d940c784fc528a4be4a/hypothesis-6.168.5-cp310-abi3-win_amd64.whl", hash = "sha256:2311590eccba452de863dfe3466daa86a05c25f072ab31ed8bb4d3313ee68439", size = 684616, upload-time = "2026-10-05T23:25:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/40e7c36b46c8788eddc7a322ad324e6db53c8ab9a8b9a95d6535ee7bdaaf/hypothesis-6.168.5-cp310-abi3-win_arm64.whl", hash = "sha256:222a6d23a2a824b0f9f73761c2fb9cd2aca96cf3e5b441617625bce4f7eb4fd4", size = 683061, upload-time = "2026-10-05T23:25:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/f1149d913d685809c016b2a3ae9d727741ae22f52376c6d0ed51eecb5ac8/hypothesis-6.168.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8c35e5d4a85d0d6071cc267a6cbb8fd7ae23ca8a0f745ea5a52c0064d7c1c4b8", size = 792108, upload-time = "2026-10-05T23:25:12.323Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/7e5ffb6bbfc033c85746243dc4d1541876082e136ee44c02f843bb77427e/hypothesis-6.168.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:244a8d14c0a8a3be0345ad0b120deafb94517cc1d74a961d14b5b5eb041b4c0c", size = 788035, upload-time = "2026-10-05T23:26:26.557Z" }, + { url = "https://files.pythonhosted.org/packages/38/df/022129d3e16d19a84e7a5a35ebf7baca07d3482fb34f0faaab865b14fe66/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e68e1d43b7c9c7a1aa659dfe1c0ecc2de79391b20db853c1e18ea7e3d2ce31f", size = 1115438, upload-time = "2026-10-05T23:24:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/b3e45b0386d8f643a304105883c5bfce79fd530b2dfe3a70564e1d7aa0bd/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a4d3773f285e75551eeef12df058e6316b666bcc3ec187c5eb52a893fbb015", size = 1157503, upload-time = "2026-10-05T23:25:05.609Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4a/aba5a74ddb20c9f41ba5b8f2918c5a12660146cab2120f14122122715060/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc327005f2fbb55db81d132948ee7c6cec0589694bed04b1e45fc8fc317e12bd", size = 1290235, upload-time = "2026-10-05T23:25:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/34/f4/7204aa6117a38085e6f1dbefd5cd98050a58c847f2bdecc917422cdb2b1c/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f21c74ad83fe77abc72e82c54114148fb01396769c234e26c9b9dbc21344a9", size = 1324204, upload-time = "2026-10-05T23:26:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4b/15a46ced6d999148d1b718c5488c243bd56dfcd687a61404fe371192dfd5/hypothesis-6.168.5-cp311-cp311-win_amd64.whl", hash = "sha256:bd3ff6e53e29b86ec6078f123284e65e1c678fe7b30c2b52512244faf266502c", size = 684414, upload-time = "2026-10-05T23:26:18.231Z" }, + { url = "https://files.pythonhosted.org/packages/90/43/a04a727578cbef9f75c11fa6fbad66d13aaffc354f4f979506219814c7d4/hypothesis-6.168.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ddee1ef4bab47e315b705e42d2f4354e789973d11f9620d2df242aef4cfa42b2", size = 793156, upload-time = "2026-10-05T23:25:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/f4/91/55de4e2a12fe98ebd5bc8f35e59870c897ab360cbfe5aa63862cdbef56ad/hypothesis-6.168.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ceb49b0dc3a4b6126cd0d3bf2b634af4e91513c8f1e2daee16041414ed8e3d", size = 784788, upload-time = "2026-10-05T23:26:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f4/61/230abc6320540bdf73baf9a1c025fb0aa27cfd5a3791a2e0c95114239a70/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a09caa95d2d7e6546f727f703de606145835d9ca215fb3134a21353c69afaac", size = 1113853, upload-time = "2026-10-05T23:25:30.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4d/3bf0a7806b3fa12ed076f2daeb3db0e6f9738994e879432ffd8dbcffd634/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97ac1d516a42a3b1f13b36a1aa6a5f842e43d67e69d4dc664a9645b28de411ef", size = 1156695, upload-time = "2026-10-05T23:24:28.607Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a0/603f918fcf8f74f81ea593b04e3a9a9fcd426bbf389ed52cb340249bdc14/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4819fba78c6cbaa6e2f9fd5a69a413817446943f286763819b5ac52391bff3e", size = 1288792, upload-time = "2026-10-05T23:25:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/711ef5be6e889dcd40d9b03cdd85cd42ae39af75835bced3c374730291a9/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87334b95dfbc101652fa48a427a742b0715b814506d9a10f621c29e476b4a2c1", size = 1323090, upload-time = "2026-10-05T23:25:58.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/0377d7d13ff3e2c16efd141942649edcdb568caec4576f86ac779545dd85/hypothesis-6.168.5-cp312-cp312-win_amd64.whl", hash = "sha256:2fcec23ff4eb526ee85d3510f564b938ca74f6011f1eec1050e4eb55280b0468", size = 681999, upload-time = "2026-10-05T23:25:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b3/1f7f72cd28d02a5ca99c432fbffe4b750a375df2284af9d916943dd3aa4f/hypothesis-6.168.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:714337b25ca9137bc359c570b868269462307e120999412ca1946f997f4b9db5", size = 793075, upload-time = "2026-10-05T23:25:15.905Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ba/5b0874828695c4d49e3858d0967254f783e563cd0e211a6db27d11d48a1f/hypothesis-6.168.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f1c3617155fcf5b5259a1f2e4c775d3eec7bfa80b162b2f6f145b08f871ab08", size = 784693, upload-time = "2026-10-05T23:24:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/ca777becba5251b0d778bb9d83d15524c559a07e4b5d4e6211473855bae2/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebee70b7a026210bb47c86c89e5bfb42effd5bd630080e76bc084f29c01c7f7a", size = 1113693, upload-time = "2026-10-05T23:24:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/5a74bf329e405db3edc5639a2595eccf33ad6f5aaa191019e9f824d630f4/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cfb06b31cca005345b8ad63f88986d21fd359a7dc3dba2965dd3515b720e5c9", size = 1156530, upload-time = "2026-10-05T23:24:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/e79cf67f03f212a1394abac21053bd6887aa70f557be1da3f9c9c73e58ae/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a4c244d7ab64963fb575f0ec2d813630e1d14cefc39e7c460d5d778e5af4118", size = 1288713, upload-time = "2026-10-05T23:25:22.763Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/df452159ac8d7b278071a3e81fafc69da833ec4302b8c85f5b6e530aea21/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e59d519f6fb38b3fa4fcde046767b03a24740fe827d261ee7ff9a721c06169b", size = 1323134, upload-time = "2026-10-05T23:26:02.485Z" }, + { url = "https://files.pythonhosted.org/packages/af/fb/f07d8d09fb57eb14555cad64dfbe29bfdcecff3806f1e01268258088e741/hypothesis-6.168.5-cp313-cp313-win_amd64.whl", hash = "sha256:c103f655644afa4ef6bf7efbf86e44b78ee475fd0691da2db86e2cfe72c07234", size = 682019, upload-time = "2026-10-05T23:24:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/de/e9/7c3c2262b8cfa825c4c1764d62aa15e628bae257ccfd2ee4f3ffa4f81eaa/hypothesis-6.168.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c4dc037d8001bc6eccb8636f4a38d16ea6b250d6bf0a89075aaa5e5069f751cc", size = 793253, upload-time = "2026-10-05T23:26:14.331Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/7909ed7d29302491e9b7bc0e7ac3287c20736c05a0cc35bae65024aeec3b/hypothesis-6.168.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c90743321f29b65491d146adfc2ece85869bacb71ce18b47674795e896c81ee3", size = 784837, upload-time = "2026-10-05T23:25:24.728Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/57742c459349052e6a3e0c011855840f8cbbbadca91079d5b591f08b25ae/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09debb7f7f0f229da5f7e2ad515a5be7a8dc607ec204074775f8ab6731a447f0", size = 1114268, upload-time = "2026-10-05T23:24:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/07bd2449f91f9426f705fb689429bab6e26d1365f8ac4ef7d7c1cec9055e/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d227f8ac497eca0bde4e8562d32dd4e82fc9566526020bbd567f76b833b923b0", size = 1156838, upload-time = "2026-10-05T23:24:35.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/75/7f3dda517e5134f73e2ae41821bf40b3fd3ac6551a9a43ea9287471738a1/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc6ebd35601c72c842e5899c3f760f9ed26c69e786ee40a9a64fb5a4a3058315", size = 1288864, upload-time = "2026-10-05T23:24:42.645Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cd/4b1364140642cf3f1431ca59b5841fc322872dfa7197b2facb97692da234/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:503e103ad49e702bad200157d82778eebbc14d3045e9700a8e8fe5db40912953", size = 1323300, upload-time = "2026-10-05T23:24:14.826Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/47d7cffcaf15318a4308516b6b3d2fd0db599f18eacc0f2dc553be2206a7/hypothesis-6.168.5-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:bc5cc310f9f86ec62f0d0dd7eea5a4788f18ec793b70ee2c7163b916768e1057", size = 624239, upload-time = "2026-10-05T23:24:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/97/6e/2ca0f68150be175b7cfa7bfb6692260638d86aeb9313478ba82e198186e6/hypothesis-6.168.5-cp314-cp314-win_amd64.whl", hash = "sha256:71ce0599e806ce3a68f9f118edf450bf091e11b134f6bcc5f8dd706b42c91ebc", size = 681884, upload-time = "2026-10-05T23:25:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/4fc2b5970df0c27668ec08abc505f1d01314a69953f89dc0edc6528ff5a0/hypothesis-6.168.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f66b02c9e95e916a2c58f725a92377ec988146ed7b5aeccd5e78ceecac1eae6f", size = 791636, upload-time = "2026-10-05T23:26:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/03cdf5f052dcb441e045be1fd0aa531e85cde1a1cbabab60968625c570a3/hypothesis-6.168.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bab27926e1d1575fb43b70d4aeece05b74a5e477af0509b56cb6fd778070dd93", size = 783280, upload-time = "2026-10-05T23:26:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/615557af244e2f3ce4763029c03a62ed82dcbbd646b72a8c479ef0408b33/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edeb42c3009b5652dc1c44907ec91bfe9284100ad5e57993dfebabb76f2961a1", size = 1112149, upload-time = "2026-10-05T23:24:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/67/a6707fcd51dc5f2531bf88ac072e99f31ab9d8020488b01349a6d2981081/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8977456328147c521a16a089325017b2c728fddc23351693a4fd924cc7fc7001", size = 1155637, upload-time = "2026-10-05T23:24:24.047Z" }, + { url = "https://files.pythonhosted.org/packages/85/d4/ac2e852d2f163afd398854662bcbb0b849a767abbf2f95a75de6f685f821/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:36ecf7ac351f9c0b5489ba800884b607da754e88ef40713fbfcc170d2151e6eb", size = 1287040, upload-time = "2026-10-05T23:25:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/f77b1602704bda6ff3d9d0817120bd7fb94fd792bd25508b36ce4b8bd2a2/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0333aa5129ba3019a83fb81a7f0fc238180e415a9edddd9a15101f8deaaa517e", size = 1322166, upload-time = "2026-10-05T23:25:45.39Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2e/94138a73e0906b31cb5968d20be58688f582a09e5958f2c75d45a7049545/hypothesis-6.168.5-cp314-cp314t-win_amd64.whl", hash = "sha256:2fcb87341d76ae0183e8219c9a14d55957c50d14973879db5fea3e81da45ba1a", size = 681599, upload-time = "2026-10-05T23:24:37.827Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/92cb8092b680be2b6ec5ffe83b9f1a98dbf414117566f9e3ba4e8b569214/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:453ab7d0a1fadbaa54ae8722d22463cc2046fa8ef25b9b88715d28279bf79fc1", size = 790950, upload-time = "2026-10-05T23:24:45.852Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/78aea12694e3d1177e2980d44798b6d93e191faf59155b18bf5ae315f6a2/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:bbdbc43d1f9dad595b249b7bbe8ee5102bc94a4fcb0a79ff76d20e41fcfe342a", size = 782939, upload-time = "2026-10-05T23:25:39.961Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/5ef9947b2d149f773428e555bdf66688405aa5167510bdbe97c8ec5c6090/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bc36194d7b6083591060836c7872711a6820217b325bf432dd7e10b3d4af5cb", size = 1111380, upload-time = "2026-10-05T23:24:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/7e668e203fb2659c6214dc0c24cc09b7dea8a02c7c8d0ad338f644a054c4/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22425e2b1543a43c157a81472c713ba8f291cbaf054c70ffe128e2cacc294f65", size = 1142644, upload-time = "2026-10-05T23:26:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/c0/77/b112978676e795658d58c4294bf90cdbb8cb56cb8292c8c4874650468cf9/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eea0bc513d0e38d1d5ddfb581132928871cd02dc54dfe4511a5396727c48e9d0", size = 1135966, upload-time = "2026-10-05T23:24:49.806Z" }, + { url = "https://files.pythonhosted.org/packages/e6/27/cd3bf01e8246c4318ec3df15f5eeeee3214f444df0129a6c7f9a62859ee8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb142bc70bbf6645e15c7ca72de3f7c8dae198aa2743a609f4f3e3bb4f9c3a52", size = 1189646, upload-time = "2026-10-05T23:26:04.471Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/4d3e882f31c289e432dfec34dbb9029296038a8c69e8b28cebb0a5fb7ea8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27b758707bd37f5a1759cca6eef83fe1a212c38dc4ca0a203434004c5647d15", size = 1154770, upload-time = "2026-10-05T23:25:10.814Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/96f5455e1b3d0409cbbb1434c98e792bcceefd614a4b11e600072520b487/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:77a111cb50c330fa7098f65852fa17a01ecd781a85be3cf5e5871bdeeeb0ecbc", size = 1109925, upload-time = "2026-10-05T23:25:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/0758985d9d36f0c5ec981a1457ea1c8173f62d46a94531417aec117df4d7/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdd0afc13e86ec76cae3d3659569c1f601f4e9ca52b5cf91c1685979eae64d7b", size = 1148476, upload-time = "2026-10-05T23:24:54.552Z" }, + { url = "https://files.pythonhosted.org/packages/43/5c/a9b8953e1d8aefcd3c22cf8d10dd8acf93e602b903278e2e51cf8544ccea/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5fefb02035864c3d322e3b0969b296250923fdcfb574ea1ad4374f1a6333f663", size = 1285856, upload-time = "2026-10-05T23:25:38.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/66098444dc832523ddc4f2e05723662834e5f99bba3c759615d059f6420e/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:9db8aa1f5529e1b577ec18b775c2fb4225821712e946f7762b90c966604faf83", size = 1415500, upload-time = "2026-10-05T23:24:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/e971b6fe20ef8d7c2019cbf24b4f6149468efc88c42a744f5bc99e6ca0ed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:59e07d2f62b5ff573b0059959ae9cef9edfb0f5393fdb35ea81fce1ee77b27ac", size = 1368623, upload-time = "2026-10-05T23:26:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b279dfbd2c06cdb3030ba7eea042cb2cf0171d0013563d103d5207dde63b/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:8a03ca128bea29d6826fc545f1f6289fb1ea2e83a5bb811321761b2d515ca575", size = 1264457, upload-time = "2026-10-05T23:25:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/16ac9f8ddfada1cd278bd2185234d0d36ebd304926b69ad0497c210c6fed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:5c03f2d3f84f626f3fd07f54573ab40455e1a1996e98a4f4971caf8b7e796afe", size = 1281744, upload-time = "2026-10-05T23:24:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/99a44430b82998fdef0ffd7d353f64ee5f078c2805ff70f8677ee102cb6c/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2bdf8ce9b72a620cd5ec4dd6b1c1837ff6971489a863851d11d9b0f58dd4062a", size = 1321504, upload-time = "2026-10-05T23:26:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/58ef2564d1985a5c1a1dc57906b8363a767094abca180e80a0aca4cb635f/hypothesis-6.168.5-cp315-abi3.abi3t-win32.whl", hash = "sha256:5c3abbef7b17571fd713b0922407d9cd8cbc652254c0f462875f15199fcb29f7", size = 674959, upload-time = "2026-10-05T23:24:36.482Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/153414f55eb0c85bd9d891bd7811d746978c7ad3de81ea79eeb4e62e088b/hypothesis-6.168.5-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:38172199abab94a04bc017613e055faa796d7175fbc6221aac504d406c960b60", size = 681329, upload-time = "2026-10-05T23:26:08.897Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/117c82f08ab3ba1dcfbf6562ac43b8deb8efa8106646494fadd15122cc1b/hypothesis-6.168.5-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0600ddc24c32dab5ca8e780630ab6e2561df6d7f594f781d0608b38e04c4da91", size = 679632, upload-time = "2026-10-05T23:24:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/73/25/5c38b739fb778d4de48aab6509b9cf0afd0317bb0459741afdcd0ad44aed/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6786049db92275e0c5cfac7dfcda6d4bbc80bdf84cbc8c9c7171ca17f47b5aac", size = 792028, upload-time = "2026-10-05T23:24:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3f/91071d53240f5f13ab1dda286e3ddb33177537dbf55cede76e7f4a3856db/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ffbde24430dcd73231fd03324a934e0f638f7c0899fc566f3ef8c851534f8030", size = 787324, upload-time = "2026-10-05T23:24:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/10/ef/eb262e50d7741de6c49d27923e2c282d079273b8bcdacde33165ea39488d/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea967baaedfd532f1a521aaedafc66bb9de09795071492b0e7252139df38479f", size = 1115118, upload-time = "2026-10-05T23:24:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/87/67/a655a8666164aa896516f919af272fa3a3a00d2786be880c31bb638e79e2/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f98289a5da876c08b9eeb68d1cfdfbd0fcc110cf364d33c3cc32cf229ffe8", size = 1157894, upload-time = "2026-10-05T23:26:28.641Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/71c422a29446c03e9a052f10b8ee527044242e71e3c0139100991f721e16/hypothesis-6.168.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e313a01ce580180dc3bb8fa98ddd0ffb20e51e108d9fa747ba6c1596790dc3fa", size = 685030, upload-time = "2026-10-05T23:24:09.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +]