Skip to content

test(parity): derive cross-consumer parity from the role tables beside the hand-written suites #1700

Closed

Description

@dekobon

Objective

Derive a cross-consumer parity test from the role tables that runs beside the hand-written parity suites, and use it to make #1458's sibling sweeps a mechanical diff for the roles. Retire a hand-written row only after a perturbation shows the derived test discriminates the same defect.

Umbrella: R00: #1690 · Planning ID: R10: #1700
Completion prerequisites: R05: #1695, R06: #1696, R07: #1697, R08: #1698, R09: #1699

Why this exists, and what it cannot do

tests/parity/* are exhaustive matches on LANG over fixtures someone chose (#1281 made them exhaustive after Ruby, Lua, Perl and Go were found missing from families they can express). They catch two languages disagreeing on one construct the author thought of. slot_pays_tests.rs is better — cyclomatic as an oracle ABC did not write — but a construct absent from its template is unmeasured (#1535, #1536).

A derived test takes its inputs from the rows: for every claimed (language, role, kind) it locates occurrences in the corpus and asserts each consumer's verdict. What that adds is breadth across kinds nobody fixtured and a sweep that is a test run rather than an issue-body table. What it cannot do — and this package must say so in its module doc — is detect an omitted row, because inputs and expected verdicts come from the same declaration; nor cover a kind the corpus never emits. Hand-written fixtures with independently specified expected values, including negative contexts, stay for that (.claude/rules/testing.md, "Take the expected value from outside the fix").

Implementation steps

  • roles::parity test module (landed as roles/derived_parity_tests.rs + src/metrics/derived_parity_tests.rs; the per-space abc.conditions == cyclomatic() - 1 half was measured and left out — 36 fixture functions differ by Rule 5 design — see the retirement comment): per language table, per row, locate every occurrence of each claimed kind in that language's integration corpus (reuse the corpus walk) and assert owns_* / pays_* per occurrence; per space on the shared slot fixtures, assert abc.conditions == cyclomatic() - 1 where the language's policy expressions say they must agree, and the documented difference otherwise.
  • A census-driven variant of cyclomatic_cross_language_parity.rs (the role set is the family list of the R-layer relations and the F-layer forwards; an absent row prints its reason): the family list is the role set; a language declaring absent is skipped with its reason printed.
  • Report, per (language, role, kind), "no occurrence in corpus" (make role-coverage-claimed; the Rust census prints its own) as a named row in the census output so uncovered claims are visible, not silent.
  • Retire a slot_pays_tests.rs row only when (none retired — measured, recorded below) perturbing the production line it guards fails the derived test; record each retirement with its perturbation. Rows pinning a documented policy difference (else / ? Rule-5 rows) stay.
  • Retirement candidate from R07 (retired after both perturbations, recorded below) (comment): elixir_consumer_word_policy_agrees_with_the_control_flow_rows (src/metrics/elixir_spelling_parity_tests.rs) — retire only when perturbing the Elixir row gate and the consumer arm each fail the derived test; the derived locator must find Elixir occurrences by word, not kind, and read the with / try policy exceptions from a declared place.
  • Bring R07's 21 per-language control-flow fixtures and 5 Tcl/Elixir C5b fixtures into the tree as the corpus rows for the six control-flow roles, landing with the derived assertions that read them; recovery-shape fixtures keep their parity-not-contract wording. Split (2026-10-31):
    • (a) Preserve the fixtures — done by chore(utils): tier-1 homes for the role-series drift drivers and the R07 fixtures #1744 (branch fix/issue-1744, pending merge: tests/corpus/control_flow_test.rs over the submodule's control_flow/<lang>/, submodule c51ca7fa), which copies the 25 distinct files into tests/repositories/big-code-analysis-output/ with per-function snapshots captured at fdd12ad4. The cache holds 25, not 21 + 5: fixtures-c5b/r07.{ex,tcl,irule} are byte-identical to their fixtures-r07/ twins, and fixtures-0e/sw.{groovy,java} were omitted from the count. None is a recovery-shape fixture — every one dumps with zero ERROR / MISSING nodes at the pin. The "three recovery-shape fixtures (C# stranded catch, Perl when, Bash heredoc-in-compound)" named in the R07 comment below do not exist in the cache; if this package wants that coverage it writes them.
    • (b) Attach the derived parity assertions to those files — this package's, unchanged in scope.
  • Gate the derived tests on the union of the language features whose rows they read, with the non-vacuity assertion inside (.claude/rules/testing.md, "Gate a feature-gated fixture table on the union of its rows").

Tests and edge cases

  • Perturb a consumer or a projection (make Java's operator_field projection return None; make the ABC token arm skip its projection) while the row and the independent hand-written fixture stay fixed; the derived test and the hand-written one both fail. Dropping a row entry is not a valid perturbation here — it deletes the generated assertion with it, which is the stated limitation.
  • -p big-code-analysis --no-default-features --features go: the derived tests run their Go rows and assert non-zero; --no-default-features with no language: absent, not vacuous. Confirm the filtered-out counts differ across sets (fix(abc/objc): @available scores no condition #1457).

Acceptance criteria

  • A new role_table! with one row and no fixture produces at least one derived assertion per claimed kind on the corpus or a named "no occurrence" row.
  • Retirements listed with perturbation evidence; no hand-written row retired without it.
  • check-test-lang-gates and --compare clean.

Verification and completion evidence

  • Perturbation result; feature-isolation run counts; BCA_GATE: line (completion comment).

Activity

  1. added
    refactorCode maintainability / tech-debt cleanup (no behaviour change)
    on Oct 23, 2026
  2. dekobon commented on Oct 23, 2026

    @dekobon
    OwnerAuthor

    Tracking is ready

    The body now links the master issue and the work packages it references. Native parent and blocked-by relationships match the listed completion prerequisites. Implementation and acceptance checkboxes remain open; prerequisite closure and the R04: #1694 go decision govern execution order.

  3. dekobon commented on Oct 31, 2026

    @dekobon
    OwnerAuthor

    Inputs and a retirement candidate from R07 (#1697)

    Two things from R07: #1697's close-out belong to this package's derived parity test.

    1. Retirement candidate: the Elixir rows-vs-consumer test

    R07's Elixir cyclomatic and cognitive bodies dispatch once on the cached ElixirCallTarget word rather than reading the Branch / Switch / Loop rows (one text read per call instead of five; the rows and the consumer arms are therefore two spellings of one set). The alignment review called that the "two encodings" class the epic counts, and R07 closed it with a hand-written test:

    elixir_consumer_word_policy_agrees_with_the_control_flow_rows (src/metrics/elixir_spelling_parity_tests.rs:577) iterates ElixirCallTarget::ALL (made pub for it), scores def f(x) do\n <word> x do\n _ -> x\n end\nend against the same body under a plain foo, and asserts the container decides / opens a level exactly when a row owns the word — naming the two per-word policies the consumers carry (with pays no container decision, #1535; try opens no level, #416). It fails when While is dropped from either the row gate or the cyclomatic arm, and panics on a variant added to the enum without a spelling.

    This is exactly one hand-written instance of what this package derives per (language, role, kind). It is the retirement candidate under the rule above — retire it only when perturbing the row gate and perturbing the consumer arm each fail the derived test, with both perturbations recorded. Note the derived form has to reach the same thing the hand-written one does: Elixir's rows are text-gated, so the derived locator must find occurrences by word, not by kind (every control construct is Call), and the per-word policy exceptions have to be declared where the derived test can read them rather than re-typed in a second table.

    The two Tcl/Elixir read-budget tests (src/observation_tests.rs:313,351) are not candidates: they pin a cache invariant on the real walk, which no row-derived assertion expresses.

    2. Corpus rows: the R07 fixtures

    R07 wrote one control-flow fixture per language exercising every Branch / Switch / Loop / Catch / ElseIf / Exit spelling the grammar has, plus the dialect-specific shapes — 21 files (r07.{c,cpp,cs,ex,go,groovy,irule,java,js,kt,lua,m,php,pl,py,rb,rs,sh,tcl,ts,tsx}) and 5 C5b fixtures (c5b.{ex,tcl} for the keyword-section and switch-arm shapes; r07.{ex,irule,tcl} recovery variants). They were the fixture roots of the zero-drift oracle runs recorded on #1697 and today live only in the tier-2 working cache (.claude/rules/temp-files.md), which this package should not cite.

    • Bring them into the tree as this package's corpus rows for the control-flow roles (the "no occurrence in corpus" step above will otherwise report most dialect-specific shapes — a Groovy switch expression, a Perl when, a Tcl switch brace list — as unlocated, since the integration corpora do not contain them all). Each fixture pins nothing itself; the derived assertions are what give it a reason to exist, so they land together.
    • The three recovery-shape fixtures (C# stranded catch, Perl when, Bash heredoc-in-compound) pin parity between consumers on an error-recovery tree, not a language contract (grammar-dispatch §6); keep that wording on the derived rows that read them.

    Added by the R07 close-out; #1697's completion comment lists these as its deferred items.

  4. dekobon commented on Nov 1, 2026

    @dekobon
    OwnerAuthor

    Fixture step split in the body: (a) preservation is #1744's (25 distinct files — the three c5b/r07.* were byte-identical copies of the r07 set, and sw.{groovy,java} were uncounted — landing in the output submodule with snapshots captured at fdd12ad4); (b) the derived assertions stay here. Correction: none of the cached fixtures is a recovery shape (all parse clean at the pin), so the three recovery fixtures the R07 comment names must be written by this package if wanted.

  5. dekobon commented on Nov 1, 2026

    @dekobon
    OwnerAuthor

    Pointer update: #1744 landed the step this issue carries for it on branch fix/issue-1744 (pending merge; commits 2783e686 and 603e902e, output submodule c51ca7fa). The checklist line above now says so; ir_check.sh and snapdiff.py are recorded there as one-off and not landed.

  6. dekobon commented on Nov 1, 2026

    @dekobon
    OwnerAuthor

    Follow-up noted by the whole-branch review of fix/batch-2026-10-31 (which landed #1744's fixtures): check-role-coverage.py samples only CORPORA = (DeepSpeech, pdf.js, serde), so make role-coverage still reports 15 languages as not sampled by the corpus — and the branch just preserved one hand-written control-flow file per language under big-code-analysis-output/control_flow// that would sample every one of them. drift-oracle.py's corpus_skip likewise skips the submodule unless it is passed as a --root. Adding the submodule to CORPORA changes .role-coverage-observed.txt provenance and deserves its own review; it fits this package's "report no occurrence in corpus as a named row" step. Deliberately not done on the batch.

  7. dekobon commented on Nov 1, 2026

    @dekobon
    OwnerAuthor

    From R09 (#1699): rows and fixtures the derived parity can read

    R09 landed the seven documentation rows in every language and three cross-language pins in big-code-analysis-ast/src/roles/doc_tests/parity.rs that this issue's derived tests should subsume or sit beside:

    • doc_targets_outside_the_space_tree_are_each_named — declared-list parity owns_DocTarget ⊆ owns_FuncOpener ∪ owns_Container ∪ owns_Member per language, every exception named in a test-side (language, kind, reason) table, both directions (an unnamed target fails; a stale name fails). The C-family entries resolve names per dialect with a dialect_dependent escape for kinds the space tree owns only in C++.
    • doc_targets_the_space_tree_rejects_by_gate_are_each_named — the same on nodes with the real chain and gates applied, with a non-vacuity target count per fixture; C#'s bodied property / indexer are the named gated-out shapes.
    • doc_comment_implies_comment_and_docstring_implies_neither — is_doc_comment ⇒ is_comment as the Checker answers it per language, and Python's Docstring owners reaching neither predicate.

    When the census-driven variant lands, these are the hand-written rows to retire by the perturbation rule in this issue's steps. The 26 per-language doc fixtures (one file per language spelling every row owner and both sides of every gate, plus six Doxygen fixtures: entries per dialect, an Objective-C header read as C / C++ / Mozcpp, error recovery, a sole conditional child of an error) are the corpus rows for the seven roles, as R07's control-flow fixtures are for its six; they live in the per-language rows_answer_the_table tests in doc_tests/mod.rs today.

  8. dekobon commented on Nov 3, 2026

    @dekobon
    OwnerAuthor

    Two scope decisions before implementation (R10 plan, revision 2)

    Recorded here so neither is slipped in. Both deviate from the literal text of the steps above; the reason for each is below.

    1. slot_pays_tests.rs: expect zero retirements. The derived form that can read the corpus is a per-function-space abc.conditions == cyclomatic() - 1 + delta over control_flow/<lang>/* with a test-side delta table naming each Rule-5 / outer-negation entry. A difference check passes when both metrics regress by the same amount; the hand-written rows assert exact (conditions, decisions) pairs on minimal templates, which catch that. The derived assertion therefore adds corpus breadth beside the rows rather than replacing any. Each attempted retirement is still recorded with its perturbation and result, so the step is satisfied by evidence, not deletions.

    2. The three integration corpora (DeepSpeech / pdf.js / serde) are opt-in, pending a cost measurement. The default derived test walks control_flow/ plus the inline R09 doc fixtures — every one of the 25 production tables, deterministic, clean-parse. The full-corpus walk is a separate #[ignore] test wired to a new make role-parity-corpus recipe (which role-coverage-observe will depend on), because make role-coverage-observe runs no Rust test today and an ignored test with no recipe runs nowhere. If the measured default walk stays under ~5 s under nextest, the corpora are promoted into it and the recipe is dropped.

    Also corrected from the earlier framing: the Elixir candidate is not deferred to #1701. Cyclomatic::compute is a per-node trait reachable from a root-crate test, so the derived form runs the metric arm directly; the two mandated perturbations decide retirement.

    Candidate selection in the derived census is by the declared kind lists (OWNERS ∪ PAYERS ∪ LEAF_KINDS), never by a gated verdict, so gate negatives are recorded rather than discarded; Elixir occurrences are keyed by ElixirCallTarget word.

  9. 4 remaining items

  10. dekobon commented on Nov 4, 2026

    @dekobon
    OwnerAuthor

    Landed on main at d96b6fca via PR #1790 (four signed commits, fast-forwarded).

    The last CI round turned up one more thing worth recording: the features (*) and chain audit jobs run the lib tests with submodules: false, and the derived-parity tests read control_flow/ from the output submodule. The whole-corpus obligations (a positive per owner kind / per relation, the stale direction of each exception table, Elixir's per-word coverage, the root rows_own! premise) are now corpus_-prefixed tests that assert the submodule is present; those legs filter the prefix by name (not test(/::corpus_/) / --skip ::corpus_) so the skip shows in the test count rather than as a pass. The structural assertions and the contradiction direction of every exception table still run on the documentation fixtures alone on every leg.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorCode maintainability / tech-debt cleanup (no behaviour change)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions