From 981dbbc033d78dc09257f06d3e8af7a20881104c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 02:33:49 +0000 Subject: [PATCH 01/84] docs(identity): freeze the lineage decision policy, rules first Own.NET#266 slice 2, step 1, in progress. Step 0 froze WHAT a mapping may conclude; this freezes WHICH EVIDENCE LICENSES WHICH conclusion, and what happens when two answers apply at once. Still no mapper. Five rules: three for `continued` (same site, drift, rename), one for `branched`, one for `merged`. None for `ended` or `new` - those are earned by boundary evidence, which the outcome contract already governs, and a second route to a death or a birth is the exact defect that contract was corrected for. NO CONFIDENCE SCORE, as a decision rather than an omission. A scalar collapses WHICH signals fired into a number, which finding-lineage/v1 already forbids for evidence: two different evidence sets scoring 0.8 become indistinguishable, and "would this mapping still be made under the new rule" stops having an answer - the promise mapping_provenance exists to keep. A mapping cites the RULE by id in `licensed_by`, so retiring a rule has a mechanical consequence: every mapping citing it can be found. A threshold moving from 0.7 to 0.75 leaves no such trail. Precedence is mechanical rather than positional. Rules apply as a SET, not a sequence, and conflicts resolve by a declared pairwise relation - no global ranking, no priority integers, no tie-break by name, all of which decide conflicts nobody has looked at. An if/elif chain would make the answer depend on the order someone typed the branches in, and that order is invisible in the output. Two defects in this file were found before any fixture was written, both by checking it against what is already frozen rather than by reading it again. 1. `dominance` was declared empty "on purpose". That contradicted step 0's `copy-branches-without-a-winner`, where the original site continues AND a copy exists - so R-CONT-SAME-SITE and R-BRANCH-COPY both apply and disagree. That fixture forbids "continued to exactly one of them", so the conflict was already decided by the outcome contract and the policy had to implement it, not leave it open. The structural record that EXPLAINS multiplicity beats a 1:1 rule that reached its answer without looking at the other successor. 2. A completeness requirement was added: every pair of rules that can be satisfied together and disagrees must be in `dominance` or in `deliberately_unresolved_conflicts`. It found a seventh pair on its first run - R-BRANCH-COPY vs R-MERGE-FOLD, an occurrence that is both a copy source and a fold participant. That is N:M, and none of the six outcomes is N:M. Neither rule dominates: each holds a structural record the other does not consult and both are true, so picking either drops something real. It is recorded as deliberately unresolved. That check is a proof over the rule set rather than an example: a fixture shows one conflict is handled, this says none is unhandled. Also fixed here, as policy rather than prose: a defeated signal is REMOVED before rules apply, never weighed - defeaters win by acting earlier on a different object. And an input that could not be EVALUATED is distinct from one evaluated and absent; rules depending on it do not apply, which is the absence-of-record doctrine at a third layer. With no diff, `path_rename` cannot be evaluated, so a file that really did move comes out `unresolved` naming the missing input rather than `ended` plus `new`. Still to come in step 1: six adversarial fixtures, the integrity suite carrying the completeness check, the doc, and CI wiring. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 263 +++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 contracts/finding-lineage-decision-v1.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json new file mode 100644 index 0000000..a34feb6 --- /dev/null +++ b/contracts/finding-lineage-decision-v1.json @@ -0,0 +1,263 @@ +{ + "contract": "finding-lineage-decision/v1", + "status": "frozen-unimplemented", + "builds_on": "finding-lineage/v1", + "notes": [ + "Own.NET#266 slice 2, step 1. finding-lineage/v1 froze WHAT a mapping may", + "conclude and what it must show. This freezes WHICH EVIDENCE LICENSES WHICH", + "conclusion - and, just as load-bearing, what happens when two answers apply", + "at once. Still no mapper: this is the decision policy, not the code.", + "Everything here is subordinate to the outcome contract. Where they appear to", + "disagree, finding-lineage/v1 wins and this file is the thing that is wrong." + ], + "why_rule_ids_and_not_a_confidence_score": [ + "A scalar confidence collapses WHICH signals fired into a number, and the", + "outcome contract already forbids exactly that for evidence. Two different", + "evidence sets scoring 0.8 become indistinguishable in the record, and the", + "question 'would this mapping still be made under the new rule' stops having", + "an answer - which is the promise mapping_provenance exists to keep.", + "So a mapping cites the RULE that licensed it, by id, in `licensed_by`.", + "Changing a rule then has a mechanical consequence: every mapping citing it", + "can be found and re-judged. A threshold moving from 0.7 to 0.75 leaves no", + "such trail.", + "This is a deliberate NO to a confidence class, not an omission. If one is", + "ever wanted, it must be derived FROM the cited rules and stored beside them,", + "never instead of them." + ], + "decision_procedure": [ + "1. COLLECT the signals that were actually evaluated. A signal that could not", + " be evaluated is not absent - see `unavailable_inputs`.", + "2. DEFEAT: remove every signal an explaining record accounts for. A defeated", + " signal is REMOVED, never weighed. finding-lineage/v1 owns the defeaters", + " for boundary evidence; `signal_defeaters` here owns the rest.", + "3. APPLY every rule to the surviving set. All of them, not the first match.", + "4. ARBITRATE by `arbitration`, below.", + "5. RECORD the outcome, `licensed_by` (every rule that applied, not just the", + " winner), the evidence, and the provenance." + ], + "order_independence": [ + "Step 3 evaluates a SET, not a sequence, and step 4 resolves by a declared", + "relation rather than by position. This is the whole reason the procedure is", + "written this way: an `if/elif` chain makes the answer depend on the order", + "someone happened to type the branches in, and that order is invisible in the", + "output. Two mappers implementing this contract must agree, and they cannot", + "agree about something neither of them records." + ], + "rules": { + "R-CONT-SAME-SITE": { + "outcome": "continued", + "requires_all": [ + "same_path", + "structural_context", + "anchored_content" + ], + "means": "the occurrence is where it was, in the symbol it was in, saying what it said" + }, + "R-CONT-DRIFT": { + "outcome": "continued", + "requires_all": [ + "same_path", + "structural_context", + "line_drift" + ], + "means": "same file and same enclosing symbol, moved by an edit elsewhere", + "note": "content is NOT required: a defect may be edited without ceasing to be the same defect" + }, + "R-CONT-RENAME": { + "outcome": "continued", + "requires_all": [ + "path_rename", + "structural_context", + "anchored_content" + ], + "means": "the file moved and the occurrence moved with it, unchanged inside", + "note": "path_rename alone can never do this. A rename relates FILES; the other two are what relate the occurrence." + }, + "R-BRANCH-COPY": { + "outcome": "branched", + "requires_all": [ + "copy_record" + ], + "requires_per_successor": "a rule of outcome `continued`", + "means": "one predecessor, several successors, and a RECORD explaining why there are several", + "note": "the copy record is what separates a branch from an ambiguity. Without it, several equally-supported candidates are `unresolved` - see `arbitration.multiplicity`." + }, + "R-MERGE-FOLD": { + "outcome": "merged", + "requires_all": [ + "merge_record" + ], + "requires_per_predecessor": "a rule of outcome `continued`", + "means": "several predecessors, one successor, and a RECORD explaining the fold" + } + }, + "rules_note": [ + "This set is deliberately SMALL and deliberately not exhaustive. Every rule", + "here names at least two evidence kinds, because finding-lineage/v1 sets that", + "floor and this file may not sit under it.", + "There is no rule licensing `ended` or `new`: those are earned by boundary", + "evidence, which the outcome contract already governs. Adding a rule for them", + "here would create a second, competing route to a death or a birth - the", + "exact defect that contract was corrected for." + ], + "signal_defeaters": { + "structural_context": { + "defeated_by": "revision_b.renamed_symbols[].from", + "why": "an enclosing symbol that was RENAMED still matches by name only by accident; the rename record says the site moved, and a name match across a rename is coincidence rather than continuity" + }, + "anchored_content": { + "defeated_by": "revision_b.reformatted_paths", + "why": "content equality across a reformatting pass is a property of the formatter, not of the occurrence - it neither confirms nor denies that this is the same defect" + } + }, + "defeater_rule": [ + "A defeated signal is REMOVED from the set before rules are applied. It is not", + "given a lower weight, because weight is the thing this contract refuses to", + "have. A rule that needed it simply stops applying, and the record says which", + "signal was defeated and by which record.", + "Defeaters therefore always beat positive signals - not because they are", + "'stronger', but because they act EARLIER and on a different object: they", + "remove an input rather than argue with a conclusion." + ], + "unavailable_inputs": { + "meaning": "a signal that could not be EVALUATED at all: no symbol index, no diff, no content for the revision", + "distinct_from": "a signal that was evaluated and did not hold", + "effect": "rules depending on it do not apply; they do not fail, and they certainly do not count against the mapping", + "recorded_as": "inputs_unavailable, listing the signal names", + "why": [ + "This is the absence-of-record doctrine at a third layer. The runtime witness", + "refuses to read an unread heap as a clean one; the occurrence contract", + "refuses an ambiguous anchor rather than guessing; here, a diff that was not", + "available must not read as 'the file did not move'.", + "Concretely: with no diff, `path_rename` cannot be evaluated, so R-CONT-RENAME", + "cannot apply - and an occurrence that really did move with a renamed file", + "comes out `unresolved`, naming the missing input, instead of `ended` plus", + "`new`. Slower, and true." + ] + }, + "arbitration": { + "none_applies": { + "outcome": "unresolved", + "reason": "lineage-id-unavailable:no-mapping-evidence" + }, + "all_agree": { + "outcome": "the shared outcome", + "licensed_by": "EVERY applicable rule, not the first or the strongest", + "why": "the record has to survive one of those rules being retired later" + }, + "conflict": { + "outcome": "unresolved", + "reason": "lineage-id-unavailable:conflicting-rules", + "unless": "exactly one applicable rule DOMINATES every other applicable rule, per `dominance`", + "why": "two rules reaching different conclusions from the same evidence is a defect in this contract, and a mapper is the wrong place to paper over it. Refusing makes it visible; picking one hides it behind whichever branch was typed first." + }, + "multiplicity": { + "rule": "a 1:1 outcome requires exactly ONE candidate satisfying the licensing rule", + "several_candidates_with_a_structural_record": "branched or merged, per R-BRANCH-COPY / R-MERGE-FOLD", + "several_candidates_without_one": { + "outcome": "unresolved", + "reason": "lineage-id-unavailable:ambiguous-candidates" + }, + "why": [ + "This is what makes twins work without any rule mentioning twins. Two", + "occurrences of one pattern in one file agree on path, pattern and content;", + "only structural_context tells them apart. A rule that does not include it", + "is satisfied by BOTH candidates, and uniqueness turns that into a refusal", + "instead of into whichever candidate was enumerated first.", + "Uniqueness is checked per RULE, not per outcome: a discriminating rule may", + "single out a candidate while a blunter one does not." + ] + } + }, + "dominance": { + "declared_pairs": [ + { + "winner": "R-BRANCH-COPY", + "loses": [ + "R-CONT-SAME-SITE", + "R-CONT-DRIFT", + "R-CONT-RENAME" + ], + "why": "a copy record EXPLAINS the multiplicity; a 1:1 rule reached its answer by not looking at the other successor. The more informed reading wins, and the losers are still recorded in licensed_by so the disagreement stays visible." + }, + { + "winner": "R-MERGE-FOLD", + "loses": [ + "R-CONT-SAME-SITE", + "R-CONT-DRIFT", + "R-CONT-RENAME" + ], + "why": "the mirror case: a merge record explains why several predecessors reach one successor, and continuing just one of them would report the others as fixed." + } + ], + "rule": [ + "These two pairs are NOT decoration. finding-lineage/v1 preregisters", + "`copy-branches-without-a-winner`, where the original site continues AND a copy", + "exists: R-CONT-SAME-SITE and R-BRANCH-COPY both apply and disagree. That", + "fixture forbids 'continued to exactly one of them', so the outcome contract", + "has already decided this conflict - and a decision policy that left it", + "unresolved would contradict a frozen case rather than implement it.", + "An earlier draft of this file declared dominance empty 'on purpose'. It was", + "checked against the step 0 matrix and was simply wrong: the conflict is", + "constructible, and the answer was already frozen.", + "Dominance is declared PAIRWISE and explicitly. There is no global ranking,", + "no rule priority number, and no tie-break by name - all three would decide", + "conflicts nobody has looked at yet." + ] + }, + "record_additions": { + "licensed_by": "the ids of every rule that applied. Required for continued, branched and merged.", + "signals_defeated": "signal name -> the record that defeated it. Present whenever step 2 removed anything.", + "inputs_unavailable": "signal names that could not be evaluated. Present whenever any were." + }, + "new_limitations": { + "conflicting-rules": "lineage-id-unavailable:conflicting-rules", + "input-unavailable": "lineage-id-unavailable:input-unavailable" + }, + "preregistered_cases": [ + "copy-record-dominates-the-single-match", + "merge-record-dominates-the-single-match", + "defeated-signal-drops-below-the-floor", + "renamed-symbol-defeats-structural-context", + "unavailable-diff-is-not-a-deletion", + "blunt-rule-loses-to-uniqueness" + ], + "preregistration_rule": [ + "Fixed before the mapper, same as slice 2 step 0. These six are about the", + "POLICY rather than the vocabulary: each is a shape where two answers are", + "available and the contract has to say which, or say neither." + ], + "conflict_completeness_rule": [ + "Every pair of rules that (a) can be satisfied by one evidence set and (b) names", + "a different outcome MUST either appear in `dominance.declared_pairs` or be", + "listed in `deliberately_unresolved_conflicts`. The integrity suite computes the", + "pairs from `requires_all` and fails on any that is in neither list.", + "This is a proof over the RULE SET rather than an example: a fixture can only", + "show that one conflict is handled, while this says none is unhandled. It is", + "also what caught the empty-dominance error, and it will catch the next rule", + "added without a thought about what it argues with." + ], + "deliberately_unresolved_conflicts": [ + { + "between": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ], + "outcome": "unresolved", + "reason": "lineage-id-unavailable:conflicting-rules", + "why": [ + "One occurrence that is both a copy source and a participant in a fold is an", + "N:M shape, and finding-lineage/v1 has six outcomes among which none is N:M.", + "Neither rule dominates, because neither is more informed: each holds a", + "structural record the other does not consult, and both records are true.", + "Picking `branched` would drop the predecessors the fold names; picking", + "`merged` would drop the successors the copy names. Refusing loses nothing", + "except the pretence, and the record names both rules in licensed_by so the", + "shape is visible to whoever decides whether v2 needs a seventh outcome.", + "This pair was not noticed while the rules were being written. The", + "completeness check found it on its first run, which is the argument for", + "having a proof over the rule set rather than only fixtures." + ] + } + ] +} From 891a7471be6334ba57a05690164a3d385a1514c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 12:56:22 +0000 Subject: [PATCH 02/84] docs(identity): correct four things the policy claimed but did not hold Review of 981dbbc, all four confirmed. Kept as a separate commit on top: the first freeze attempt and its falsification are both worth being able to read. 1. `licensed_by` asserted something false. The draft recorded every applicable rule there, so a `branched` mapping claimed R-CONT-SAME-SITE licensed `branched` - it did not, it proposed `continued` and lost arbitration. Split into `applicable_rules` (everything that fired, winners and losers, which is the audit trail a rule retirement needs) and `licensed_by` (what the outcome actually rests on). On a refused conflict `licensed_by` is EMPTY: nothing licensed the refusal, the refusal is what happens when nothing can. 2. The decision layer was inventing values in the senior contract's frozen vocabulary. It declared `conflicting-rules` and `input-unavailable` as new `unresolved.reason` values while also declaring itself subordinate to finding-lineage/v1, whose suite builds the allowed set from its OWN limitations. Both claims cannot hold, and teaching the junior test to accept the union would have been a test granting a contract what the senior contract withheld. Resolved by reading the senior contract instead of extending it: `unresolved` means "the evidence does not decide", so a rule conflict IS ambiguous-candidates - one relation with two incompatible readings rather than two candidate occurrences, and that distinction is precision, not outcome. An unevaluable input leaves nothing to license a link: no-mapping-evidence. The precision moves to `decision_detail`, a junior field where a junior contract may speak. If something is ever genuinely unsayable in the senior vocabulary, the honest move is amending it in the open, not widening it from below. 3. Pairwise completeness was too weak, exactly as review said. A > B > C > A covers every pair and decides nothing. The invariant is now set-level: every jointly satisfiable non-empty SET yields one defined result independent of enumeration order. Five rules means 31 subsets, checked exhaustively rather than argued. Two probes show the checks are not redundant and neither subsumes the other. A three-cycle IS caught by acyclicity but NOT by order-independence: with a cycle every rule is dominated, nothing survives, and the answer is stably `unresolved` - defined-looking and wrong. Conversely, with P>Q and Q>S declared but P vs S not, the subset {P,Q,S} resolves to `continued` because P survives - a defined answer resting on a comparison nobody declared. Only pairwise completeness catches that one. 4. `copy_record` and `merge_record` were bare atoms, named in rules and observable from nowhere stated - the very gap step 0 closed for boundary evidence when it required `observable_from` and `match`. They now have a catalog pointing at REAL step 0 fixture fields (`revision_b.copies`, `revision_b.merged_symbols`), and `signal_defeaters` is validated against it rather than against strings agreed by convention. The two defeater sources are declared as not yet exercised by any step 0 fixture, which is a fact worth stating rather than leaving to be found. Verified now, to be moved into the suite next: dominance acyclic, declared pairs name existing rules with different outcomes, all 31 subsets defined, 0 mismatches over shuffled orders, and all 8 subsets containing both R-BRANCH-COPY and R-MERGE-FOLD still unresolved. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 152 +++++++++++++++++++-- 1 file changed, 139 insertions(+), 13 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index a34feb6..a6646e8 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -28,12 +28,11 @@ "1. COLLECT the signals that were actually evaluated. A signal that could not", " be evaluated is not absent - see `unavailable_inputs`.", "2. DEFEAT: remove every signal an explaining record accounts for. A defeated", - " signal is REMOVED, never weighed. finding-lineage/v1 owns the defeaters", - " for boundary evidence; `signal_defeaters` here owns the rest.", + " signal is REMOVED, never weighed.", "3. APPLY every rule to the surviving set. All of them, not the first match.", - "4. ARBITRATE by `arbitration`, below.", - "5. RECORD the outcome, `licensed_by` (every rule that applied, not just the", - " winner), the evidence, and the provenance." + " The result is `applicable_rules`.", + "4. ARBITRATE by `arbitration`. The result is the outcome and `licensed_by`.", + "5. RECORD both lists, the evidence, `decision_detail`, and the provenance." ], "order_independence": [ "Step 3 evaluates a SET, not a sequence, and step 4 resolves by a declared", @@ -102,11 +101,11 @@ ], "signal_defeaters": { "structural_context": { - "defeated_by": "revision_b.renamed_symbols[].from", - "why": "an enclosing symbol that was RENAMED still matches by name only by accident; the rename record says the site moved, and a name match across a rename is coincidence rather than continuity" + "defeated_by_signal": "renamed_symbol_record", + "why": "an enclosing symbol that was RENAMED matches by name only by accident; the rename record says the site moved, so a name match across it is coincidence rather than continuity" }, "anchored_content": { - "defeated_by": "revision_b.reformatted_paths", + "defeated_by_signal": "reformatted_paths", "why": "content equality across a reformatting pass is a property of the formatter, not of the occurrence - it neither confirms nor denies that this is the same defect" } }, @@ -206,14 +205,12 @@ ] }, "record_additions": { - "licensed_by": "the ids of every rule that applied. Required for continued, branched and merged.", + "applicable_rules": "every rule that fired after defeaters, winners and losers alike. Required whenever any fired.", + "licensed_by": "the rule(s) the outcome rests on. Required for continued, branched and merged; EMPTY on a refused conflict.", + "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: conflicting_rules, inputs_unavailable, defeated_signals.", "signals_defeated": "signal name -> the record that defeated it. Present whenever step 2 removed anything.", "inputs_unavailable": "signal names that could not be evaluated. Present whenever any were." }, - "new_limitations": { - "conflicting-rules": "lineage-id-unavailable:conflicting-rules", - "input-unavailable": "lineage-id-unavailable:input-unavailable" - }, "preregistered_cases": [ "copy-record-dominates-the-single-match", "merge-record-dominates-the-single-match", @@ -259,5 +256,134 @@ "having a proof over the rule set rather than only fixtures." ] } + ], + "applicable_rules_vs_licensed_by": [ + "Two lists, because they answer two different questions, and an earlier draft", + "of this file conflated them into a claim that was simply false.", + "`applicable_rules` - every rule that fired on the surviving evidence set,", + " including the ones that lost arbitration. This is the audit trail: retiring", + " a rule later means finding every mapping it touched, and the losers are", + " exactly the mappings that would change if the winner were retired.", + "`licensed_by` - the rule or rules the OUTCOME actually rests on. Nothing else.", + "The draft put both into `licensed_by`, so a branched mapping asserted that", + "R-CONT-SAME-SITE licensed `branched`. It did not: it proposed `continued` and", + "lost. A record that says otherwise is wrong in the same way a fabricated", + "successor is wrong - it attributes a conclusion to something that never", + "reached it.", + "On a refused conflict `licensed_by` is EMPTY, and the disagreeing rules appear", + "in `applicable_rules` and in `decision_detail.conflicting_rules`. Nothing", + "licensed the refusal; the refusal is what happens when nothing can." + ], + "reason_mapping": { + "no_rule_applied": { + "reason": "lineage-id-unavailable:no-mapping-evidence", + "detail": "inputs_unavailable, when the reason nothing applied is that a signal could not be evaluated" + }, + "conflicting_rules": { + "reason": "lineage-id-unavailable:ambiguous-candidates", + "detail": "conflicting_rules: the ids that disagreed" + }, + "several_candidates": { + "reason": "lineage-id-unavailable:ambiguous-candidates", + "detail": "the candidate ids" + } + }, + "reason_mapping_rule": [ + "THE DECISION LAYER INVENTS NO REASON VALUES. Every `unresolved.reason` it can", + "emit is drawn from finding-lineage/v1's `limitations`, and the integrity suite", + "asserts that against the SENIOR contract rather than against a union of both.", + "A draft of this file declared two new values, `conflicting-rules` and", + "`input-unavailable`, while also declaring itself subordinate to a contract", + "whose vocabulary is frozen and whose own suite builds the allowed set from its", + "own `limitations`. Those two claims cannot both hold. Teaching the junior test", + "to accept the union would have been a test granting a contract what the senior", + "contract withheld.", + "The resolution is that both situations are already sayable. `unresolved` means", + "'the evidence does not decide', so a rule conflict IS ambiguous-candidates -", + "one relation with two incompatible readings rather than two candidate", + "occurrences, and the distinction between those is precision, not outcome.", + "An input that could not be evaluated leaves nothing to license a link, which", + "is no-mapping-evidence.", + "The precision is not lost: it moves to `decision_detail`, a junior field, where", + "a junior contract is entitled to speak. If a future situation genuinely cannot", + "be said in the senior vocabulary, the honest move is to amend the senior", + "contract in the open - not to widen it from below." + ], + "arbitration_invariant": [ + "PAIRWISE COMPLETENESS IS NOT ENOUGH. A > B, B > C, C > A covers every pair and", + "decides nothing; a mapper implementing only the pairwise law would then resolve", + "by enumeration order, which is the defect this whole section exists to remove.", + "The invariant is set-level:", + " For every jointly satisfiable non-empty SET of applicable rules, arbitration", + " yields exactly one defined result, independent of enumeration order.", + "With five rules that is 31 subsets, so the suite checks them exhaustively", + "rather than by argument. It also checks the dominance graph is ACYCLIC, that", + "every declared pair names existing rules with different outcomes, and that", + "permuting the rule order changes nothing.", + "Any subset containing both R-BRANCH-COPY and R-MERGE-FOLD stays unresolved", + "until some rule explicitly represents N:M - adding one is a visible act, and", + "silently letting a superset resolve would be how N:M starts being reported as", + "whichever half was checked first." + ], + "structural_signals": { + "copy_record": { + "observable_from": "revision_b.copies", + "entry_shape": { + "from": "path", + "to": "path", + "similarity": "int" + }, + "matches": { + "from": "predecessor.path", + "to": "successor.path" + }, + "why": "the record that explains why one predecessor has several successors" + }, + "merge_record": { + "observable_from": "revision_b.merged_symbols", + "entry_shape": { + "from": "list of enclosing symbols", + "to": "enclosing symbol" + }, + "matches": { + "from": "predecessor.enclosing_symbol", + "to": "successor.enclosing_symbol" + }, + "why": "the record that explains why several predecessors reach one successor" + }, + "renamed_symbol_record": { + "observable_from": "revision_b.renamed_symbols", + "entry_shape": { + "from": "enclosing symbol", + "to": "enclosing symbol" + }, + "matches": { + "from": "predecessor.enclosing_symbol" + }, + "why": "defeats structural_context: a name that matches across a rename matches by accident", + "note": "not exercised by any finding-lineage/v1 fixture; the step 1 fixtures introduce it" + }, + "reformatted_paths": { + "observable_from": "revision_b.reformatted_paths", + "entry_shape": "path", + "matches": { + "path": "predecessor.path" + }, + "why": "defeats anchored_content: equality across a formatter pass is a property of the formatter", + "note": "not exercised by any finding-lineage/v1 fixture; the step 1 fixtures introduce it" + } + }, + "structural_signals_rule": [ + "`copy_record` and `merge_record` were bare atoms in the first draft - named in", + "rules, observable from nowhere stated. That is exactly the gap step 0 closed", + "for boundary evidence when it required `observable_from` and `match`, on the", + "grounds that evidence living only in prose is not evidence.", + "`observable_from` here names REAL fields of the step 0 fixtures: `copies` and", + "`merged_symbols` are the ones copy-branches-without-a-winner and", + "duplicate-sites-merge-into-one already carry. The two defeater sources are", + "declared as not yet exercised, which is a fact worth stating rather than", + "leaving to be discovered.", + "`signal_defeaters` is validated against this catalog, so a defeater cannot", + "name a source that does not exist." ] } From 9e2862c279fb8a8ed7a736ed10af6a5e4244a570 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:09:28 +0000 Subject: [PATCH 03/84] docs(identity): give the outcome contract a word for evidence that contradicts itself finding-lineage/v1 gains `conflicting-evidence`. Step 1 needed to report that one observed structure supports two incompatible conclusions - a copy record and a merge record touching one occurrence, which is N:M and therefore none of the six outcomes - and the contract had no way to say it. Step 1's first attempt reported it as `ambiguous-candidates`. That is a different thing, and the difference is worth keeping sharp: ambiguous-candidates SEVERAL possible partners, nothing prefers one. A choice with no grounds. two-candidates-unresolved. conflicting-evidence ONE observed structure, incompatible readings of the same relation. Grounds that argue with each other. Collapsing them would have made the junior contract's precision the only place the distinction survived, in a field the senior vocabulary does not govern. This is the senior contract being amended in the open, which is what the junior one's own rule requires - and that rule was written one commit ago, in 891a747, in response to exactly this class of mistake. It fired on the next review, on its author. That is the argument for writing such rules down rather than intending them. Still frozen-unimplemented. No mapper exists, so nothing is built on the old shape and nothing has to be migrated - the same reason the earlier correction was made in place rather than as a v2. The step 0 suite passes unchanged and its 28 vacuity probes all still bite: adding a limitation widens what `unresolved` may cite and touches nothing else. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-v1.json | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index acc9767..f22b21b 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -18,7 +18,21 @@ "gave 'unresolved' only an A-side anchor - so every unmatched B occurrence was", "pushed toward 'new'. That is the fabricated-birth defect the contract exists to", "forbid, written into the contract itself. It is fixed in place rather than as a", - "v2 because v1 has never been implemented or released: nothing is built on it." + "v2 because v1 has never been implemented or released: nothing is built on it.", + "", + "Amended again by slice 2 STEP 1, which added 'conflicting-evidence'. Step 1", + "built a decision policy on this contract and found it had no way to say that", + "one observed structure supports two incompatible conclusions - a copy record", + "and a merge record touching one occurrence, which is N:M and therefore none of", + "the six outcomes. Step 1 first tried to report that as 'ambiguous-candidates',", + "which is a different thing: candidates are partners you cannot choose between,", + "not readings that contradict each other.", + "It was corrected here, in the senior contract, rather than by the junior one", + "inventing a value in a vocabulary it declares itself subordinate to. That the", + "junior contract had a written rule saying exactly this, and that the rule fired", + "on the very next review, is the reason it is written down at all.", + "Still frozen-unimplemented: no mapper exists, so nothing is built on the old", + "shape and nothing has to be migrated." ], "outcomes": { "continued": { @@ -120,6 +134,7 @@ "limitations": { "no-mapping-evidence": "lineage-id-unavailable:no-mapping-evidence", "ambiguous-candidates": "lineage-id-unavailable:ambiguous-candidates", + "conflicting-evidence": "lineage-id-unavailable:conflicting-evidence", "missing-occurrence-id": "lineage-id-unavailable:missing-occurrence-id", "no-predecessor-revision": "lineage-id-unavailable:no-predecessor-revision", "insufficient-evidence-kind": "lineage-id-unavailable:insufficient-evidence-kind" @@ -129,7 +144,13 @@ "nothing else. An identity limitation is a statement about the mapper's reach;", "'ended' and 'new' are statements about the world, and cannot be earned by one.", "'no-predecessor-revision' means there is no revision A to compare against at", - "all - a first run. It must never be used while a predecessor revision exists." + "all - a first run. It must never be used while a predecessor revision exists.", + "'ambiguous-candidates' and 'conflicting-evidence' are neighbours and opposites,", + "and the difference is worth keeping sharp. Ambiguous candidates: SEVERAL", + "possible partners, nothing prefers one - the shape of two-candidates-unresolved.", + "Conflicting evidence: ONE observed structure that supports incompatible", + "conclusions about the same relation. The first is a choice with no grounds; the", + "second is grounds that argue with each other." ], "boundary_evidence_kinds": { "containing-file-deleted": { From 250aaa415e3a59acfbab3ad1b51b65ca3a246cd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:10:27 +0000 Subject: [PATCH 04/84] docs(identity): say what the subset sweep proves, and what it does not Two corrections to the decision policy, both about claims the suite was about to start enforcing as law. CONFLICT NOW CITES `conflicting-evidence`, the value 9e2862c added to the senior contract for it. The previous mapping onto `ambiguous-candidates` was closer than inventing a value but still wrong: ambiguous candidates are several partners with nothing to prefer between them, while a rule conflict is one structure read two incompatible ways. Reusing the word would have left that distinction alive only in `decision_detail`, a field the senior vocabulary does not govern. "JOINTLY SATISFIABLE" WAS THE WRONG PROPERTY, and the next line gave it away by counting 31 subsets. Five rules give 31 mathematical subsets, not 31 realisable evidence states: `same_path` and `path_rename` are normally exclusive for one edge, and the branch/merge rules apply at a different cardinality scope. The invariant is now totality over the declared ids, reachable or not - stronger, and it keeps the suite out of a job it must not have. A checker deciding which signal combinations are physically possible would be a second mapper, hidden and worse dressed than the first. So the sweep is now labelled for what it proves. It proves ARBITRATION ALGEBRA: given a set of ids, one answer, always, whatever the order. It does not prove APPLICABILITY SEMANTICS - that some real record can produce that set. Fixtures prove that, and keeping the two apart means neither has to pretend to be the other. The three checks are named separately because two probes show they do not subsume each other. A three-cycle is caught by dominance sanity and NOT by order-independence: with a cycle everything is dominated, nothing survives, and the answer is stably `unresolved` - defined-looking and wrong. With A>B and B>C declared but A vs C not, {A,B,C} resolves because A survives: a defined answer resting on a comparison nobody declared, which only pair completeness catches. Smaller, same class: `applicable_rules` is now defined as the DISTINCT ids with at least one surviving application. One rule may fire on several candidate edges and still appear once; a set of ids cannot carry multiplicity, and promising it would be a claim the shape cannot keep. Bindings belong in `evidence` and `decision_detail`. Checked: every reason value the policy can emit is drawn from the senior contract's limitations - no-mapping-evidence, ambiguous-candidates, conflicting-evidence - and nothing else. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 94 ++++++++++++++-------- 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index a6646e8..ca885a9 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -146,7 +146,7 @@ }, "conflict": { "outcome": "unresolved", - "reason": "lineage-id-unavailable:conflicting-rules", + "reason": "lineage-id-unavailable:conflicting-evidence", "unless": "exactly one applicable rule DOMINATES every other applicable rule, per `dominance`", "why": "two rules reaching different conclusions from the same evidence is a defect in this contract, and a mapper is the wrong place to paper over it. Refusing makes it visible; picking one hides it behind whichever branch was typed first." }, @@ -205,7 +205,7 @@ ] }, "record_additions": { - "applicable_rules": "every rule that fired after defeaters, winners and losers alike. Required whenever any fired.", + "applicable_rules": "the DISTINCT ids of rules having at least one surviving application. A set of ids, not a count of applications: one rule may fire on several candidate edges, and this field does not carry that multiplicity. The bindings live in `evidence` and `decision_detail`.", "licensed_by": "the rule(s) the outcome rests on. Required for continued, branched and merged; EMPTY on a refused conflict.", "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: conflicting_rules, inputs_unavailable, defeated_signals.", "signals_defeated": "signal name -> the record that defeated it. Present whenever step 2 removed anything.", @@ -241,7 +241,7 @@ "R-MERGE-FOLD" ], "outcome": "unresolved", - "reason": "lineage-id-unavailable:conflicting-rules", + "reason": "lineage-id-unavailable:conflicting-evidence", "why": [ "One occurrence that is both a copy source and a participant in a fold is an", "N:M shape, and finding-lineage/v1 has six outcomes among which none is N:M.", @@ -272,7 +272,11 @@ "reached it.", "On a refused conflict `licensed_by` is EMPTY, and the disagreeing rules appear", "in `applicable_rules` and in `decision_detail.conflicting_rules`. Nothing", - "licensed the refusal; the refusal is what happens when nothing can." + "licensed the refusal; the refusal is what happens when nothing can.", + "One more thing this field is NOT: a trace. A rule that fires on two candidate", + "edges appears once. Promising multiplicity in a set of ids would be a claim the", + "shape cannot keep, and the place for per-candidate bindings is the evidence and", + "the decision detail, where they can be recorded without being counted twice." ], "reason_mapping": { "no_rule_applied": { @@ -280,7 +284,7 @@ "detail": "inputs_unavailable, when the reason nothing applied is that a signal could not be evaluated" }, "conflicting_rules": { - "reason": "lineage-id-unavailable:ambiguous-candidates", + "reason": "lineage-id-unavailable:conflicting-evidence", "detail": "conflicting_rules: the ids that disagreed" }, "several_candidates": { @@ -292,38 +296,40 @@ "THE DECISION LAYER INVENTS NO REASON VALUES. Every `unresolved.reason` it can", "emit is drawn from finding-lineage/v1's `limitations`, and the integrity suite", "asserts that against the SENIOR contract rather than against a union of both.", - "A draft of this file declared two new values, `conflicting-rules` and", - "`input-unavailable`, while also declaring itself subordinate to a contract", - "whose vocabulary is frozen and whose own suite builds the allowed set from its", - "own `limitations`. Those two claims cannot both hold. Teaching the junior test", - "to accept the union would have been a test granting a contract what the senior", - "contract withheld.", - "The resolution is that both situations are already sayable. `unresolved` means", - "'the evidence does not decide', so a rule conflict IS ambiguous-candidates -", - "one relation with two incompatible readings rather than two candidate", - "occurrences, and the distinction between those is precision, not outcome.", - "An input that could not be evaluated leaves nothing to license a link, which", - "is no-mapping-evidence.", - "The precision is not lost: it moves to `decision_detail`, a junior field, where", - "a junior contract is entitled to speak. If a future situation genuinely cannot", - "be said in the senior vocabulary, the honest move is to amend the senior", - "contract in the open - not to widen it from below." + "A draft of this file declared two new values of its own. A second draft mapped", + "a rule conflict onto `ambiguous-candidates`, which was closer but still wrong:", + "ambiguous candidates are several partners with nothing to prefer between them,", + "while a rule conflict is ONE structure read two incompatible ways. Reusing the", + "word would have left that distinction alive only in a junior field.", + "So the senior contract was amended, in the open, to carry", + "`conflicting-evidence` - which is what the rule two paragraphs down demands,", + "and it demanded it of this file first.", + "An unevaluable input stays `no-mapping-evidence`: there really is nothing", + "observed to license a link, and `inputs_unavailable` keeps the reason why.", + "If a future situation is genuinely unsayable in the senior vocabulary, amend", + "the senior contract. Do not widen it from below, and do not teach a junior", + "test to accept a union - that is a test granting a contract what the senior", + "contract withheld." ], "arbitration_invariant": [ "PAIRWISE COMPLETENESS IS NOT ENOUGH. A > B, B > C, C > A covers every pair and", - "decides nothing; a mapper implementing only the pairwise law would then resolve", - "by enumeration order, which is the defect this whole section exists to remove.", - "The invariant is set-level:", - " For every jointly satisfiable non-empty SET of applicable rules, arbitration", - " yields exactly one defined result, independent of enumeration order.", - "With five rules that is 31 subsets, so the suite checks them exhaustively", - "rather than by argument. It also checks the dominance graph is ACYCLIC, that", - "every declared pair names existing rules with different outcomes, and that", - "permuting the rule order changes nothing.", - "Any subset containing both R-BRANCH-COPY and R-MERGE-FOLD stays unresolved", - "until some rule explicitly represents N:M - adding one is a visible act, and", - "silently letting a superset resolve would be how N:M starts being reported as", - "whichever half was checked first." + "decides nothing; a mapper implementing only the pairwise law would resolve by", + "enumeration order, which is the defect this section exists to remove.", + "The invariant is TOTALITY over the declared rule ids:", + "", + " Arbitration is a total function on every non-empty subset of the declared", + " rule IDs - whether or not that subset is reachable from a valid evidence", + " record - and its result does not depend on enumeration order.", + "", + "An earlier draft said 'every jointly satisfiable set', then counted 31 subsets", + "in the next line. That does not follow: five rules give 31 mathematical", + "subsets, not 31 realisable evidence states. `same_path` and `path_rename` are", + "normally exclusive for one predecessor-successor edge, and the branch/merge", + "rules apply at a different cardinality scope entirely.", + "Totality is the stronger and cleaner property anyway, and it keeps the suite", + "out of a job it must not have: deciding which signal combinations are", + "physically possible is domain reasoning, and a checker doing it would become a", + "second mapper, hidden and worse dressed than the first." ], "structural_signals": { "copy_record": { @@ -385,5 +391,25 @@ "leaving to be discovered.", "`signal_defeaters` is validated against this catalog, so a defeater cannot", "name a source that does not exist." + ], + "what_the_subset_sweep_proves": [ + "The 31-subset sweep proves ARBITRATION ALGEBRA: given a set of rule ids, the", + "policy yields one answer, always, regardless of order.", + "It does NOT prove APPLICABILITY SEMANTICS: that some real evidence record can", + "actually produce that set. That is what the fixtures are for, and keeping the", + "two apart is why neither has to pretend to be the other.", + "Three properties, checked separately because two probes show they do not", + "subsume one another:", + " 1. PAIR COMPLETENESS - every different-outcome pair of ids is either an", + " explicit dominance or an explicit deliberate refusal.", + " 2. DOMINANCE SANITY - the graph is acyclic, has no self-edge, and every", + " declared pair names existing ids whose outcomes really differ.", + " 3. TOTALITY / ORDER-INDEPENDENCE - all 31 subsets yield exactly one result,", + " and every permutation yields the same one.", + "A three-cycle is caught by (2) and NOT by (3): with a cycle every rule is", + "dominated, nothing survives, and the answer is stably `unresolved` - defined-", + "looking and wrong. Conversely, with A>B and B>C declared but A vs C not, the", + "subset {A,B,C} resolves because A survives - a defined answer resting on a", + "comparison nobody declared, which only (1) catches." ] } From cba03141cbec27cfc9c52ed64db5b684c2c1c28c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:24:22 +0000 Subject: [PATCH 05/84] docs(identity): remove stale pre-falsification claims from decision policy Three passages still described the policy as it was two commits ago. Left alone, the suite would have started enforcing the new law while the prose next to it taught the old one - and a suite author would have had to pick which half of one file to believe. 1. `dominance.declared_pairs[0].why` said losing rules stay "recorded in licensed_by". They stay in `applicable_rules`; `licensed_by` names only what the outcome rests on. The N:M refusal said the same thing more sharply wrong: it claimed both disagreeing ids go into `licensed_by`, on a refusal where the contract correctly requires `licensed_by: []`. Nothing licensed the refusal. Both now point at `applicable_rules` and `decision_detail.conflicting_rules`, which is where the shape stays visible without being attributed to a rule that did not produce it. 2. The rule-id motivation still claimed the re-judgement property for `licensed_by`. After the split that property moved: `applicable_rules` is the RECALL SET, because retiring a rule has to find the mappings where it LOST too - those are exactly the ones that change if the winner is later retired or arbitration moves. A recall set built from `licensed_by` would miss them silently. `licensed_by` answers the other question: why this outcome and not another. Neither substitutes for the other. 3. The worst of the three: `conflict_completeness_rule` still required pairs that "can be satisfied by one evidence set" and had the suite compute that from `requires_all`. That is precisely the applicability reasoning 250aaa4 forbids the checker to do, in the same file. Completeness is now over every DECLARED different-outcome pair, reachable or not. That is also the stronger rule, not a retreat. Declaring an unreachable pair costs one line and ends the question; a pair silently judged unreachable by a checker's guess is a decision nobody reviewed. Checked rather than assumed: the only surviving occurrence of "satisfied by one evidence set" is inside the correction that removes it, and every live mention of `licensed_by` states the new semantics. A structural walk over the JSON distinguishes historical notes from live claims, since a plain grep flags the corrections' own explanations of what they corrected. Contract layer is now internally consistent. Next: the three properties move out of terminal scratch scripts and into identity/tests/test_lineage_decision.py. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 56 +++++++++++++++------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index ca885a9..a7e25c8 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -16,10 +16,17 @@ "evidence sets scoring 0.8 become indistinguishable in the record, and the", "question 'would this mapping still be made under the new rule' stops having", "an answer - which is the promise mapping_provenance exists to keep.", - "So a mapping cites the RULE that licensed it, by id, in `licensed_by`.", - "Changing a rule then has a mechanical consequence: every mapping citing it", - "can be found and re-judged. A threshold moving from 0.7 to 0.75 leaves no", - "such trail.", + "So a mapping cites RULES BY ID, and the two id lists answer two questions.", + "`applicable_rules` is the RECALL SET for re-judgement. Retiring or changing a", + "rule means finding every mapping it touched - including the ones where it", + "LOST, because those are exactly the mappings that change if the winner is", + "later retired or if arbitration moves. A recall set built from `licensed_by`", + "would miss them, and miss them silently.", + "`licensed_by` answers a different question: why this outcome and not another.", + "Neither list substitutes for the other, and an earlier draft of this file", + "claimed the historical-re-judgement property for `licensed_by` while it still", + "held both. Splitting the fields moved that property to the other list.", + "A threshold moving from 0.7 to 0.75 leaves no such trail at all.", "This is a deliberate NO to a confidence class, not an omission. If one is", "ever wanted, it must be derived FROM the cited rules and stored beside them,", "never instead of them." @@ -177,7 +184,7 @@ "R-CONT-DRIFT", "R-CONT-RENAME" ], - "why": "a copy record EXPLAINS the multiplicity; a 1:1 rule reached its answer by not looking at the other successor. The more informed reading wins, and the losers are still recorded in licensed_by so the disagreement stays visible." + "why": "a copy record EXPLAINS the multiplicity; a 1:1 rule reached its answer by not looking at the other successor. The more informed reading wins, and the losers stay in `applicable_rules` so the disagreement remains visible - not in `licensed_by`, which names only what the outcome rests on." }, { "winner": "R-MERGE-FOLD", @@ -225,14 +232,23 @@ "available and the contract has to say which, or say neither." ], "conflict_completeness_rule": [ - "Every pair of rules that (a) can be satisfied by one evidence set and (b) names", - "a different outcome MUST either appear in `dominance.declared_pairs` or be", - "listed in `deliberately_unresolved_conflicts`. The integrity suite computes the", - "pairs from `requires_all` and fails on any that is in neither list.", - "This is a proof over the RULE SET rather than an example: a fixture can only", - "show that one conflict is handled, while this says none is unhandled. It is", - "also what caught the empty-dominance error, and it will catch the next rule", - "added without a thought about what it argues with." + "PAIR COMPLETENESS. Every pair of DECLARED rule ids naming a different outcome", + "must appear either in `dominance.declared_pairs` or in", + "`deliberately_unresolved_conflicts`. The suite enumerates pairs from the rule", + "ids and their outcomes, and fails on any pair in neither list.", + "It does NOT ask whether the pair is reachable from a real evidence record. An", + "earlier wording said 'can be satisfied by one evidence set' and had the suite", + "compute that from `requires_all` - which is the applicability reasoning that", + "`what_the_subset_sweep_proves` forbids the checker to do, in the same file. A", + "contract cannot hold both, and a suite author would have had to pick which", + "half to believe.", + "Checking every declared pair is also the stronger rule: an unreachable pair", + "costs one line to declare and stops being a question, while a pair judged", + "unreachable by a checker's guess is a decision nobody reviewed.", + "This is a proof over the RULE SET rather than an example: a fixture shows one", + "conflict is handled, this says none is unhandled. It caught the empty-dominance", + "error and then the N:M pair, and it will catch the next rule added without a", + "thought about what it argues with." ], "deliberately_unresolved_conflicts": [ { @@ -249,11 +265,15 @@ "structural record the other does not consult, and both records are true.", "Picking `branched` would drop the predecessors the fold names; picking", "`merged` would drop the successors the copy names. Refusing loses nothing", - "except the pretence, and the record names both rules in licensed_by so the", - "shape is visible to whoever decides whether v2 needs a seventh outcome.", - "This pair was not noticed while the rules were being written. The", - "completeness check found it on its first run, which is the argument for", - "having a proof over the rule set rather than only fixtures." + "except the pretence.", + "The shape stays visible without being licensed: both ids appear in", + "`applicable_rules` and in `decision_detail.conflicting_rules`, while", + "`licensed_by` is EMPTY. Nothing licensed the refusal - the refusal is what", + "happens when nothing can, and whoever decides whether a v2 needs a seventh", + "outcome reads it from there.", + "This pair was not noticed while the rules were being written. The pair", + "completeness check found it on its first run, which is the argument for a", + "proof over the rule set rather than fixtures alone." ] } ], From a05edeb761f1fed44aaa97766e2da50534ed94b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 13:48:29 +0000 Subject: [PATCH 06/84] test(identity): move the arbitration proofs out of the terminal identity/tests/test_lineage_decision.py, wired into both CI runs. Until now the most valuable part of step 1 lived in scratch scripts: the mechanism that told the author of the rules "you forgot a conflict, friend" - twice, on its first two runs. That is not something to leave in terminal history. The suite is careful about what it is NOT. Not a mapper test. Not an applicability test: whether a real record can produce a given set of rule applications is what fixtures are for, and a checker deciding which signal combinations are physically possible would be a second mapper. And not a test of its own arbitration function - writing an evaluator, sweeping every subset and observing it always returns something proves the evaluator is total, which is a man building a calculator and being pleased it can add. TOTALITY TURNED OUT TO BE A THEOREM, NOT AN AXIOM. Review asked whether properties 1 and 2 already entail 3, and said that if so it should be stated rather than staged as three tidy axioms. They do: Let S have more than one outcome and no refused pair. By pair completeness every different-outcome pair inside S is a dominance edge. (a) Survivors cannot span two outcome classes: two survivors of different outcomes form a pair that is an edge, and whichever way it points one of them has an incoming edge from inside S. (b) Survivors cannot be empty: a finite acyclic digraph has a source. So the result exists and is unique, and order never enters the argument. Confirmed rather than asserted: all 3^7 = 2187 classifications of this rule set's seven conflicting pairs were enumerated. 1705 satisfy properties 1 and 2, and NONE of those breaks totality. So there is no independent vacuity probe for 3, and that is a result rather than a gap - the contract now says two axioms and a theorem instead of three axioms. 1 and 2 are independent of each other, shown rather than argued: dropping one pair classification fails completeness with sanity still green, and a three-cycle with every pair classified fails sanity with completeness still green. Both also fail the sweep, which is exactly what being downstream means. The sweep is kept regardless. A proof holds for the contract as it stands; the sweep re-checks it after every future edit, including one that quietly moves an assumption the proof leans on. Its size is computed as 2**n - 1 from the rule count, so a sixth rule makes it 63 without anyone remembering a sacred number. The suite also enforces subordination FROM ABOVE: every `reason` the policy can emit is checked against finding-lineage/v1's limitations, never against a union of both files, and no rule may license `ended` or `new`, which boundary evidence owns. Structural signals must be observable from revision B data, and defeaters must name catalogued signals rather than strings agreed by convention. 19 suites green in both modes, actionlint clean, ASCII-only, pytest entry point per the #64 lesson. Next: the six adversarial fixtures. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- .github/workflows/ci.yml | 2 + contracts/finding-lineage-decision-v1.json | 52 ++-- identity/tests/test_lineage_decision.py | 266 +++++++++++++++++++++ 3 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 identity/tests/test_lineage_decision.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a6f23e..69bb2ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: PYTHONPATH=. python3 identity/tests/test_pattern.py PYTHONPATH=. python3 identity/tests/test_occurrence.py PYTHONPATH=. python3 identity/tests/test_lineage_contract.py + PYTHONUTF8=1 PYTHONPATH=. python3 identity/tests/test_lineage_decision.py PYTHONPATH=. python3 aggregate/tests/test_normalize.py PYTHONPATH=. python3 report/tests/test_sarif.py PYTHONPATH=. python3 report/tests/test_baseline.py @@ -60,6 +61,7 @@ jobs: PYTHONPATH=. python3 -O identity/tests/test_pattern.py PYTHONPATH=. python3 -O identity/tests/test_occurrence.py PYTHONPATH=. python3 -O identity/tests/test_lineage_contract.py + PYTHONUTF8=1 PYTHONPATH=. python3 -O identity/tests/test_lineage_decision.py PYTHONPATH=. python3 -O aggregate/tests/test_normalize.py PYTHONPATH=. python3 -O report/tests/test_sarif.py PYTHONPATH=. python3 -O report/tests/test_baseline.py diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index a7e25c8..f482545 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -413,23 +413,39 @@ "name a source that does not exist." ], "what_the_subset_sweep_proves": [ - "The 31-subset sweep proves ARBITRATION ALGEBRA: given a set of rule ids, the", - "policy yields one answer, always, regardless of order.", - "It does NOT prove APPLICABILITY SEMANTICS: that some real evidence record can", - "actually produce that set. That is what the fixtures are for, and keeping the", - "two apart is why neither has to pretend to be the other.", - "Three properties, checked separately because two probes show they do not", - "subsume one another:", - " 1. PAIR COMPLETENESS - every different-outcome pair of ids is either an", - " explicit dominance or an explicit deliberate refusal.", - " 2. DOMINANCE SANITY - the graph is acyclic, has no self-edge, and every", - " declared pair names existing ids whose outcomes really differ.", - " 3. TOTALITY / ORDER-INDEPENDENCE - all 31 subsets yield exactly one result,", - " and every permutation yields the same one.", - "A three-cycle is caught by (2) and NOT by (3): with a cycle every rule is", - "dominated, nothing survives, and the answer is stably `unresolved` - defined-", - "looking and wrong. Conversely, with A>B and B>C declared but A vs C not, the", - "subset {A,B,C} resolves because A survives - a defined answer resting on a", - "comparison nobody declared, which only (1) catches." + "The subset sweep proves ARBITRATION ALGEBRA: given a set of rule ids, the", + "policy yields one answer, always, regardless of order. It does NOT prove", + "APPLICABILITY SEMANTICS - that some real evidence record can produce that set.", + "Fixtures prove that, and keeping the two apart means neither has to pretend to", + "be the other.", + "", + "TWO AXIOMS AND A THEOREM, not three axioms. An earlier draft listed the three", + "as if they were independent, which looked tidier and was false:", + " 1. PAIR COMPLETENESS - every different-outcome pair of declared ids is", + " classified exactly once, as dominance or as deliberate refusal.", + " 2. DOMINANCE SANITY - edges name existing ids, never self-edge, always span", + " different outcomes, never point both ways, and form no cycle. No", + " transitivity is inferred: A>B and B>C do not create A>C.", + " 3. TOTALITY - follows FROM 1 and 2 plus the absorbing refusal rule:", + " Let S have more than one outcome and no refused pair. By 1 every", + " different-outcome pair inside S is an edge. Survivors cannot span two", + " outcome classes, since any two survivors of different outcomes form a", + " pair that is an edge, and whichever way it points one of them has an", + " incoming edge from inside S. Survivors cannot be empty, since a finite", + " acyclic digraph has a source. So the result exists and is unique, and", + " order never enters the argument.", + "", + "1 and 2 ARE independent of each other, shown rather than argued: dropping one", + "pair classification fails 1 with 2 still green, and a three-cycle with every", + "pair classified fails 2 with 1 still green.", + "3 has no independent probe, and that is a result rather than a gap. All 3^7 =", + "2187 classifications of this rule set's seven conflicting pairs were", + "enumerated: 1705 satisfy 1 and 2, and none of those breaks totality.", + "", + "The sweep is kept anyway. A proof holds for the contract as it stands, and the", + "sweep re-checks it after every future edit - including an edit that quietly", + "moves an assumption the proof leans on. It costs 2^n - 1 evaluations, computed", + "from the rule count rather than written down, so a sixth rule makes it 63", + "without anyone having to remember a sacred number." ] } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py new file mode 100644 index 0000000..e686669 --- /dev/null +++ b/identity/tests/test_lineage_decision.py @@ -0,0 +1,266 @@ +"""Structural integrity for `finding-lineage-decision/v1`. Bare python3 or pytest: + + PYTHONUTF8=1 PYTHONPATH=. python3 identity/tests/test_lineage_decision.py + +WHAT THIS IS NOT +---------------- +Not a mapper test, and not an applicability test. There is no mapper, and +whether a real evidence record can produce a given set of rule applications is +what the fixtures are for. This suite never asks whether a signal combination is +physically possible: that is domain reasoning, and a checker doing it would be a +second mapper, hidden and worse dressed than the first. + +It is also not a test of its own arbitration function. That distinction matters +enough to state: writing an evaluator, running it over every subset, and +observing that it always returns something proves only that the evaluator is +total. The claim being checked is about the CONTRACT's declared classifications, +so the properties below are read off the contract and the sweep is a cross-check +on a proof, not the proof itself. + +THE THREE PROPERTIES, AND WHICH ARE INDEPENDENT +----------------------------------------------- + 1. PAIR COMPLETENESS - every unordered pair of declared rule ids with + DIFFERENT outcomes is classified exactly once: an explicit dominance edge, + or an explicit deliberate refusal. Never both, never neither. + 2. DOMINANCE SANITY - edges name existing ids, never self-edge, always span + different outcomes, never appear in both directions, and the graph is + acyclic. No transitivity is inferred: A>B and B>C do not create A>C. + 3. TOTALITY - every non-empty subset of declared ids yields exactly one + result, independent of enumeration order. + +1 and 2 are independent axioms, and two probes show neither implies the other. +3 is NOT a third axiom: it is a THEOREM of 1 and 2 plus the absorbing refusal +rule, and the suite says so rather than staging it as one. + + Let S be a subset with more than one outcome and no refusal-classified pair. + By completeness every different-outcome pair inside S is a dominance edge. + (a) Survivors cannot span two outcome classes: if a and b both survive with + different outcomes, {a, b} is a different-outcome pair, so it is an edge + in one direction or the other, and whichever way it points gives one of + them an incoming edge from inside S. Contradiction. + (b) Survivors cannot be empty: dominance restricted to S is acyclic, and a + finite acyclic digraph has a source. + Therefore the survivor set is non-empty and single-outcome, so the result + exists and is unique. Order never enters the argument. + +The sweep is kept anyway, because a proof holds for the contract as it is today +and the sweep re-checks it after every future edit - including edits that break +an assumption the proof leans on without anyone noticing which one. + +-O-safe (explicit raises, no bare assert). ASCII-only output. +""" +import itertools +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, ROOT) + +SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") +POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") + +fails: list[str] = [] + + +def check(cond: bool, msg: str) -> None: + if not cond: + fails.append(msg) + + +def load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def dominance_edges(policy: dict) -> set: + return {(p["winner"], loser) + for p in policy["dominance"]["declared_pairs"] + for loser in p["loses"]} + + +def refusal_pairs(policy: dict) -> set: + return {frozenset(c["between"]) for c in policy["deliberately_unresolved_conflicts"]} + + +def has_cycle(nodes, edges) -> bool: + seen, stack = set(), set() + + def walk(node) -> bool: + if node in stack: + return True + if node in seen: + return False + seen.add(node) + stack.add(node) + found = any(walk(m) for (w, m) in edges if w == node) + stack.discard(node) + return found + + return any(walk(n) for n in nodes) + + +def arbitrate(subset, outcomes, edges, refusals): + """The contract's procedure, read structurally. Returns None for 'no defined + result', which the theorem says is unreachable - the sweep is what keeps that + honest after a future edit.""" + s = set(subset) + if not s: + return None + outs = {outcomes[r] for r in s} + if len(outs) == 1: + return (outs.pop(), frozenset(s)) + for pair in itertools.combinations(sorted(s), 2): + if frozenset(pair) in refusals: + return ("unresolved", frozenset()) + survivors = {r for r in s if not any((w, r) in edges for w in s)} + souts = {outcomes[r] for r in survivors} + if len(souts) == 1: + return (souts.pop(), frozenset(survivors)) + return None + + +def main() -> int: + senior = load(SENIOR) + policy = load(POLICY) + + rules = policy["rules"] + ids = sorted(rules) + outcomes = {r: rules[r]["outcome"] for r in ids} + edges = dominance_edges(policy) + refusals = refusal_pairs(policy) + + # ---- 0. The policy is subordinate, enforced from ABOVE. ----------------- + check(policy["builds_on"] == "finding-lineage/v1", + f"the policy must declare what it is subordinate to, got {policy.get('builds_on')!r}") + check(policy["status"] == "frozen-unimplemented", + "the policy must declare itself unimplemented until a mapper exists") + + senior_limitations = set(senior["limitations"].values()) + emitted = {v["reason"] for v in policy["reason_mapping"].values()} + emitted.add(policy["arbitration"]["none_applies"]["reason"]) + emitted.add(policy["arbitration"]["conflict"]["reason"]) + emitted.add(policy["arbitration"]["multiplicity"]["several_candidates_without_one"]["reason"]) + emitted |= {c["reason"] for c in policy["deliberately_unresolved_conflicts"]} + for reason in sorted(emitted): + check(reason in senior_limitations, + f"the policy emits {reason!r}, which finding-lineage/v1 does not define. " + "Amend the senior contract in the open; do not widen it from below.") + + senior_outcomes = set(senior["outcomes"]) + floor = senior["minimum_evidence_kinds_for_continued"] + for rid in ids: + check(outcomes[rid] in senior_outcomes, + f"{rid} names outcome {outcomes[rid]!r}, which is not one of the six") + check(outcomes[rid] not in ("ended", "new"), + f"{rid} licenses {outcomes[rid]!r}; boundary evidence owns that, not a rule") + if outcomes[rid] == "continued": + check(len(rules[rid].get("requires_all", [])) >= floor, + f"{rid} licenses continued on {len(rules[rid].get('requires_all', []))} " + f"kind(s); the frozen floor is {floor}") + + # ---- 1. PAIR COMPLETENESS - an exact cover over DECLARED ids. ----------- + conflicting = {frozenset(p) for p in itertools.combinations(ids, 2) + if outcomes[p[0]] != outcomes[p[1]]} + dominance_pairs = {frozenset(e) for e in edges} + for pair in sorted(conflicting, key=sorted): + a, b = sorted(pair) + in_dom, in_ref = pair in dominance_pairs, pair in refusals + check(in_dom or in_ref, + f"{a} and {b} reach different outcomes and neither dominates nor is a " + "declared refusal - a conflict nobody has looked at") + check(not (in_dom and in_ref), + f"{a} and {b} are classified twice, as dominance AND as a deliberate " + "refusal; a pair has one classification") + for pair in sorted(dominance_pairs | refusals, key=sorted): + check(pair in conflicting, + f"{sorted(pair)} is classified but is not a different-outcome pair of " + "declared ids; the classification describes nothing") + + # ---- 2. DOMINANCE SANITY ------------------------------------------------ + for (w, l) in sorted(edges): + check(w in rules, f"dominance names unknown winner {w!r}") + check(l in rules, f"dominance names unknown loser {l!r}") + check(w != l, f"{w} dominates itself") + if w in rules and l in rules: + check(outcomes[w] != outcomes[l], + f"{w} dominates {l} but both name {outcomes[w]!r}; dominance settles " + "disagreements, and there is none") + check((l, w) not in edges, f"{w} and {l} dominate each other") + check(not has_cycle(ids, edges), + "the dominance graph has a cycle. Every rule in it is dominated, nothing " + "survives, and arbitration answers `unresolved` while looking perfectly " + "well defined - which is why this is checked separately from order " + "independence, and cannot be caught by it.") + + # ---- 3. TOTALITY - a THEOREM of 1 and 2, re-checked exhaustively. ------- + expected = 2 ** len(ids) - 1 + results = {} + for size in range(1, len(ids) + 1): + for sub in itertools.combinations(ids, size): + results[frozenset(sub)] = arbitrate(sub, outcomes, edges, refusals) + check(len(results) == expected, + f"swept {len(results)} subsets, expected {expected} for {len(ids)} rules") + undefined = sorted((sorted(s) for s, r in results.items() if r is None), key=len) + check(not undefined, + f"subsets with no defined result: {undefined[:4]}. The theorem says this " + "cannot happen while properties 1 and 2 hold, so this failure means one of " + "them is broken in a way its own check did not catch, or the theorem's " + "assumptions moved.") + + mismatched = 0 + for subset in results: + base = results[subset] + for perm in itertools.islice(itertools.permutations(sorted(subset)), 6): + if arbitrate(perm, outcomes, edges, refusals) != base: + mismatched += 1 + check(mismatched == 0, f"{mismatched} subset permutation(s) changed the result") + + for pair in refusals: + for subset, res in results.items(): + if pair <= subset: + check(res is not None and res[0] == "unresolved", + f"{sorted(subset)} contains the refused pair {sorted(pair)} but " + f"resolves to {res}") + + # ---- 4. Structural signals are observable, like boundary evidence. ------ + catalog = policy["structural_signals"] + for name, spec in catalog.items(): + for field in ("observable_from", "matches", "why"): + check(field in spec, f"structural signal {name!r} must state {field!r}") + check(str(spec.get("observable_from", "")).startswith("revision_b."), + f"structural signal {name!r} must be observable from revision B data, " + f"got {spec.get('observable_from')!r}") + for signal, spec in policy["signal_defeaters"].items(): + check(signal in senior["evidence_kinds"], + f"{signal!r} is defeated by policy but is not a frozen evidence kind") + check(spec["defeated_by_signal"] in catalog, + f"{signal!r} is defeated by {spec['defeated_by_signal']!r}, which the " + "structural signal catalog does not carry") + for rid in ids: + for req in rules[rid].get("requires_all", []): + check(req in senior["evidence_kinds"] or req in catalog, + f"{rid} requires {req!r}, which is neither a frozen evidence kind " + "nor a catalogued structural signal") + + for f in fails: + print(f"FAIL: {f}") + if fails: + print(f"identity/lineage-decision: FAIL - {len(fails)} check(s) failed") + return 1 + print(f"identity/lineage-decision: OK - {len(ids)} rules, {len(conflicting)} " + f"different-outcome pairs all classified, {len(results)} subsets total " + f"(totality is a theorem of completeness and acyclicity, swept anyway)") + return 0 + + +def test_lineage_decision() -> None: + """Pytest entry point; the bare-script path uses `main()` directly.""" + rc = main() + if rc != 0: + raise AssertionError(f"lineage decision policy failed: {len(fails)} check(s)") + + +if __name__ == "__main__": + raise SystemExit(main()) From bb256a3b3e5afa40f07a568e55a8bf6992bf2d94 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 14:10:42 +0000 Subject: [PATCH 07/84] test(identity): make the decision-policy proof checks actually bite Three defects in a05edeb, all found by review reading the committed file against what the commit message claimed. 1. "CLASSIFIED EXACTLY ONCE" WAS NOT CHECKED EXACTLY ONCE. Declarations were turned into sets before being counted, so `loses: ["R-X", "R-X"]` collapsed into one edge and passed, and so did a repeated refusal. A set answers the weaker question "has one classification after deduplication"; the contract claims something about the declarations themselves. Now counted raw, at three levels: a directed edge declared once, a refusal pair declared once, and no pair in both categories. 2. THE PERMUTATION SWEEP COULD NOT FAIL. `arbitrate` takes `set(subset)` as its first act, so permuting the argument asked Python whether a set remembers order. It does not, and confirming that 720 times is not evidence about this contract - it is a man building a calculator and being pleased it can add. Deleted. Order-independence is already carried by the theorem, whose every step is stated over subsets and a relation. What remains is the one thing worth pinning: that the input is a set of distinct ids at all. 3. THE META-PROOFS WERE STILL IN THE TERMINAL. a05edeb was titled "move the arbitration proofs out of the terminal" and moved the RESULT while the checks ON the proof stayed in scratch scripts - which is a commit message louder than its diff. They are committed now, and to make that possible the two properties became pure functions of a policy shape: the meta-checks break the policy IN MEMORY and require the checkers to complain. 5a. dropping every refusal fails completeness with sanity still green. 5b. a three-cycle with every pair still classified fails sanity with completeness still green. Together these show the two axioms are independent, rather than asserting it. 5c. a duplicate declaration is caught before deduplication. 5d. THE THEOREM, enumerated. Three states per conflicting pair - a>b, b>a, refusal - over all 3^k classifications, k read from the contract rather than hardcoded. Every classification satisfying both axioms must satisfy totality. Today: 1705 of 2187 satisfy the axioms, 0 break totality, in 0.2 seconds. The meta-checks were themselves probed, since a meta-check that cannot fail is the same defect one level up. Four mutations, four bites - and the sharpest is that disabling the acyclicity check makes the suite report "totality is NOT a theorem" with a counterexample. The enumeration can detect a false claim about a proof, not merely agree with a true one. 19 suites green in both modes, ASCII-only. Now the six adversarial fixtures. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 225 +++++++++++++++++++----- 1 file changed, 180 insertions(+), 45 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index e686669..78db7d2 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -74,14 +74,17 @@ def load(path: str) -> dict: return json.load(fh) -def dominance_edges(policy: dict) -> set: - return {(p["winner"], loser) +def raw_dominance_edges(policy: dict) -> list: + """Occurrences, NOT a set. `classified exactly once` is a claim about the + declarations, and a set answers a weaker question - it would let + `loses: ["R-X", "R-X"]` collapse into one edge and pass.""" + return [(p["winner"], loser) for p in policy["dominance"]["declared_pairs"] - for loser in p["loses"]} + for loser in p["loses"]] -def refusal_pairs(policy: dict) -> set: - return {frozenset(c["between"]) for c in policy["deliberately_unresolved_conflicts"]} +def raw_refusal_pairs(policy: dict) -> list: + return [frozenset(c["between"]) for c in policy["deliberately_unresolved_conflicts"]] def has_cycle(nodes, edges) -> bool: @@ -101,6 +104,63 @@ def walk(node) -> bool: return any(walk(n) for n in nodes) +def pair_completeness_failures(ids, outcomes, raw_edges, raw_refusals) -> list: + """PROPERTY 1, as a pure function of a policy shape. + + Pure so the meta-checks below can feed it a deliberately broken policy and + require it to complain. A property checker that only ever sees the real + contract is a property checker nobody has tested.""" + out = [] + conflicting = {frozenset(p) for p in itertools.combinations(sorted(ids), 2) + if outcomes[p[0]] != outcomes[p[1]]} + # Exactly once means exactly once, counted BEFORE deduplication. + for edge in sorted(set(raw_edges)): + if raw_edges.count(edge) > 1: + out.append(f"dominance declares {edge[0]} > {edge[1]} {raw_edges.count(edge)} times") + for pair in sorted({frozenset(r) for r in raw_refusals}, key=sorted): + if raw_refusals.count(pair) > 1: + out.append(f"{sorted(pair)} is declared a refusal {raw_refusals.count(pair)} times") + dominance_pairs = {frozenset(e) for e in raw_edges} + refusals = {frozenset(r) for r in raw_refusals} + for pair in sorted(conflicting, key=sorted): + a, b = sorted(pair) + in_dom, in_ref = pair in dominance_pairs, pair in refusals + if not (in_dom or in_ref): + out.append(f"{a} and {b} reach different outcomes and neither dominates nor " + "is a declared refusal - a conflict nobody has looked at") + if in_dom and in_ref: + out.append(f"{a} and {b} are classified twice, as dominance AND as a " + "deliberate refusal; a pair has one classification") + for pair in sorted(dominance_pairs | refusals, key=sorted): + if pair not in conflicting: + out.append(f"{sorted(pair)} is classified but is not a different-outcome " + "pair of declared ids; the classification describes nothing") + return out + + +def dominance_sanity_failures(ids, outcomes, raw_edges) -> list: + """PROPERTY 2, pure for the same reason.""" + out, edges = [], set(raw_edges) + for (w, l) in sorted(edges): + if w not in outcomes: + out.append(f"dominance names unknown winner {w!r}") + if l not in outcomes: + out.append(f"dominance names unknown loser {l!r}") + if w == l: + out.append(f"{w} dominates itself") + if w in outcomes and l in outcomes and outcomes[w] == outcomes[l]: + out.append(f"{w} dominates {l} but both name {outcomes[w]!r}; dominance " + "settles disagreements, and there is none") + if (l, w) in edges: + out.append(f"{w} and {l} dominate each other") + if has_cycle(ids, edges): + out.append("the dominance graph has a cycle. Every rule in it is dominated, " + "nothing survives, and arbitration answers `unresolved` while " + "looking perfectly well defined - which is why this is checked " + "separately from totality and cannot be caught by it.") + return out + + def arbitrate(subset, outcomes, edges, refusals): """The contract's procedure, read structurally. Returns None for 'no defined result', which the theorem says is unreachable - the sweep is what keeps that @@ -128,8 +188,10 @@ def main() -> int: rules = policy["rules"] ids = sorted(rules) outcomes = {r: rules[r]["outcome"] for r in ids} - edges = dominance_edges(policy) - refusals = refusal_pairs(policy) + raw_edges = raw_dominance_edges(policy) + raw_refusals = raw_refusal_pairs(policy) + edges = set(raw_edges) + refusals = {frozenset(r) for r in raw_refusals} # ---- 0. The policy is subordinate, enforced from ABOVE. ----------------- check(policy["builds_on"] == "finding-lineage/v1", @@ -160,39 +222,13 @@ def main() -> int: f"{rid} licenses continued on {len(rules[rid].get('requires_all', []))} " f"kind(s); the frozen floor is {floor}") - # ---- 1. PAIR COMPLETENESS - an exact cover over DECLARED ids. ----------- + # ---- 1 and 2, via the pure functions the meta-checks also exercise. ----- conflicting = {frozenset(p) for p in itertools.combinations(ids, 2) if outcomes[p[0]] != outcomes[p[1]]} - dominance_pairs = {frozenset(e) for e in edges} - for pair in sorted(conflicting, key=sorted): - a, b = sorted(pair) - in_dom, in_ref = pair in dominance_pairs, pair in refusals - check(in_dom or in_ref, - f"{a} and {b} reach different outcomes and neither dominates nor is a " - "declared refusal - a conflict nobody has looked at") - check(not (in_dom and in_ref), - f"{a} and {b} are classified twice, as dominance AND as a deliberate " - "refusal; a pair has one classification") - for pair in sorted(dominance_pairs | refusals, key=sorted): - check(pair in conflicting, - f"{sorted(pair)} is classified but is not a different-outcome pair of " - "declared ids; the classification describes nothing") - - # ---- 2. DOMINANCE SANITY ------------------------------------------------ - for (w, l) in sorted(edges): - check(w in rules, f"dominance names unknown winner {w!r}") - check(l in rules, f"dominance names unknown loser {l!r}") - check(w != l, f"{w} dominates itself") - if w in rules and l in rules: - check(outcomes[w] != outcomes[l], - f"{w} dominates {l} but both name {outcomes[w]!r}; dominance settles " - "disagreements, and there is none") - check((l, w) not in edges, f"{w} and {l} dominate each other") - check(not has_cycle(ids, edges), - "the dominance graph has a cycle. Every rule in it is dominated, nothing " - "survives, and arbitration answers `unresolved` while looking perfectly " - "well defined - which is why this is checked separately from order " - "independence, and cannot be caught by it.") + for msg in pair_completeness_failures(ids, outcomes, raw_edges, raw_refusals): + check(False, msg) + for msg in dominance_sanity_failures(ids, outcomes, raw_edges): + check(False, msg) # ---- 3. TOTALITY - a THEOREM of 1 and 2, re-checked exhaustively. ------- expected = 2 ** len(ids) - 1 @@ -209,13 +245,17 @@ def main() -> int: "them is broken in a way its own check did not catch, or the theorem's " "assumptions moved.") - mismatched = 0 - for subset in results: - base = results[subset] - for perm in itertools.islice(itertools.permutations(sorted(subset)), 6): - if arbitrate(perm, outcomes, edges, refusals) != base: - mismatched += 1 - check(mismatched == 0, f"{mismatched} subset permutation(s) changed the result") + # NO PERMUTATION SWEEP. An earlier version ran one and it could not fail: + # `arbitrate` takes `set(subset)` as its first act, so permuting the argument + # asked Python whether a set remembers order. It does not, and confirming + # that 720 times is not evidence about this contract. + # Order-independence is already carried by the theorem, whose every step is + # stated over subsets and a relation. What IS worth pinning is that the input + # is a set of distinct ids in the first place - the shape the argument needs. + check(all(len(set(sub)) == len(sub) for sub in + itertools.chain.from_iterable(itertools.combinations(ids, n) + for n in range(1, len(ids) + 1))), + "arbitration input must be a set of distinct rule ids") for pair in refusals: for subset, res in results.items(): @@ -244,6 +284,101 @@ def main() -> int: f"{rid} requires {req!r}, which is neither a frozen evidence kind " "nor a catalogued structural signal") + + # ---- 5. META: the proof checks are checked, in-process. ---------------- + # a05edeb was titled "move the arbitration proofs out of the terminal" and + # moved the RESULT while the meta-proofs stayed in scratch scripts. These are + # those, committed. They break the policy IN MEMORY, which is why the + # properties above are pure functions rather than inline loops. + + # 5a. Property 1 bites, and property 2 stays green while it does. + no_refusals = ([], []) + broke_1 = pair_completeness_failures(ids, outcomes, raw_edges, no_refusals[1]) + check(bool(broke_1), + "dropping every refusal classification left pair completeness satisfied; " + "the check does not bite") + check(not dominance_sanity_failures(ids, outcomes, raw_edges), + "dropping a refusal classification also tripped dominance sanity; the two " + "properties are supposed to be independent") + + # 5b. Property 2 bites, and property 1 stays green while it does. + cyc_ids = [r for r in ids if outcomes[r] == "continued"][:1] + \ + [r for r in ids if outcomes[r] == "branched"][:1] + \ + [r for r in ids if outcomes[r] == "merged"][:1] + if len(cyc_ids) == 3: + a, b, c = cyc_ids + others = [r for r in ids if r not in cyc_ids] + cyc_edges = [(a, b), (b, c), (c, a)] + # every remaining conflicting pair still classified, so property 1 holds + for x in others: + for y in (a, b, c): + if outcomes[x] != outcomes[y]: + cyc_edges.append((y, x) if outcomes[y] != "continued" else (x, y)) + for x, y in itertools.combinations(others, 2): + if outcomes[x] != outcomes[y]: + cyc_edges.append((x, y)) + check(bool(dominance_sanity_failures(ids, outcomes, cyc_edges)), + "a three-cycle did not trip dominance sanity") + check(not pair_completeness_failures(ids, outcomes, cyc_edges, []), + "the three-cycle construction also broke pair completeness, so it does " + "not show the two properties are independent") + + # 5c. Duplicate declarations are caught BEFORE deduplication. + check(bool(pair_completeness_failures(ids, outcomes, raw_edges + raw_edges[:1], + raw_refusals)), + "a dominance edge declared twice was absorbed by a set and passed; " + "`classified exactly once` is a claim about the declarations") + if raw_refusals: + check(bool(pair_completeness_failures(ids, outcomes, raw_edges, + raw_refusals + raw_refusals[:1])), + "a refusal declared twice was absorbed by a set and passed") + + # 5d. THE THEOREM, checked exhaustively over the whole policy space. + # Three states per conflicting pair: a>b, b>a, refusal. Every classification + # satisfying properties 1 and 2 must satisfy totality - if one did not, the + # theorem would be false and the contract would be claiming a proof it does + # not have. + k = len(conflicting) + space = 3 ** k + budget = 300_000 + if space * (2 ** len(ids)) > budget * 40: + print(f"NOTE: policy space 3^{k} = {space} is too large to enumerate here; " + "the theorem check ran on pairs only") + else: + pair_list = [tuple(sorted(pr)) for pr in sorted(conflicting, key=sorted)] + satisfying, counterexamples = 0, [] + for combo in itertools.product((0, 1, 2), repeat=k): + e, rf = [], [] + for (x, y), state in zip(pair_list, combo): + if state == 0: + e.append((x, y)) + elif state == 1: + e.append((y, x)) + else: + rf.append(frozenset((x, y))) + if pair_completeness_failures(ids, outcomes, e, rf): + continue + if dominance_sanity_failures(ids, outcomes, e): + continue + satisfying += 1 + es, rs = set(e), set(rf) + for n in range(1, len(ids) + 1): + for sub in itertools.combinations(ids, n): + if arbitrate(sub, outcomes, es, rs) is None: + counterexamples.append((combo, sub)) + break + if counterexamples: + break + check(satisfying > 0, + f"no classification of the {k} conflicting pairs satisfied both axioms; " + "the enumeration is not exercising anything") + check(not counterexamples, + f"totality is NOT a theorem of the two axioms: {len(counterexamples)} " + f"counterexample(s), first {counterexamples[:1]}. The contract claims a " + "proof it does not have.") + print(f" theorem: {satisfying} of {space} classifications satisfy both " + f"axioms, 0 break totality") + for f in fails: print(f"FAIL: {f}") if fails: From ab992fb091d9b742caad12dc360f69bef1af8339 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 14:31:14 +0000 Subject: [PATCH 08/84] fix(identity): state the arbitration the theorem actually proves The contract and the committed proof described two different policies. `arbitration.conflict` read "unless exactly ONE applicable rule DOMINATES every other applicable rule", while `arbitrate` in the suite - and the theorem in `what_the_subset_sweep_proves` - removed the dominated rules and let the SURVIVORS decide. Those are not the same policy. Under a classification where two continued rules both dominate the merge rule and neither dominates the other, the survivor reading answers `continued` and the single-winner reading refuses; the 3^7 enumeration covers exactly such classifications, so the sweep was proving totality of a policy this file did not describe. The prose gives way, not the algebra. Totality follows from pair completeness and acyclicity precisely because survivors cannot span two outcome classes and cannot be empty. "Exactly one dominating rule" is not implied by either axiom, and refusing a subset whose survivors all agree would be wrong on its own terms: there is nothing left to disagree with them. 1. `arbitration.conflict.unless` is now the survivor reading, with the abandoned wording and why it cost the contract its theorem recorded next to it. 2. `licensed_by` gets ONE definition - `arbitration.licensed_by_rule` - as the surviving rules, empty exactly on a refusal. `all_agree` is marked as a reading of that rule rather than a separate law: dominance never spans one outcome, so every applicable rule survives there. The record shape and the two-list section repeat the same definition. 3. Probe 5a no longer needs a refusal to exist. It dropped every refusal classification, which made it an assertion about this policy having refusals - a policy declaring none would have failed for having nothing to break. It now drops one declared classification of whichever kind is present. 4. The 3^k enumeration is fail-closed on cost. Exceeding the reviewed budget takes the suite red naming the choice, instead of printing a NOTE and reporting OK with the proof claim unpaid. Two new shape checks keep the definition from evaporating: `arbitration` must carry `licensed_by_rule`, and `conflict` must say what `licensed_by` is on a refusal. Still no mapper. Suite: 5 rules, 7 pairs classified, 31 subsets, 1705 of 2187 classifications satisfy both axioms and 0 break totality (67797 arbitrations, budget 250000). All 20 python suites green in normal and -O. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 56 +++++++++++++-- identity/tests/test_lineage_decision.py | 80 +++++++++++++++++----- 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index f482545..dde17f2 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -149,13 +149,37 @@ "all_agree": { "outcome": "the shared outcome", "licensed_by": "EVERY applicable rule, not the first or the strongest", - "why": "the record has to survive one of those rules being retired later" + "why": "the record has to survive one of those rules being retired later", + "this_is_not_a_separate_law": [ + "It is the general rule read on a set that happens to agree. Dominance", + "never spans two rules naming the same outcome - `dominance sanity`", + "forbids exactly that - so no rule here can be dominated by another", + "applicable one, and every applicable rule survives. The branch is kept", + "because it is the common case and reading it out of the general rule", + "each time invites a mapper to get it subtly wrong." + ] }, "conflict": { "outcome": "unresolved", "reason": "lineage-id-unavailable:conflicting-evidence", - "unless": "exactly one applicable rule DOMINATES every other applicable rule, per `dominance`", - "why": "two rules reaching different conclusions from the same evidence is a defect in this contract, and a mapper is the wrong place to paper over it. Refusing makes it visible; picking one hides it behind whichever branch was typed first." + "unless": "after removing every rule dominated by another APPLICABLE rule, the surviving rules are non-empty and all name the same outcome; then the outcome is that shared outcome and `licensed_by` is EVERY surviving rule", + "licensed_by_on_refusal": "EMPTY. Nothing licensed the refusal.", + "why": "two rules reaching different conclusions from the same evidence is a defect in this contract, and a mapper is the wrong place to paper over it. Refusing makes it visible; picking one hides it behind whichever branch was typed first.", + "why_survivors_and_not_a_single_winner": [ + "An earlier wording read `unless exactly ONE applicable rule dominates", + "every other applicable rule`. That is a different policy, and a narrower", + "one: {R-CONT-DRIFT, R-CONT-RENAME, R-MERGE-FOLD} has both continued rules", + "dominating the merge rule and neither dominating the other, so the old", + "wording refused while the survivor reading answers `continued`. Refusing", + "there would be wrong on its own terms - both survivors agree, and there is", + "nothing left to disagree with them.", + "It also cost the contract its theorem. Totality follows from pair", + "completeness and acyclicity precisely because survivors cannot span two", + "outcome classes and cannot be empty; `exactly one dominating rule` is not", + "implied by either axiom, so the proof in `what_the_subset_sweep_proves`", + "supported the survivor reading and the prose asked for something else.", + "The suite was proving totality of a policy this file did not describe." + ] }, "multiplicity": { "rule": "a 1:1 outcome requires exactly ONE candidate satisfying the licensing rule", @@ -173,7 +197,14 @@ "Uniqueness is checked per RULE, not per outcome: a discriminating rule may", "single out a candidate while a blunter one does not." ] - } + }, + "licensed_by_rule": [ + "ONE definition, and the branches above are readings of it: `licensed_by` is", + "the set of SURVIVING rules - the applicable rules that no other applicable", + "rule dominates - and it is empty exactly when the outcome is a refusal.", + "Dominated rules never appear in it. They stay in `applicable_rules`, where", + "the disagreement remains readable without being credited with the answer." + ] }, "dominance": { "declared_pairs": [ @@ -213,7 +244,7 @@ }, "record_additions": { "applicable_rules": "the DISTINCT ids of rules having at least one surviving application. A set of ids, not a count of applications: one rule may fire on several candidate edges, and this field does not carry that multiplicity. The bindings live in `evidence` and `decision_detail`.", - "licensed_by": "the rule(s) the outcome rests on. Required for continued, branched and merged; EMPTY on a refused conflict.", + "licensed_by": "the SURVIVING rules the outcome rests on - the applicable rules that no other applicable rule dominates, per `arbitration.licensed_by_rule`. Non-empty for continued, branched and merged; EMPTY on a refused conflict.", "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: conflicting_rules, inputs_unavailable, defeated_signals.", "signals_defeated": "signal name -> the record that defeated it. Present whenever step 2 removed anything.", "inputs_unavailable": "signal names that could not be evaluated. Present whenever any were." @@ -284,7 +315,9 @@ " including the ones that lost arbitration. This is the audit trail: retiring", " a rule later means finding every mapping it touched, and the losers are", " exactly the mappings that would change if the winner were retired.", - "`licensed_by` - the rule or rules the OUTCOME actually rests on. Nothing else.", + "`licensed_by` - the SURVIVING rules, and only those: the applicable rules that", + " no other applicable rule dominates. Several may survive together, and all of", + " them are named; see `arbitration.licensed_by_rule`.", "The draft put both into `licensed_by`, so a branched mapping asserted that", "R-CONT-SAME-SITE licensed `branched`. It did not: it proposed `continued` and", "lost. A record that says otherwise is wrong in the same way a fabricated", @@ -446,6 +479,15 @@ "sweep re-checks it after every future edit - including an edit that quietly", "moves an assumption the proof leans on. It costs 2^n - 1 evaluations, computed", "from the rule count rather than written down, so a sixth rule makes it 63", - "without anyone having to remember a sacred number." + "without anyone having to remember a sacred number.", + "", + "The 3^k enumeration is FAIL-CLOSED on cost. A sixth rule multiplies both the", + "classification space and the subset sweep, and past a reviewed budget the", + "suite stops with `policy-space exceeded exhaustive-proof budget` rather than", + "printing a note and going green on the pair checks alone. A run that quietly", + "stopped proving the theorem while still reporting OK is worse than a red one:", + "the claim in this section would go on standing with nothing behind it. Raising", + "the budget is a reviewed edit, and so is replacing the enumeration with another", + "checked argument." ] } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 78db7d2..77f2717 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -28,6 +28,13 @@ 3. TOTALITY - every non-empty subset of declared ids yields exactly one result, independent of enumeration order. +Arbitration is read here the way `arbitration.conflict` states it: remove every +rule some other applicable rule dominates, and the SURVIVORS decide. If they all +name one outcome that is the outcome, and they are all named in `licensed_by`. +Not "one rule dominates all the others" - that is a strictly narrower policy, it +is not what properties 1 and 2 imply, and while the contract said it the sweep +below was proving totality of a policy the contract did not describe. + 1 and 2 are independent axioms, and two probes show neither implies the other. 3 is NOT a third axiom: it is a THEOREM of 1 and 2 plus the absorbing refusal rule, and the suite says so rather than staging it as one. @@ -61,6 +68,11 @@ SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") +# Reviewed cost ceiling for the 3^k totality enumeration, in arbitrations. The +# current policy spends 2187 * 31 = 67797 of it. Exceeding it is a FAILURE, not a +# downgrade: see meta-check 5d. +EXHAUSTIVE_PROOF_BUDGET = 250_000 + fails: list[str] = [] @@ -162,9 +174,10 @@ def dominance_sanity_failures(ids, outcomes, raw_edges) -> list: def arbitrate(subset, outcomes, edges, refusals): - """The contract's procedure, read structurally. Returns None for 'no defined - result', which the theorem says is unreachable - the sweep is what keeps that - honest after a future edit.""" + """The contract's procedure, read structurally. Returns (outcome, licensed_by) + where licensed_by is the SURVIVOR set - empty on a refusal - or None for 'no + defined result', which the theorem says is unreachable. The sweep is what + keeps that honest after a future edit.""" s = set(subset) if not s: return None @@ -198,6 +211,16 @@ def main() -> int: f"the policy must declare what it is subordinate to, got {policy.get('builds_on')!r}") check(policy["status"] == "frozen-unimplemented", "the policy must declare itself unimplemented until a mapper exists") + # `licensed_by` has ONE definition, and `arbitrate` below implements it. A + # policy that drops the definition and leaves the branches to imply it is how + # the survivor reading and the single-winner reading drifted apart the first + # time. + check("licensed_by_rule" in policy["arbitration"], + "`arbitration` must define `licensed_by` once, for every branch; " + "without it each branch states its own and they drift") + check("licensed_by_on_refusal" in policy["arbitration"]["conflict"], + "`arbitration.conflict` must say what `licensed_by` is on a refusal; " + "empty is a claim, and it has to be written down as one") senior_limitations = set(senior["limitations"].values()) emitted = {v["reason"] for v in policy["reason_mapping"].values()} @@ -292,14 +315,29 @@ def main() -> int: # properties above are pure functions rather than inline loops. # 5a. Property 1 bites, and property 2 stays green while it does. - no_refusals = ([], []) - broke_1 = pair_completeness_failures(ids, outcomes, raw_edges, no_refusals[1]) - check(bool(broke_1), - "dropping every refusal classification left pair completeness satisfied; " - "the check does not bite") - check(not dominance_sanity_failures(ids, outcomes, raw_edges), - "dropping a refusal classification also tripped dominance sanity; the two " - "properties are supposed to be independent") + # The mutation drops ONE declared classification of whichever kind the policy + # happens to carry. An earlier version dropped every refusal, which made the + # probe an assertion about this policy having refusals: a policy that declared + # none would have failed here for having nothing to break, and one that later + # resolved its last refused pair into a dominance edge would have taken the + # suite red for an improvement. + if raw_edges: + mut_edges, mut_refusals, dropped = raw_edges[1:], raw_refusals, "dominance edge" + elif raw_refusals: + mut_edges, mut_refusals, dropped = raw_edges, raw_refusals[1:], "refusal" + else: + mut_edges, mut_refusals, dropped = None, None, "" + if mut_edges is None: + check(not conflicting, + "the policy declares different-outcome pairs but no classification of " + "any kind, so property 1 cannot be probed by removing one") + else: + check(bool(pair_completeness_failures(ids, outcomes, mut_edges, mut_refusals)), + f"dropping one declared {dropped} left pair completeness satisfied; " + "the check does not bite") + check(not dominance_sanity_failures(ids, outcomes, mut_edges), + f"dropping one declared {dropped} also tripped dominance sanity; the " + "two properties are supposed to be independent") # 5b. Property 2 bites, and property 1 stays green while it does. cyc_ids = [r for r in ids if outcomes[r] == "continued"][:1] + \ @@ -338,12 +376,21 @@ def main() -> int: # satisfying properties 1 and 2 must satisfy totality - if one did not, the # theorem would be false and the contract would be claiming a proof it does # not have. + # The budget is FAIL-CLOSED. Growing the rule set past it takes the suite red + # with a message naming the choice, because the alternative - printing a note, + # skipping the enumeration and still reporting OK - leaves the contract's proof + # claim standing with nothing behind it, and nobody reads a green run's notes. k = len(conflicting) space = 3 ** k - budget = 300_000 - if space * (2 ** len(ids)) > budget * 40: - print(f"NOTE: policy space 3^{k} = {space} is too large to enumerate here; " - "the theorem check ran on pairs only") + cost = space * (2 ** len(ids) - 1) + if cost > EXHAUSTIVE_PROOF_BUDGET: + check(False, + f"policy-space exceeded exhaustive-proof budget: 3^{k} = {space} " + f"classifications x {2 ** len(ids) - 1} subsets = {cost} arbitrations, " + f"over the reviewed budget of {EXHAUSTIVE_PROOF_BUDGET}. Either raise " + "the reviewed budget or replace the finite enumeration with another " + "checked argument. Do not skip it: `what_the_subset_sweep_proves` " + "claims totality is proven, and this is where that is paid for.") else: pair_list = [tuple(sorted(pr)) for pr in sorted(conflicting, key=sorted)] satisfying, counterexamples = 0, [] @@ -377,7 +424,8 @@ def main() -> int: f"counterexample(s), first {counterexamples[:1]}. The contract claims a " "proof it does not have.") print(f" theorem: {satisfying} of {space} classifications satisfy both " - f"axioms, 0 break totality") + f"axioms, 0 break totality ({cost} arbitrations, budget " + f"{EXHAUSTIVE_PROOF_BUDGET})") for f in fails: print(f"FAIL: {f}") From c41f960c7dcf9e0b3b94d7aa2352639835c42160 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:02:33 +0000 Subject: [PATCH 09/84] fix(identity): license the frozen corpus, and unbind the theorem from k Writing the first two step-1 fixtures falsified the policy before either was finished. Walking the frozen finding-lineage/v1 corpus rule by rule showed the decision policy could not license outcomes that contract has already frozen: copy-branches-without-a-winner occ-a1->occ-b2 no continued rule duplicate-sites-merge-into-one occ-a1->occ-b1 no continued rule duplicate-sites-merge-into-one occ-a2->occ-b1 no continued rule deleted-source-survives-in-its-copy frozen continued, only R-BRANCH-COPY fired The cause is one shape. Every continued rule requires `structural_context`, and both structural cases change the enclosing symbol - DocView.Wire becomes DocCopyView.Wire in the copy, WireA and WireB become Wire in the fold. So `requires_per_successor: a rule of outcome continued` was unsatisfiable on the two fixtures those rules exist for, and the conflict `dominance.rule` cites as its reason to exist could not arise. The fourth case was worse than a gap: the only rule that fired named a different outcome than the frozen one. EVIDENCE: explicit partner profiles, and neither of the two easy repairs. No bridges. Reading `copy_record` AS `same_path`, or `merge_record` AS `structural_context`, is evidence laundering - a structural record explains why the ordinary context changed, and no honest bridge turns DocView.Wire into DocCopyView.Wire. No bare count. The senior floor of 2 is a floor, not the algorithm: it forbids resting on one weak signal, it does not promise any two suffice. `same_path + same_pattern_id` is two kinds and describes a twin. So each structural rule names its partner profile - `same_pattern_id` and `anchored_content`, what a partner shows on its own across a transformation that moved the file, the symbol, or both. The suite checks the profile clears the floor and holds nothing `sufficient_alone`; it never derives it. + R-CONT-COPY, the literal mirror of R-CONT-RENAME, taking the path relation from `copies` instead of `renames`. Its absence is what made the fourth case a contradiction rather than a hole. + cardinality as a machine-readable PRECONDITION on every rule - 1:1, or 1:N with min_successors 2, or N:1 with min_predecessors 2 - so a lone successor never licenses `branched` and is then overruled by another section. One question, one passage. + the two new different-outcome pairs classified. The guards make R-CONT-COPY vs R-BRANCH-COPY hard to reach, and it is classified anyway: that is the price of a completeness law covering unreachable pairs. THEOREM: change the argument, do not raise the ceiling. The sixth rule took the 3^k meta-enumeration from 3^7 x 31 = 67797 to 3^9 x 63 = 1240029 and the fail-closed budget fired. It was right to fire, and raising it would buy one rule and the same bill after it - because the proof of totality quantifies over a subset and a relation and never mentions k. So the two jobs are split. This policy is still verified directly and completely: exact pair completeness, dominance sanity, the full 2^n - 1 sweep of its own ids, absorbing refusals, mutation probes. The theorem is falsified against a FIXED four-rule model, outcomes partitioned A A B C - sized by the test file, not the policy - and called bounded falsification, not proof. The model is chosen to contain the shape the survivor reading turns on, two rules of one outcome surviving together, and a guard fails if it stops containing it. Adding an ordinary rule is now a contract edit and nothing else. Verified: all eleven frozen mapping outcomes are licensable by a rule of the right outcome, none by a rule of the wrong one. Ten probes bite - cardinality dropped, min_successors 1, profile removed, profile of one kind, profile naming a non-kind, the old wording restored, an unclassified pair, continued at 1:N, a model without the interesting shape, and the model over budget. Disabling acyclicity still refutes the theorem, now with 46 counterexamples at 3645 arbitrations instead of 67797. Still no mapper. The six fixtures come next, as one matrix. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 191 +++++++++++++++++--- identity/tests/test_lineage_decision.py | 199 ++++++++++++++------- 2 files changed, 303 insertions(+), 87 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index dde17f2..4f180b7 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -52,6 +52,9 @@ "rules": { "R-CONT-SAME-SITE": { "outcome": "continued", + "cardinality": { + "shape": "1:1" + }, "requires_all": [ "same_path", "structural_context", @@ -61,6 +64,9 @@ }, "R-CONT-DRIFT": { "outcome": "continued", + "cardinality": { + "shape": "1:1" + }, "requires_all": [ "same_path", "structural_context", @@ -71,6 +77,9 @@ }, "R-CONT-RENAME": { "outcome": "continued", + "cardinality": { + "shape": "1:1" + }, "requires_all": [ "path_rename", "structural_context", @@ -79,21 +88,58 @@ "means": "the file moved and the occurrence moved with it, unchanged inside", "note": "path_rename alone can never do this. A rename relates FILES; the other two are what relate the occurrence." }, + "R-CONT-COPY": { + "outcome": "continued", + "cardinality": { + "shape": "1:1" + }, + "requires_all": [ + "copy_record", + "structural_context", + "anchored_content" + ], + "means": "the file was COPIED and this occurrence went with the copy, unchanged inside, in a symbol of the same name", + "note": "the literal mirror of R-CONT-RENAME, differing only in where the path relation comes from: `copies` instead of `renames`. Its absence was a hole, not a simplification - finding-lineage/v1 freezes `deleted-source-survives-in-its-copy` as continued and forbids even unresolved there, and with no rule of this shape the only rule that fired on that edge was R-BRANCH-COPY, which names a different outcome. A policy whose single applicable rule contradicts a frozen case is not incomplete; it is wrong." + }, "R-BRANCH-COPY": { "outcome": "branched", + "cardinality": { + "shape": "1:N", + "min_successors": 2 + }, "requires_all": [ "copy_record" ], - "requires_per_successor": "a rule of outcome `continued`", + "requires_all_scope": "GROUP. The copy record relates the predecessor's path to the path of at least one successor that is NOT at the predecessor's path - that successor is the one the record explains.", + "partner_profile": { + "per": "successor", + "requires_all": [ + "same_pattern_id", + "anchored_content" + ], + "why": "what every successor must show ON ITS OWN, across a transformation that changed the path and may have changed the enclosing symbol too" + }, "means": "one predecessor, several successors, and a RECORD explaining why there are several", "note": "the copy record is what separates a branch from an ambiguity. Without it, several equally-supported candidates are `unresolved` - see `arbitration.multiplicity`." }, "R-MERGE-FOLD": { "outcome": "merged", + "cardinality": { + "shape": "N:1", + "min_predecessors": 2 + }, "requires_all": [ "merge_record" ], - "requires_per_predecessor": "a rule of outcome `continued`", + "requires_all_scope": "GROUP. The record's `from` list must carry every predecessor's enclosing symbol and its `to` must be the successor's.", + "partner_profile": { + "per": "predecessor", + "requires_all": [ + "same_pattern_id", + "anchored_content" + ], + "why": "the merge record relates the old structural sites to the new one; what each predecessor must show on its own is that it is the same defect, not that it is in a symbol the fold has just renamed out of existence" + }, "means": "several predecessors, one successor, and a RECORD explaining the fold" } }, @@ -104,7 +150,20 @@ "There is no rule licensing `ended` or `new`: those are earned by boundary", "evidence, which the outcome contract already governs. Adding a rule for them", "here would create a second, competing route to a death or a birth - the", - "exact defect that contract was corrected for." + "exact defect that contract was corrected for.", + "", + "FALSIFIED ONCE, BY ITS OWN FIXTURE STEP. The first draft gave the structural", + "rules a per-partner condition reading `a rule of outcome continued`. Walking", + "the frozen finding-lineage/v1 corpus rule by rule showed that condition is", + "unsatisfiable on the two cases those rules exist for: in the copy, occ-a1 and", + "occ-b2 share neither path nor enclosing symbol, and in the fold both", + "predecessors sit in symbols the fold replaced. Every continued rule requires", + "`structural_context`, so none applied, so R-BRANCH-COPY and R-MERGE-FOLD", + "could never license the outcomes the senior contract had already frozen.", + "The same walk found R-CONT-COPY missing, with a worse symptom than a gap.", + "This is what a preregistered corpus is FOR. The policy could not quietly", + "declare itself ready while failing to reproduce the cases it was written", + "against, and no fixture written after the fact would have argued with it." ], "signal_defeaters": { "structural_context": { @@ -182,8 +241,8 @@ ] }, "multiplicity": { - "rule": "a 1:1 outcome requires exactly ONE candidate satisfying the licensing rule", - "several_candidates_with_a_structural_record": "branched or merged, per R-BRANCH-COPY / R-MERGE-FOLD", + "rule": "a 1:1 outcome requires exactly ONE candidate satisfying the licensing rule. Rules that are not 1:1 declare so in `cardinality` and are guarded by it instead - see `cardinality_rule`.", + "several_candidates_with_a_structural_record": "branched or merged, per R-BRANCH-COPY / R-MERGE-FOLD, whose `cardinality` guards already require the multiplicity rather than tolerating it", "several_candidates_without_one": { "outcome": "unresolved", "reason": "lineage-id-unavailable:ambiguous-candidates" @@ -195,7 +254,11 @@ "is satisfied by BOTH candidates, and uniqueness turns that into a refusal", "instead of into whichever candidate was enumerated first.", "Uniqueness is checked per RULE, not per outcome: a discriminating rule may", - "single out a candidate while a blunter one does not." + "single out a candidate while a blunter one does not.", + "What this section no longer does is overturn a structural rule after the fact.", + "Cardinality is a precondition now, so a lone successor never licenses", + "`branched` in the first place; see `cardinality_rule` for why that is a", + "different thing from arriving at the same answer two sections later." ] }, "licensed_by_rule": [ @@ -213,7 +276,8 @@ "loses": [ "R-CONT-SAME-SITE", "R-CONT-DRIFT", - "R-CONT-RENAME" + "R-CONT-RENAME", + "R-CONT-COPY" ], "why": "a copy record EXPLAINS the multiplicity; a 1:1 rule reached its answer by not looking at the other successor. The more informed reading wins, and the losers stay in `applicable_rules` so the disagreement remains visible - not in `licensed_by`, which names only what the outcome rests on." }, @@ -222,13 +286,14 @@ "loses": [ "R-CONT-SAME-SITE", "R-CONT-DRIFT", - "R-CONT-RENAME" + "R-CONT-RENAME", + "R-CONT-COPY" ], "why": "the mirror case: a merge record explains why several predecessors reach one successor, and continuing just one of them would report the others as fixed." } ], "rule": [ - "These two pairs are NOT decoration. finding-lineage/v1 preregisters", + "These two winners are NOT decoration. finding-lineage/v1 preregisters", "`copy-branches-without-a-winner`, where the original site continues AND a copy", "exists: R-CONT-SAME-SITE and R-BRANCH-COPY both apply and disagree. That", "fixture forbids 'continued to exactly one of them', so the outcome contract", @@ -237,6 +302,19 @@ "An earlier draft of this file declared dominance empty 'on purpose'. It was", "checked against the step 0 matrix and was simply wrong: the conflict is", "constructible, and the answer was already frozen.", + "For a while the motive was true and the mechanism was not: the same draft's", + "per-successor condition made R-BRANCH-COPY unreachable, so the conflict this", + "section cited as its reason to exist could not actually arise. Explicit", + "partner profiles restore it - on that fixture occ-b1 really does satisfy", + "R-CONT-SAME-SITE while the successor SET satisfies R-BRANCH-COPY, so the", + "disagreement is real and the branch reading really is the more informed one.", + "R-CONT-COPY loses to both for the same reason as the other 1:1 readings: a", + "structural record read at group cardinality knows why the partners are several,", + "and a 1:1 rule reached its answer by not looking. The cardinality guards make", + "this particular pair hard to reach in practice - R-CONT-COPY wants exactly one", + "successor and R-BRANCH-COPY wants two - and it is classified anyway, which is", + "the price of a completeness law that covers unreachable pairs. That price buys", + "the absence of a conflict nobody looked at.", "Dominance is declared PAIRWISE and explicitly. There is no global ranking,", "no rule priority number, and no tie-break by name - all three would decide", "conflicts nobody has looked at yet." @@ -468,26 +546,87 @@ " acyclic digraph has a source. So the result exists and is unique, and", " order never enters the argument.", "", + "THE ARGUMENT DOES NOT MENTION THE NUMBER OF RULES. Read it again and check:", + "every step quantifies over a subset and a relation, and nothing anywhere says", + "five, or six, or twenty. That is why the check below is NOT an enumeration of", + "this policy's own classification space.", + "It used to be. All 3^7 = 2187 classifications of the five-rule set's seven", + "conflicting pairs were enumerated, and the run was honest but the design was", + "not: it charged an exponential in k for a theorem whose proof is independent", + "of k. Adding one ordinary rule took it to 3^9 x 63 = 1240029 arbitrations, so", + "the fail-closed budget fired - correctly - and turned `write a sixth rule`", + "into `revise the CI compute policy`. Raising the ceiling would have bought one", + "more rule and the same bill again after it.", + "", + "So the two jobs are separated:", + " - THIS POLICY is verified directly and completely: exact pair completeness,", + " dominance sanity, the full 2^n - 1 subset sweep of the real rule set,", + " absorbing refusals, and mutations proving each checker bites. That cost is", + " linear in the policy and it stays.", + " - THE THEOREM is falsified against a FIXED synthetic model - four rules", + " partitioned A, A, B, C, every classification of its pairs enumerated. Its", + " size does not move when a production rule is added, so adding one is a", + " contract edit and nothing else.", + "The model is chosen to contain the case the survivor reading turns on: two", + "rules of the SAME outcome surviving together, which is exactly what the old", + "`exactly one dominating rule` wording got wrong. A model without that shape", + "would be a cheaper check of a weaker claim.", + "", + "And it is called what it is: BOUNDED FALSIFICATION, not proof. The proof is the", + "paragraph above, written out and readable. The falsifier is a standing attempt", + "to break it, and a general argument that survives every classification of a", + "small model is not thereby proven - it is un-refuted at that size. The honest", + "claim is the weaker one, and it costs nothing to state it.", + "", "1 and 2 ARE independent of each other, shown rather than argued: dropping one", "pair classification fails 1 with 2 still green, and a three-cycle with every", - "pair classified fails 2 with 1 still green.", - "3 has no independent probe, and that is a result rather than a gap. All 3^7 =", - "2187 classifications of this rule set's seven conflicting pairs were", - "enumerated: 1705 satisfy 1 and 2, and none of those breaks totality.", + "pair classified fails 2 with 1 still green." + ], + "partner_profile_rule": [ + "WHY A PROFILE AND NOT A COUNT, AND NOT A BRIDGE.", "", - "The sweep is kept anyway. A proof holds for the contract as it stands, and the", - "sweep re-checks it after every future edit - including an edit that quietly", - "moves an assumption the proof leans on. It costs 2^n - 1 evaluations, computed", - "from the rule count rather than written down, so a sixth rule makes it 63", - "without anyone having to remember a sacred number.", + "Two repairs were available for the falsification above and both are refused", + "here, on the record, because both look reasonable from a distance.", "", - "The 3^k enumeration is FAIL-CLOSED on cost. A sixth rule multiplies both the", - "classification space and the subset sweep, and past a reviewed budget the", - "suite stops with `policy-space exceeded exhaustive-proof budget` rather than", - "printing a note and going green on the pair checks alone. A run that quietly", - "stopped proving the theorem while still reporting OK is worse than a red one:", - "the claim in this section would go on standing with nothing behind it. Raising", - "the budget is a reviewed edit, and so is replacing the enumeration with another", - "checked argument." + "NOT BRIDGES. A bridge would let `copy_record` be read AS `same_path`, or", + "`merge_record` AS `structural_context`, so the ordinary continued rules would", + "fire again. That is evidence laundering: a structural record explains why the", + "ordinary context CHANGED, and reading it as though the context did not change", + "throws away the only thing the record knows. The copy case makes the dishonesty", + "concrete - no bridge turns `DocView.Wire` into `DocCopyView.Wire` while telling", + "the truth, because the symbol really is a different one.", + "", + "NOT A BARE COUNT. `minimum_evidence_kinds_for_continued` is 2, and it would", + "have been easy to make the per-partner condition `at least two kinds`. But the", + "senior contract calls that number a FLOOR, not the algorithm, and it is right:", + "the floor forbids resting on one weak signal, it does not promise that any two", + "weak signals suffice. `same_path` plus `same_pattern_id` is two kinds and says", + "only that one file holds two occurrences of one pattern - which is the twin", + "shape the whole contract exists to refuse. Choosing WHICH combinations license", + "what is the job the senior contract left to this file, and answering it with a", + "cardinal number would be handing the job back.", + "", + "So each structural rule declares its partner profile EXPLICITLY, by naming the", + "kinds. `same_pattern_id` and `anchored_content`: the partner must be an", + "occurrence of the same pattern saying the same thing, which is what survives a", + "transformation that moved the file, the symbol, or both. The integrity suite", + "checks the profile clears the senior floor and that no kind in it is", + "`sufficient_alone` - it does NOT derive the profile, because a derived profile", + "would be the cardinal number again with extra steps." + ], + "cardinality_rule": [ + "Cardinality is a machine-readable PRECONDITION of the rule, not something", + "`arbitration.multiplicity` cleans up afterwards.", + "The difference is not stylistic. Left to arbitration, a single copy_record", + "would first license `branched` for one lonely successor and then have another", + "section of this file overturn it - two passages deciding one question, which", + "is a theological dispute between halves of a JSON document waiting to be", + "debugged at the worst possible moment. Declared as a guard, R-BRANCH-COPY", + "simply does not apply below two successors, and R-CONT-COPY - which does - is", + "the rule that answers.", + "`1:1` for every continued rule, `1:N` with `min_successors: 2` for the branch,", + "`N:1` with `min_predecessors: 2` for the fold. These mirror the shape rules", + "finding-lineage/v1 already enforces on its fixtures, which is the point: the", + "policy states them where a mapper reads them." ] } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 77f2717..38ae30b 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -50,9 +50,23 @@ Therefore the survivor set is non-empty and single-outcome, so the result exists and is unique. Order never enters the argument. -The sweep is kept anyway, because a proof holds for the contract as it is today -and the sweep re-checks it after every future edit - including edits that break -an assumption the proof leans on without anyone noticing which one. +WHAT IS SWEPT, AND WHAT IS NOT +------------------------------ +The real policy is verified directly and completely: properties 1 and 2 on its +own declarations, plus the full 2^n - 1 subset sweep of its own rule ids. That +cost is linear in the policy and it stays. + +The THEOREM is not re-derived from this policy's classification space. It used +to be - 3^k over the k conflicting pairs - and that was an exponential in k paid +for an argument whose every step quantifies over a subset and a relation and +never mentions k at all. One extra rule took it from 3^7 x 31 = 67797 to +3^9 x 63 = 1240029, so adding an ordinary rule became a CI compute decision. +Instead the theorem is falsified against a FIXED four-rule model (outcomes +partitioned A, A, B, C), sized by this file and not by the policy. The model is +chosen to CONTAIN the shape the survivor reading turns on: two rules of one +outcome surviving together. And it is named for what it is - bounded +falsification, not proof. The proof is the paragraph above; this is a standing +attempt to break it at a size where every classification can be tried. -O-safe (explicit raises, no bare assert). ASCII-only output. """ @@ -68,9 +82,10 @@ SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") -# Reviewed cost ceiling for the 3^k totality enumeration, in arbitrations. The -# current policy spends 2187 * 31 = 67797 of it. Exceeding it is a FAILURE, not a -# downgrade: see meta-check 5d. +# Reviewed cost ceiling for the theorem falsifier, in arbitrations. It bounds a +# FIXED four-rule model - 3^5 * 15 = 3645 - and not this policy's own +# classification space, so adding a production rule cannot move it. Exceeding it +# is a FAILURE, not a downgrade: see meta-check 5d. EXHAUSTIVE_PROOF_BUDGET = 250_000 fails: list[str] = [] @@ -245,6 +260,61 @@ def main() -> int: f"{rid} licenses continued on {len(rules[rid].get('requires_all', []))} " f"kind(s); the frozen floor is {floor}") + # Cardinality is a PRECONDITION of the rule, so it is checked as one. The + # alternative - letting `arbitration.multiplicity` overturn a structural + # rule after it fired - puts one question in two sections. + card = rules[rid].get("cardinality") + check(isinstance(card, dict) and "shape" in card, + f"{rid} declares no `cardinality`; a mapper would have to guess whether " + "the rule is 1:1, and guessing is how a lone successor gets `branched`") + if isinstance(card, dict): + shape = card.get("shape") + if outcomes[rid] == "continued": + check(shape == "1:1", f"{rid} licenses continued at cardinality {shape!r}; " + "continued is 1:1 in the senior contract") + elif outcomes[rid] == "branched": + check(shape == "1:N", f"{rid} licenses branched at {shape!r}") + check(card.get("min_successors", 0) >= 2, + f"{rid} licenses branched without requiring two successors; " + "a branch with one is a continued, and the senior contract says so") + elif outcomes[rid] == "merged": + check(shape == "N:1", f"{rid} licenses merged at {shape!r}") + check(card.get("min_predecessors", 0) >= 2, + f"{rid} licenses merged without requiring two predecessors") + + # A rule whose cardinality is not 1:1 says something about a GROUP, so it + # must also say what each partner shows on its own. `a rule of outcome + # continued` used to stand here and was unsatisfiable on the very frozen + # cases these rules exist for - see `rules_note`. + prof = rules[rid].get("partner_profile") + if isinstance(card, dict) and card.get("shape") != "1:1": + check(isinstance(prof, dict), f"{rid} is a group rule with no `partner_profile`") + if prof is not None: + check(isinstance(prof, dict) and prof.get("per") in ("successor", "predecessor"), + f"{rid}: partner_profile must say which side it is `per`") + req = prof.get("requires_all") if isinstance(prof, dict) else None + check(isinstance(req, list) and req, + f"{rid}: partner_profile must NAME the kinds; the contract refuses to " + "derive them, because a derived profile is the floor's cardinal number " + "with extra steps") + if isinstance(req, list): + for kind in req: + check(kind in senior["evidence_kinds"], + f"{rid}: partner_profile names {kind!r}, not a frozen evidence kind") + check(not senior["evidence_kinds"].get(kind, {}).get("sufficient_alone"), + f"{rid}: partner_profile rests on {kind!r}, which is sufficient " + "alone; a profile of one strong signal is not a profile") + check(len(set(req)) == len(req), f"{rid}: partner_profile repeats a kind") + check(len(set(req)) >= floor, + f"{rid}: partner_profile names {len(set(req))} kind(s); the frozen " + f"floor is {floor}. The floor is checked, never used to generate.") + # The abandoned wording must not creep back in under its old names. + for dead in ("requires_per_successor", "requires_per_predecessor"): + check(dead not in rules[rid], + f"{rid} still carries {dead!r}. That condition was falsified against the " + "frozen corpus: every continued rule needs `structural_context`, and the " + "copy and fold cases change the enclosing symbol.") + # ---- 1 and 2, via the pure functions the meta-checks also exercise. ----- conflicting = {frozenset(p) for p in itertools.combinations(ids, 2) if outcomes[p[0]] != outcomes[p[1]]} @@ -371,61 +441,68 @@ def main() -> int: raw_refusals + raw_refusals[:1])), "a refusal declared twice was absorbed by a set and passed") - # 5d. THE THEOREM, checked exhaustively over the whole policy space. - # Three states per conflicting pair: a>b, b>a, refusal. Every classification - # satisfying properties 1 and 2 must satisfy totality - if one did not, the - # theorem would be false and the contract would be claiming a proof it does - # not have. - # The budget is FAIL-CLOSED. Growing the rule set past it takes the suite red - # with a message naming the choice, because the alternative - printing a note, - # skipping the enumeration and still reporting OK - leaves the contract's proof - # claim standing with nothing behind it, and nobody reads a green run's notes. - k = len(conflicting) - space = 3 ** k - cost = space * (2 ** len(ids) - 1) - if cost > EXHAUSTIVE_PROOF_BUDGET: - check(False, - f"policy-space exceeded exhaustive-proof budget: 3^{k} = {space} " - f"classifications x {2 ** len(ids) - 1} subsets = {cost} arbitrations, " - f"over the reviewed budget of {EXHAUSTIVE_PROOF_BUDGET}. Either raise " - "the reviewed budget or replace the finite enumeration with another " - "checked argument. Do not skip it: `what_the_subset_sweep_proves` " - "claims totality is proven, and this is where that is paid for.") - else: - pair_list = [tuple(sorted(pr)) for pr in sorted(conflicting, key=sorted)] - satisfying, counterexamples = 0, [] - for combo in itertools.product((0, 1, 2), repeat=k): - e, rf = [], [] - for (x, y), state in zip(pair_list, combo): - if state == 0: - e.append((x, y)) - elif state == 1: - e.append((y, x)) - else: - rf.append(frozenset((x, y))) - if pair_completeness_failures(ids, outcomes, e, rf): - continue - if dominance_sanity_failures(ids, outcomes, e): - continue - satisfying += 1 - es, rs = set(e), set(rf) - for n in range(1, len(ids) + 1): - for sub in itertools.combinations(ids, n): - if arbitrate(sub, outcomes, es, rs) is None: - counterexamples.append((combo, sub)) - break - if counterexamples: - break - check(satisfying > 0, - f"no classification of the {k} conflicting pairs satisfied both axioms; " - "the enumeration is not exercising anything") - check(not counterexamples, - f"totality is NOT a theorem of the two axioms: {len(counterexamples)} " - f"counterexample(s), first {counterexamples[:1]}. The contract claims a " - "proof it does not have.") - print(f" theorem: {satisfying} of {space} classifications satisfy both " - f"axioms, 0 break totality ({cost} arbitrations, budget " - f"{EXHAUSTIVE_PROOF_BUDGET})") + # 5d. THE THEOREM, falsified against a FIXED model. + # Not an enumeration of this policy's classification space. That is what used + # to be here, and it was an exponential in k paid for a theorem whose proof + # never mentions k: adding one ordinary rule took 3^7 x 31 = 67797 to + # 3^9 x 63 = 1240029 and fired the budget, turning `write a sixth rule` into + # `revise the CI compute policy`. + # The real policy is verified directly and completely above. What is enumerated + # here is a four-rule model whose outcomes partition A, A, B, C - chosen because + # it CONTAINS the case the survivor reading turns on, two rules of one outcome + # surviving together. Its cost is a constant of this file. + model_outcomes = {"M-A1": "A", "M-A2": "A", "M-B": "B", "M-C": "C"} + model_ids = sorted(model_outcomes) + model_pairs = [tuple(sorted(pr)) for pr in + sorted(({frozenset(x) for x in itertools.combinations(model_ids, 2) + if model_outcomes[x[0]] != model_outcomes[x[1]]}), key=sorted)] + mk = len(model_pairs) + model_space = 3 ** mk + model_cost = model_space * (2 ** len(model_ids) - 1) + check(model_cost <= EXHAUSTIVE_PROOF_BUDGET, + f"the theorem model costs {model_cost} arbitrations, over the reviewed budget " + f"of {EXHAUSTIVE_PROOF_BUDGET}. This model is a constant of this file, so " + "exceeding it means the model was edited, not that the policy grew.") + satisfying, counterexamples, saw_multi_survivor = 0, [], False + for combo in itertools.product((0, 1, 2), repeat=mk): + e, rf = [], [] + for (x, y), state in zip(model_pairs, combo): + if state == 0: + e.append((x, y)) + elif state == 1: + e.append((y, x)) + else: + rf.append(frozenset((x, y))) + if pair_completeness_failures(model_ids, model_outcomes, e, rf): + continue + if dominance_sanity_failures(model_ids, model_outcomes, e): + continue + satisfying += 1 + es, rs = set(e), set(rf) + for n in range(1, len(model_ids) + 1): + for sub in itertools.combinations(model_ids, n): + res = arbitrate(sub, model_outcomes, es, rs) + if res is None: + counterexamples.append((combo, sub)) + elif len({model_outcomes[r] for r in sub}) > 1 and len(res[1]) > 1: + saw_multi_survivor = True + check(satisfying > 0, + f"no classification of the model's {mk} pairs satisfied both axioms; the " + "falsifier is not exercising anything") + check(not counterexamples, + f"totality is NOT a theorem of the two axioms: {len(counterexamples)} " + f"counterexample(s), first {counterexamples[:1]}. The contract claims a " + "proof it does not have.") + # The model has to contain the interesting shape, or it is a cheaper check of + # a weaker claim wearing the same name. + check(saw_multi_survivor, + "no classification of the model produced a disagreeing subset whose SURVIVORS " + "were several rules of one outcome. That shape is the whole reason this model " + "has two rules of outcome A, and without it the falsifier would pass for a " + "policy using the abandoned `exactly one dominating rule` reading.") + print(f" theorem: bounded falsification on a fixed 4-rule model - {satisfying} " + f"of {model_space} classifications satisfy both axioms, 0 break totality " + f"({model_cost} arbitrations, independent of the {len(ids)} production rules)") for f in fails: print(f"FAIL: {f}") From 2f97dcecd887a3fa1664d02f410a2edb1b6c6400 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:19:44 +0000 Subject: [PATCH 10/84] test(identity): preregister the decision-policy fixture matrix Seven adversarial cases in identity/fixtures/lineage-decision/, each a shape where two answers are available and the policy has to say which, or say neither. Fixed before any mapper, and gated on exact equality with `preregistered_cases` - the same gate step 0 uses, and the one that has already paid for itself by proving the structural rules could not license their own frozen outcomes. copy-record-dominates-the-single-match branched over a real 1:1 match merge-record-dominates-the-single-match merged over a real 1:1 match copy-at-one-to-one-is-not-a-branch the same record, one successor defeated-signal-drops-below-the-floor a formatter removes the content renamed-symbol-defeats-structural-context a reused symbol name unavailable-diff-is-not-a-deletion a signal never evaluated blunt-rule-loses-to-uniqueness per-rule candidate uniqueness SEVEN, not six. `copy-at-one-to-one-is-not-a-branch` was added because nothing showed one copy record at 1:1, where the cardinality guard makes it explain a move rather than a multiplicity. One record, two readings, cardinality choosing between them - a boundary worth its own case rather than a sentence in someone else's. It is declared in the contract, so the equality gate still holds. WHAT THE SUITE CHECKS, AND WHAT IT REFUSES TO It never decides which rules a real evidence record produces. That is applicability, and a checker doing it becomes a second mapper. What it checks: - arbitration agreement: applicability is the fixture's to declare, but arbitration is mechanical, so a case may not disagree with the algebra. Its declared outcome and licensed_by must equal arbitrate(applicable_rules). - cardinality: the licensed shape must match the licensing rule's guard. - carried records: a claimed defeat must be in revision B AND name this occurrence, read through the catalog's own entry_shape. A defeat asserted in a note is a note. - a defeated or unevaluable signal may not be required by an applicable rule. - rule accounting: every rule named - applicable, not applicable with a reason, or recorded as unable to choose. Nothing dropped in silence, the occurrence-accounting rule applied to rules. Three defects in my own matrix were caught by these before the commit: a defeater read as a bare string list when its entries are objects, and two rules the coverage check demanded a happy-path case for when the senior corpus already constrains them. CASE OBLIGATIONS One vacuity probe passed when it should not have: moving the losing rule out of `applicable_rules` left the copy case reporting `branched` licensed by R-BRANCH-COPY - correct, and showing dominance doing no work whatever. The suite cannot catch that alone, so `case_obligations` preregisters what each case must MECHANICALLY exhibit - dominance resolving a real conflict, a defeat removing a signal, an input recorded unavailable, a blunt rule recorded as unable to choose, a rule excluded by its own cardinality guard - and the check runs against the fixture's declarations. Both dominance cases now fail if their loser is quietly reclassified. Contract additions the fixtures forced, all in senior vocabulary: - decision_detail.rules_without_a_unique_candidate, so a blunt rule losing to a discriminating one leaves a trace instead of vanishing. - reason_mapping for a defeat: `insufficient-evidence-kind`, not `no-mapping-evidence`. Evidence was observed and explained away, and in defeated-signal-drops-below-the-floor three kinds survive against a floor of two - the sharpest demonstration available that the floor is not the algorithm. - unavailable_inputs.observable_from, so unavailability is a record. 23 vacuity probes bite, covering every fixture check and every obligation. One earlier probe reported VACUOUS from a broken harness - the file path never reached the mutating script - and one reported a real check as vacuous because the probe itself was wrong; both were re-run. All 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 92 ++++- .../blunt-rule-loses-to-uniqueness.json | 92 +++++ .../copy-at-one-to-one-is-not-a-branch.json | 81 +++++ ...opy-record-dominates-the-single-match.json | 82 +++++ ...defeated-signal-drops-below-the-floor.json | 84 +++++ ...rge-record-dominates-the-single-match.json | 89 +++++ ...med-symbol-defeats-structural-context.json | 87 +++++ .../unavailable-diff-is-not-a-deletion.json | 85 +++++ identity/tests/test_lineage_decision.py | 336 +++++++++++++++++- 9 files changed, 1020 insertions(+), 8 deletions(-) create mode 100644 identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json create mode 100644 identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json create mode 100644 identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json create mode 100644 identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json create mode 100644 identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json create mode 100644 identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json create mode 100644 identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 4f180b7..f948e23 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -198,7 +198,8 @@ "cannot apply - and an occurrence that really did move with a renamed file", "comes out `unresolved`, naming the missing input, instead of `ended` plus", "`new`. Slower, and true." - ] + ], + "observable_from": "revision_b.unavailable_signals" }, "arbitration": { "none_applies": { @@ -323,22 +324,36 @@ "record_additions": { "applicable_rules": "the DISTINCT ids of rules having at least one surviving application. A set of ids, not a count of applications: one rule may fire on several candidate edges, and this field does not carry that multiplicity. The bindings live in `evidence` and `decision_detail`.", "licensed_by": "the SURVIVING rules the outcome rests on - the applicable rules that no other applicable rule dominates, per `arbitration.licensed_by_rule`. Non-empty for continued, branched and merged; EMPTY on a refused conflict.", - "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: conflicting_rules, inputs_unavailable, defeated_signals.", + "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: conflicting_rules, inputs_unavailable, defeated_signals, and rules_without_a_unique_candidate - the rules that fired on several candidates and so licensed no 1:1 mapping. That last one is not cosmetic: without it a blunt rule losing to a discriminating one leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither; it failed to single anyone out.", "signals_defeated": "signal name -> the record that defeated it. Present whenever step 2 removed anything.", "inputs_unavailable": "signal names that could not be evaluated. Present whenever any were." }, "preregistered_cases": [ "copy-record-dominates-the-single-match", "merge-record-dominates-the-single-match", + "copy-at-one-to-one-is-not-a-branch", "defeated-signal-drops-below-the-floor", "renamed-symbol-defeats-structural-context", "unavailable-diff-is-not-a-deletion", "blunt-rule-loses-to-uniqueness" ], "preregistration_rule": [ - "Fixed before the mapper, same as slice 2 step 0. These six are about the", - "POLICY rather than the vocabulary: each is a shape where two answers are", - "available and the contract has to say which, or say neither." + "Fixed before the mapper, same as slice 2 step 0. These are about the POLICY", + "rather than the vocabulary: each is a shape where two answers are available", + "and the contract has to say which, or say neither.", + "The matrix on disk must equal this list EXACTLY - the same gate step 0 uses.", + "It has already paid for itself once: trying to write the first two cases is", + "what proved the structural rules could not license the outcomes the senior", + "contract had frozen. A half-matrix of the cases that happened to pass would", + "have hidden that, which is the whole reason partial credit is not offered.", + "SEVEN, not the six first listed. `copy-at-one-to-one-is-not-a-branch` was", + "added during review: `copy-record-dominates-the-single-match` shows a copy", + "record licensing a branch, and nothing showed the same record at 1:1, where", + "the cardinality guard means it explains a MOVE instead. One record, two", + "readings, and cardinality is what chooses - a boundary worth a case of its", + "own rather than a sentence in someone else's. A case may be ADDED with its", + "entry here; none may be removed or renamed to make an implementation look", + "better." ], "conflict_completeness_rule": [ "PAIR COMPLETENESS. Every pair of DECLARED rule ids naming a different outcome", @@ -414,6 +429,23 @@ "reason": "lineage-id-unavailable:no-mapping-evidence", "detail": "inputs_unavailable, when the reason nothing applied is that a signal could not be evaluated" }, + "no_rule_applied_after_a_defeat": { + "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "detail": "defeated_signals: signal -> the record that removed it", + "why": [ + "Distinguished from `no-mapping-evidence` because the two are not the same", + "situation and a consumer reads this field. When a defeater fires, evidence", + "WAS observed - it was removed because a record explained it away - and", + "saying `no mapping evidence` would be false in the plain meaning of the", + "words.", + "`insufficient-evidence-kind` is already in the senior vocabulary and was", + "unused. This is the case it was for: the KINDS that survive do not reach", + "what any rule requires. Note that they may still clear the senior floor -", + "in `defeated-signal-drops-below-the-floor` three kinds survive against a", + "floor of two - which is the sharpest demonstration available that the", + "floor is not the algorithm." + ] + }, "conflicting_rules": { "reason": "lineage-id-unavailable:conflicting-evidence", "detail": "conflicting_rules: the ids that disagreed" @@ -628,5 +660,53 @@ "`N:1` with `min_predecessors: 2` for the fold. These mirror the shape rules", "finding-lineage/v1 already enforces on its fixtures, which is the point: the", "policy states them where a mapper reads them." - ] + ], + "case_obligations": { + "why": [ + "What each case must MECHANICALLY exhibit, beyond reaching the right answer.", + "Without this a case can be right for the wrong reason and stay green: drop", + "R-CONT-SAME-SITE out of `copy-record-dominates-the-single-match` and into", + "`not_applicable`, and the fixture still reports `branched` licensed by", + "R-BRANCH-COPY - correct, and no longer showing dominance doing any work at", + "all. The suite cannot catch that on its own, because deciding which rules", + "really fired is applicability, and a checker doing applicability is a second", + "mapper. So the obligation is PREREGISTERED here instead, and checked against", + "the fixture's own declarations." + ], + "obligations": { + "copy-record-dominates-the-single-match": [ + "dominance_did_work" + ], + "merge-record-dominates-the-single-match": [ + "dominance_did_work" + ], + "copy-at-one-to-one-is-not-a-branch": [ + "cardinality_excluded_a_rule" + ], + "defeated-signal-drops-below-the-floor": [ + "defeat_removed_a_signal" + ], + "renamed-symbol-defeats-structural-context": [ + "defeat_removed_a_signal" + ], + "unavailable-diff-is-not-a-deletion": [ + "input_was_unavailable" + ], + "blunt-rule-loses-to-uniqueness": [ + "blunt_rule_recorded" + ] + }, + "meanings": { + "dominance_did_work": "some rule in `applicable_rules` is absent from `licensed_by` AND is dominated by a rule that is in it. The conflict is real and arbitration resolved it, rather than there having been one candidate all along.", + "cardinality_excluded_a_rule": "the case names `excluded_by_cardinality`, and each rule there is genuinely unable to fit the declared shape - checked against its own `min_successors` / `min_predecessors`, not against prose.", + "defeat_removed_a_signal": "`signals_defeated` is non-empty, the defeating record is carried by revision B, and it names this occurrence.", + "input_was_unavailable": "`inputs_unavailable` is non-empty and revision B declares the same signal in `unavailable_signals`.", + "blunt_rule_recorded": "`decision_detail.rules_without_a_unique_candidate` is non-empty. The blunt rule must be visible as having failed to choose, not merely absent." + }, + "rule": [ + "Every preregistered case must appear here, and every obligation must be one", + "of the meanings above. A case added without an obligation is a case nobody", + "has said what to expect of, which is how a matrix drifts into decoration." + ] + } } diff --git a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json new file mode 100644 index 0000000..913d889 --- /dev/null +++ b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json @@ -0,0 +1,92 @@ +{ + "case": "blunt-rule-loses-to-uniqueness", + "title": "Two candidates for a blunt rule, one for a discriminating one", + "why": "ADVERSARIAL about uniqueness being per RULE rather than per outcome. Both occurrences in revision B sit in the same file and the same symbol as the predecessor, so R-CONT-DRIFT - which does not ask about content - is satisfied by BOTH and singles out nobody. R-CONT-SAME-SITE does ask, and only occ-b1 answers. If uniqueness were checked per outcome the pair would collapse into ambiguity and a real continuation would be thrown away; checked per rule, the discriminating rule licenses and the blunt one is recorded as having failed to choose. That record is the point: a blunt rule that simply vanishes leaves the next reader unable to see that anything was in tension.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 44, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-b2", + "path": "Broker/Doc.cs", + "start_line": 51, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnReset;" + } + ] + }, + "expect": [ + { + "outcome": "continued", + "frm": "occ-a1", + "to": [ + "occ-b1" + ], + "applicable_rules": [ + "R-CONT-SAME-SITE" + ], + "licensed_by": [ + "R-CONT-SAME-SITE" + ], + "decision_detail": { + "rules_without_a_unique_candidate": [ + "R-CONT-DRIFT" + ] + }, + "not_applicable": { + "R-CONT-DRIFT": "satisfied by occ-b1 AND occ-b2; a 1:1 outcome needs exactly one candidate, so it licenses nothing and is recorded in rules_without_a_unique_candidate", + "R-CONT-RENAME": "no rename record", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record - two candidates without a record explaining them are an ambiguity, not a branch", + "R-MERGE-FOLD": "no merge record" + }, + "note": "R-CONT-DRIFT is NOT in applicable_rules: it had no surviving application, because neither of its two candidate edges survives the uniqueness requirement. It is not silently dropped either - decision_detail says it fired and could not choose." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": [], + "to": [ + "occ-b2" + ], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "note": "occ-b2 is the other candidate, and nothing links it back to occ-a1 once content is asked for. It is not `new`: no record says its site arrived." + } + ], + "forbid": [ + "unresolved for occ-a1 because R-CONT-DRIFT saw two candidates", + "ambiguous-candidates as the reason for occ-a1 - the discriminating rule chose", + "R-CONT-DRIFT named in licensed_by", + "R-CONT-DRIFT in applicable_rules - it had no surviving application", + "R-CONT-DRIFT dropped without a trace - the tension must stay readable", + "occ-a1 mapped to occ-b2" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json new file mode 100644 index 0000000..ba22cc6 --- /dev/null +++ b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json @@ -0,0 +1,81 @@ +{ + "case": "copy-at-one-to-one-is-not-a-branch", + "title": "The same copy record, at one successor", + "why": "One record, two readings, and cardinality is what chooses between them. Here Broker/Doc.cs is deleted and its contents live on at Broker/Moved.cs: the copy record explains a MOVE, not a multiplicity, so R-BRANCH-COPY does not apply at all rather than applying and being overturned later. The second half is the boundary: deleted_paths would earn `ended` for occ-a1 on its own, and the copy record defeats it, because the same deletion appearing as a copy source is one story told twice. finding-lineage/v1 freezes this scenario as continued and forbids even unresolved.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "deleted_paths": [ + "Broker/Doc.cs" + ], + "copies": [ + { + "from": "Broker/Doc.cs", + "to": "Broker/Moved.cs", + "similarity": 100 + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Moved.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "continued", + "frm": "occ-a1", + "to": [ + "occ-b1" + ], + "applicable_rules": [ + "R-CONT-COPY" + ], + "licensed_by": [ + "R-CONT-COPY" + ], + "excluded_by_cardinality": [ + "R-BRANCH-COPY" + ], + "boundary_defeated": { + "boundary:containing-file-deleted": "revision_b.copies[].from" + }, + "not_applicable": { + "R-BRANCH-COPY": "cardinality 1:N with min_successors 2, and there is exactly one successor. This is a PRECONDITION, not something arbitration repairs afterwards.", + "R-CONT-SAME-SITE": "the path changed", + "R-CONT-DRIFT": "the path changed, so same_path did not fire", + "R-CONT-RENAME": "the record is a copy, not a rename; path_rename did not fire", + "R-MERGE-FOLD": "no merge record" + }, + "note": "R-CONT-COPY is the rule whose absence made this scenario a contradiction rather than a gap: before it existed the only rule that fired here was R-BRANCH-COPY, naming an outcome the senior contract had already ruled out." + } + ], + "forbid": [ + "branched from a single successor", + "ended for occ-a1 on the strength of deleted_paths - the copy record defeats it", + "unresolved - the frozen case forbids it by name", + "new for occ-b1", + "R-BRANCH-COPY appearing in applicable_rules - it is guarded out, not out-argued" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json b/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json new file mode 100644 index 0000000..a68b92f --- /dev/null +++ b/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json @@ -0,0 +1,82 @@ +{ + "case": "copy-record-dominates-the-single-match", + "title": "A copy record and a perfectly good 1:1 match, disagreeing", + "why": "The conflict the dominance table exists for, made mechanical. occ-b1 really does satisfy R-CONT-SAME-SITE - same file, same symbol, same content - so a mapper reading one edge at a time reaches `continued` honestly and drops occ-b2 on the floor. R-BRANCH-COPY reads the successor SET and the record that explains it. Both fire; the case pins that both must be RECORDED as firing and that the branch reading wins. A fixture that showed only the right outcome would pass while dominance did no work at all.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "copies": [ + { + "from": "Broker/Doc.cs", + "to": "Broker/DocCopy.cs", + "similarity": 100 + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-b2", + "path": "Broker/DocCopy.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocCopyView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "branched", + "frm": "occ-a1", + "to": [ + "occ-b1", + "occ-b2" + ], + "applicable_rules": [ + "R-BRANCH-COPY", + "R-CONT-SAME-SITE" + ], + "licensed_by": [ + "R-BRANCH-COPY" + ], + "not_applicable": { + "R-CONT-DRIFT": "occ-b1 is at the same line; no line_drift fired", + "R-CONT-RENAME": "there is no rename record - a copy is not a rename", + "R-CONT-COPY": "cardinality 1:1, and the copy record explains a second successor here", + "R-MERGE-FOLD": "no merge record, and the shape is 1:N not N:1" + }, + "note": "R-CONT-SAME-SITE stays in applicable_rules and out of licensed_by. It proposed `continued` to occ-b1 and lost; recording it as licensing `branched` would attribute a conclusion to a rule that never reached it, and dropping it would lose the recall set - retiring R-BRANCH-COPY later must find exactly this mapping." + } + ], + "forbid": [ + "continued to occ-b1 with occ-b2 dropped", + "R-CONT-SAME-SITE named in licensed_by", + "R-CONT-SAME-SITE missing from applicable_rules - the disagreement IS the record", + "unresolved - this pair is classified in dominance, not refused", + "an empty licensed_by on an outcome that is not a refusal" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json new file mode 100644 index 0000000..e000db0 --- /dev/null +++ b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json @@ -0,0 +1,84 @@ +{ + "case": "defeated-signal-drops-below-the-floor", + "title": "A reformatting pass removes the only signal the rule had left", + "why": "ADVERSARIAL about the FLOOR, not about the signals. Everything matches: same file, same symbol, same line, same text. But the file went through a formatter, so content equality is a property of the formatter rather than of the occurrence, and the defeater removes it before any rule is applied. What survives - same_path, same_pattern_id, structural_context - is THREE kinds against a senior floor of two, and still no rule is satisfied, because no rule asks for that combination. That is the clearest statement available of why the floor is a floor and not the algorithm. The reason is `insufficient-evidence-kind`, not `no-mapping-evidence`: evidence was observed and then explained away, and saying none was seen would be false.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "reformatted_paths": [ + "Broker/Doc.cs" + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a1" + ], + "to": [], + "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "applicable_rules": [], + "licensed_by": [], + "signals_defeated": { + "anchored_content": "reformatted_paths" + }, + "not_applicable": { + "R-CONT-SAME-SITE": "anchored_content was defeated, and the rule requires it", + "R-CONT-DRIFT": "line_drift did not fire - the occurrence is at the same line", + "R-CONT-RENAME": "no rename record", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record", + "R-MERGE-FOLD": "no merge record" + }, + "note": "a defeated signal is REMOVED, never down-weighted. R-CONT-SAME-SITE does not fire weakly; it does not fire." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": [], + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "applicable_rules": [], + "licensed_by": [], + "signals_defeated": { + "anchored_content": "reformatted_paths" + }, + "note": "the mirror side. Nothing about revision B says this occurrence is new, and no rule links it back." + } + ], + "forbid": [ + "continued on same_path plus structural_context - three surviving kinds clear the senior floor and satisfy no rule", + "anchored_content counted at a reduced weight - the contract has no weights", + "ended for occ-a1 or new for occ-b1 - neither carries boundary evidence", + "no-mapping-evidence as the reason - evidence was observed and then defeated" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json b/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json new file mode 100644 index 0000000..9d0e302 --- /dev/null +++ b/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json @@ -0,0 +1,89 @@ +{ + "case": "merge-record-dominates-the-single-match", + "title": "A fold into a surviving symbol, and the one predecessor that still matches it", + "why": "The mirror, and deliberately shaped so dominance has work to do. The fold keeps the name DocView.WireA, so occ-a1 matches the successor on path, symbol and content and R-CONT-SAME-SITE fires on that edge alone. occ-a2 does not - its symbol is gone. A mapper that stops there continues occ-a1 and then reads removed_symbols as proof that occ-a2 was fixed, reporting a repair that never happened. The merge record knows better, and it must be recorded as the rule that won rather than as the only rule there was.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.WireA", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-a2", + "path": "Broker/Doc.cs", + "start_line": 88, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.WireB", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "removed_symbols": [ + "DocView.WireB" + ], + "merged_symbols": [ + { + "from": [ + "DocView.WireA", + "DocView.WireB" + ], + "to": "DocView.WireA" + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.WireA", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "merged", + "frm": [ + "occ-a1", + "occ-a2" + ], + "to": [ + "occ-b1" + ], + "applicable_rules": [ + "R-CONT-SAME-SITE", + "R-MERGE-FOLD" + ], + "licensed_by": [ + "R-MERGE-FOLD" + ], + "not_applicable": { + "R-CONT-DRIFT": "occ-a1 and occ-b1 share a line; no drift fired", + "R-CONT-RENAME": "no rename record", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record, and the shape is N:1" + }, + "note": "the partner profile is what makes R-MERGE-FOLD reach occ-a2 at all: same_pattern_id and anchored_content hold across the fold, while structural_context does not and must not be faked into holding. occ-a2 is a predecessor of the merge, NOT an `ended` - removed_symbols says its site is gone and merged_symbols says where it went, and the boundary contract already ranks the second above the first." + } + ], + "forbid": [ + "continued from occ-a1 with occ-a2 reported as ended", + "R-CONT-SAME-SITE named in licensed_by", + "R-CONT-SAME-SITE missing from applicable_rules", + "occ-a2 dropped from the merge because its enclosing symbol no longer exists", + "unresolved - this pair is classified in dominance" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json new file mode 100644 index 0000000..0d9603e --- /dev/null +++ b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json @@ -0,0 +1,87 @@ +{ + "case": "renamed-symbol-defeats-structural-context", + "title": "The enclosing symbol matches by name, and the name was reused", + "why": "ADVERSARIAL about a coincidence that looks exactly like continuity. DocView.Wire was renamed to DocView.Connect, and something else in the file is now called DocView.Wire. A mapper comparing symbol names sees a match and continues into a site that has nothing to do with the predecessor. The rename record is what makes the match a coincidence: it says where the old site went, so a name agreeing across it agrees by accident. Every continued rule requires structural_context, so removing it leaves nothing standing - which is the correct outcome and a slow one.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "renamed_symbols": [ + { + "from": "DocView.Wire", + "to": "DocView.Connect" + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 47, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a1" + ], + "to": [], + "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "applicable_rules": [], + "licensed_by": [], + "signals_defeated": { + "structural_context": "renamed_symbol_record" + }, + "not_applicable": { + "R-CONT-SAME-SITE": "structural_context was defeated", + "R-CONT-DRIFT": "structural_context was defeated; line_drift alone carries nothing", + "R-CONT-RENAME": "structural_context was defeated, and there is no path rename either", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record", + "R-MERGE-FOLD": "no merge record" + }, + "note": "same_path, same_pattern_id, anchored_content and line_drift all survive. They are not enough, and no combination of them is a rule - which is the point of naming combinations instead of counting them." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": [], + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "applicable_rules": [], + "licensed_by": [], + "signals_defeated": { + "structural_context": "renamed_symbol_record" + }, + "note": "occ-b1 is not `new` either: nothing records its site as added, and a birth is earned rather than left over." + } + ], + "forbid": [ + "continued into the reused name", + "structural_context counted because the strings are equal", + "new for occ-b1 - no boundary evidence names its site as added", + "ended for occ-a1 - the symbol was renamed, not removed" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json b/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json new file mode 100644 index 0000000..e1ce637 --- /dev/null +++ b/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json @@ -0,0 +1,85 @@ +{ + "case": "unavailable-diff-is-not-a-deletion", + "title": "No diff was available, and the file moved", + "why": "The absence-of-record doctrine at the decision layer. The occurrence really did move with a renamed file, but the rename cannot be seen because path_rename could not be EVALUATED - no diff. A signal that was not evaluated is not a signal that failed, and the difference decides the answer: treating the missing diff as `the file did not move` produces a confident `ended` here plus a confident `new` there, which is a fabricated death and a fabricated birth from one missing input. Unresolved naming the missing input is slower and true.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Old.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "unavailable_signals": [ + "path_rename" + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/New.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a1" + ], + "to": [], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "inputs_unavailable": [ + "path_rename" + ], + "not_applicable": { + "R-CONT-SAME-SITE": "the paths differ", + "R-CONT-DRIFT": "the paths differ, so same_path did not fire", + "R-CONT-RENAME": "path_rename could not be evaluated; the rule does not apply, and it did not fail", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record", + "R-MERGE-FOLD": "no merge record" + }, + "note": "the reason stays `no-mapping-evidence` rather than `insufficient-evidence-kind`: nothing was observed and removed here, the input was never readable. `inputs_unavailable` carries which one, so the record says why the search was blind instead of implying the tree was clean." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": [], + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "inputs_unavailable": [ + "path_rename" + ], + "note": "the mirror. Broker/New.cs is not recorded as added, so nothing earns a birth." + } + ], + "forbid": [ + "ended for occ-a1", + "new for occ-b1", + "the missing diff read as evidence that the file did not move", + "path_rename recorded as evaluated-and-false", + "insufficient-evidence-kind as the reason - nothing was defeated here" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 38ae30b..0224a4e 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -81,6 +81,7 @@ SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") +FIXDIR = os.path.join(ROOT, "identity", "fixtures", "lineage-decision") # Reviewed cost ceiling for the theorem falsifier, in arbitrations. It bounds a # FIXED four-rule model - 3^5 * 15 = 3645 - and not this policy's own @@ -101,6 +102,46 @@ def load(path: str) -> dict: return json.load(fh) +def as_list(v) -> list: + if v is None: + return [] + return list(v) if isinstance(v, list) else [v] + + +def collect(rev: dict, dotted: str) -> list: + """Read `revision_b.field[].sub` out of a fixture. Lifted from the step 0 + suite deliberately: `the fixture must CARRY the fact` is the same rule here, + and a second, subtly different reader would be a second rule.""" + head, _, rest = dotted.partition(".") + if head != "revision_b": + return [] + field, _, sub = rest.partition("[].") + raw = rev.get(field, []) + if not sub: + return [x for x in raw if isinstance(x, str)] + out = [] + for entry in raw: + if isinstance(entry, dict) and sub in entry: + val = entry[sub] + out.extend(val if isinstance(val, list) else [val]) + return out + + +def read_signal(rev: dict, spec: dict) -> list: + """Read the values a structural signal matches on, following the catalog's own + `entry_shape`. `observable_from` names the FIELD; whether the entries are bare + strings or objects is `entry_shape`'s to say, and guessing instead is how a + checker silently reads an empty list and reports a record as present.""" + field = str(spec.get("observable_from", "")) + shape = spec.get("entry_shape") + if isinstance(shape, dict): + out = [] + for key in shape: + out.extend(collect(rev, f"{field}[].{key}")) + return out + return collect(rev, field) + + def raw_dominance_edges(policy: dict) -> list: """Occurrences, NOT a set. `classified exactly once` is a claim about the declarations, and a set answers a weaker question - it would let @@ -378,6 +419,296 @@ def main() -> int: "nor a catalogued structural signal") + # ---- 4b. THE PREREGISTERED MATRIX. -------------------------------------- + # Not a mapper test. This suite never decides which rules a real evidence + # record produces - that is applicability, and a checker doing it becomes a + # second mapper. What it checks is that each case's DECLARED rule set agrees + # with the arbitration algebra, that every id and reason exists, and that a + # claimed record is actually carried by the fixture rather than by prose. + preregistered = policy["preregistered_cases"] + on_disk = sorted(f[:-5] for f in os.listdir(FIXDIR) if f.endswith(".json")) + check(on_disk == sorted(preregistered), + "the decision fixture matrix drifted from the preregistered list.\n" + f" on disk: {on_disk}\n" + f" preregistered: {sorted(preregistered)}\n" + " A case may be ADDED with its contract entry. None may be removed or " + "renamed to make an implementation look better - and trying to write the " + "first two is what falsified the rule set, so partial credit is not on offer.") + + senior_limitations_set = set(senior["limitations"].values()) + obligations = policy["case_obligations"]["obligations"] + obligation_meanings = policy["case_obligations"]["meanings"] + check(sorted(obligations) == sorted(preregistered), + "`case_obligations` and `preregistered_cases` disagree:\n" + f" obligations: {sorted(obligations)}\n" + f" preregistered: {sorted(preregistered)}") + for cname, duties in obligations.items(): + check(bool(duties), f"{cname}: an empty obligation list says nothing") + for duty in duties: + check(duty in obligation_meanings, + f"{cname}: obligation {duty!r} has no entry in `meanings`") + licensed_outcomes: set[str] = set() + for name in sorted(set(on_disk) & set(preregistered)): + with open(os.path.join(FIXDIR, f"{name}.json"), encoding="utf-8") as fh: + case = json.load(fh) + check(case.get("case") == name, f"{name}: case field is {case.get('case')!r}") + check(case.get("contract") == "finding-lineage-decision/v1", + f"{name}: wrong contract {case.get('contract')!r}") + check(case.get("status") == "preregistered-unimplemented", + f"{name}: a decision fixture stays preregistered until a mapper exists") + check(len(case.get("why", "")) > 40, f"{name}: `why` must state what the case defends") + check(bool(case.get("expect")), f"{name}: nothing is preregistered") + + rev_a, rev_b = case.get("revision_a", {}), case.get("revision_b", {}) + occ_a = {o["occurrence_id"] for o in rev_a.get("occurrences", [])} + occ_b = {o["occurrence_id"] for o in rev_b.get("occurrences", [])} + by_id = {o["occurrence_id"]: o for o in + rev_a.get("occurrences", []) + rev_b.get("occurrences", [])} + claimed_a, claimed_b = set(), set() + + for i, exp in enumerate(case.get("expect", [])): + where = f"{name}[{i}]" + outcome = exp.get("outcome") + check(outcome in senior_outcomes, + f"{where}: outcome {outcome!r} is not in the frozen vocabulary") + frm, to = as_list(exp.get("frm")), as_list(exp.get("to")) + claimed_a.update(frm) + claimed_b.update(to) + for o in frm: + check(o in occ_a, f"{where}: predecessor {o!r} is not in revision A") + for o in to: + check(o in occ_b, f"{where}: successor {o!r} is not in revision B") + + app = exp.get("applicable_rules") + lic = exp.get("licensed_by") + check(isinstance(app, list) and isinstance(lic, list), + f"{where}: a decision fixture must declare applicable_rules and licensed_by") + if not (isinstance(app, list) and isinstance(lic, list)): + continue + for rid in app + lic: + check(rid in rules, f"{where}: names unknown rule {rid!r}") + check(len(set(app)) == len(app), f"{where}: applicable_rules repeats an id") + check(set(lic) <= set(app), + f"{where}: licensed_by {sorted(set(lic) - set(app))} is not in " + "applicable_rules; a rule cannot license what it never applied to") + licensed_outcomes.add(outcome) + + # THE BINDING CHECK. Applicability is the fixture's to declare; + # arbitration is mechanical, so the fixture may not disagree with it. + if all(rid in rules for rid in app): + if not app: + check(outcome == "unresolved", + f"{where}: no rule applied, so the outcome is unresolved") + check(not lic, f"{where}: nothing applied, so nothing licensed") + else: + got = arbitrate(app, outcomes, edges, refusals) + check(got is not None, f"{where}: arbitration has no result for {sorted(app)}") + if got is not None: + check(got[0] == outcome, + f"{where}: declares {outcome!r}, but arbitrating {sorted(app)} " + f"yields {got[0]!r}. The fixture and the algebra must not " + "describe two different policies.") + check(set(lic) == set(got[1]), + f"{where}: declares licensed_by {sorted(lic)}, but the surviving " + f"rules are {sorted(got[1])}") + + # Cardinality is a precondition, so the shape must match what licensed it. + for rid in lic: + card = rules[rid].get("cardinality", {}) + shape = card.get("shape") + if shape == "1:1": + check(len(frm) == 1 and len(to) == 1, + f"{where}: {rid} is 1:1 but licenses {len(frm)}:{len(to)}") + elif shape == "1:N": + check(len(frm) == 1 and len(to) >= card.get("min_successors", 2), + f"{where}: {rid} is 1:N with min {card.get('min_successors')} " + f"but licenses {len(frm)}:{len(to)}") + elif shape == "N:1": + check(len(to) == 1 and len(frm) >= card.get("min_predecessors", 2), + f"{where}: {rid} is N:1 with min {card.get('min_predecessors')} " + f"but licenses {len(frm)}:{len(to)}") + + # NOTHING MAY BE DROPPED IN SILENCE - the occurrence-accounting rule, + # applied to rules. Every declared rule must be accounted for by name: + # applicable, explicitly not applicable with a reason, or recorded as + # having failed to single out a candidate. A rule nobody mentions is + # indistinguishable from one the case author forgot existed, and that + # is precisely how R-CONT-COPY stayed missing. + # Mirror sides (`side: b`) are exempt: they restate the other half of a + # refusal already accounted for and raise no new rule question. + if exp.get("side") != "b": + named = (set(app) | set(exp.get("not_applicable") or {}) + | set((exp.get("decision_detail") or {}) + .get("rules_without_a_unique_candidate") or [])) + check(named >= set(ids), + f"{where}: says nothing about {sorted(set(ids) - named)}. Every rule " + "must be accounted for - applicable, not applicable with a reason, or " + "unable to choose.") + + for rid, reason in (exp.get("not_applicable") or {}).items(): + check(rid in rules, f"{where}: not_applicable names unknown rule {rid!r}") + check(rid not in app, + f"{where}: {rid} is in applicable_rules AND not_applicable") + check(isinstance(reason, str) and len(reason) > 10, + f"{where}: not_applicable[{rid}] must say WHY, not just list the id") + + if outcome == "unresolved": + check(exp.get("side") in ("a", "b"), + f"{where}: unresolved needs side 'a' or 'b', got {exp.get('side')!r}") + check(exp.get("reason") in senior_limitations_set, + f"{where}: reason {exp.get('reason')!r} is not a senior limitation") + check(not lic, f"{where}: a refusal licenses nothing") + else: + check("reason" not in exp, + f"{where}: {outcome} must not carry an identity limitation") + check(bool(lic), f"{where}: {outcome} must name what licensed it") + + # A defeat must be CARRIED by revision B, exactly as a boundary is. + for signal, source in (exp.get("signals_defeated") or {}).items(): + spec = policy["signal_defeaters"].get(signal) + check(spec is not None, + f"{where}: {signal!r} is not a defeatable signal in the policy") + if spec is None: + continue + check(spec["defeated_by_signal"] == source or + catalog.get(spec["defeated_by_signal"], {}).get("observable_from", "") + .endswith("." + str(source)), + f"{where}: {signal!r} is defeated by {spec['defeated_by_signal']!r}, " + f"not by {source!r}") + cat = catalog.get(spec["defeated_by_signal"], {}) + observed = read_signal(rev_b, cat) + check(bool(observed), + f"{where}: claims {signal!r} was defeated, but " + f"{cat.get('observable_from')} carries nothing to match on. A defeat " + "asserted in a note is a note.") + # And it must name THIS occurrence. A record about some other symbol + # in the same file defeats nothing here, which is exactly the trap + # `renamed-symbol-defeats-structural-context` is built out of. + for side, ids_ in (("a", frm), ("b", to)): + for oid in ids_: + subj = by_id.get(oid, {}) + wanted = {subj.get(attr) for attr in + ("path", "enclosing_symbol") if attr in subj} + if observed and side == "a": + check(bool(wanted & set(observed)), + f"{where}: {spec['defeated_by_signal']!r} holds " + f"{sorted(observed)!r}, which names neither the path nor " + f"the enclosing symbol of {oid}; it defeats nothing here") + for rid in app: + check(signal not in rules[rid].get("requires_all", []), + f"{where}: {rid} is applicable but requires the defeated " + f"signal {signal!r}") + + for sig in as_list(exp.get("inputs_unavailable")): + check(sig in senior["evidence_kinds"] or sig in catalog, + f"{where}: unavailable input {sig!r} is neither a frozen evidence " + "kind nor a catalogued structural signal") + declared = rev_b.get("unavailable_signals", []) + check(sig in declared, + f"{where}: claims {sig!r} was unavailable, but revision B's " + f"`unavailable_signals` holds {declared!r}. Unavailability is a " + "RECORD, which is the entire point of the case.") + for rid in app: + check(sig not in rules[rid].get("requires_all", []), + f"{where}: {rid} is applicable but requires the unevaluable " + f"signal {sig!r}") + + for rid in ((exp.get("decision_detail") or {}) + .get("rules_without_a_unique_candidate") or []): + check(rid in rules, f"{where}: unknown rule {rid!r} in decision_detail") + check(rid not in app, + f"{where}: {rid} had no unique candidate, so it had no surviving " + "application and must not be in applicable_rules") + + for kind, defeater in (exp.get("boundary_defeated") or {}).items(): + spec = next((s for s in senior["boundary_evidence_kinds"].values() + if s["value"] == kind), None) + check(spec is not None, f"{where}: unknown boundary kind {kind!r}") + if spec is None: + continue + check(defeater in [d.strip() for d in spec["defeated_by"].split(",")], + f"{where}: {kind!r} is not defeated by {defeater!r} in the senior " + f"contract, which lists {spec['defeated_by']!r}") + check(bool(collect(rev_b, defeater)), + f"{where}: claims {kind!r} is defeated by {defeater}, which is empty") + subject = by_id.get((frm or to or [None])[0], {}) + role, attr = spec["match"].split(".") + check(subject.get(attr) in collect(rev_b, spec["observable_from"]), + f"{where}: {kind!r} would not have applied anyway - " + f"{spec['observable_from']} does not name {subject.get(attr)!r}, so " + "defeating it proves nothing") + + # ---- the case's PREREGISTERED OBLIGATION, not just its answer. ------- + # A case can reach the right outcome for the wrong reason: move the losing + # rule out of `applicable_rules` and the copy case still reports `branched` + # while showing dominance doing nothing. The suite cannot notice on its own + # - which rules really fired is applicability - so the obligation is + # declared in the contract and checked against the fixture's declarations. + duties = obligations.get(name) + check(duties is not None, + f"{name}: no entry in `case_obligations`; nothing says what this case " + "must exhibit beyond being green") + for duty in duties or []: + check(duty in obligation_meanings, f"{name}: unknown obligation {duty!r}") + met = False + for exp in case.get("expect", []): + app_s = set(exp.get("applicable_rules") or []) + lic_s = set(exp.get("licensed_by") or []) + if duty == "dominance_did_work": + met |= any((w, r) in edges for r in app_s - lic_s for w in lic_s) + elif duty == "defeat_removed_a_signal": + met |= bool(exp.get("signals_defeated")) + elif duty == "input_was_unavailable": + met |= bool(exp.get("inputs_unavailable")) + elif duty == "blunt_rule_recorded": + met |= bool((exp.get("decision_detail") or {}) + .get("rules_without_a_unique_candidate")) + elif duty == "cardinality_excluded_a_rule": + excluded = exp.get("excluded_by_cardinality") or [] + for rid in excluded: + check(rid in rules, f"{name}: unknown rule {rid!r} in " + "excluded_by_cardinality") + check(rid not in app_s, + f"{name}: {rid} is excluded by cardinality and also " + "applicable") + card = rules.get(rid, {}).get("cardinality", {}) + nf, nt = len(as_list(exp.get("frm"))), len(as_list(exp.get("to"))) + blocked = (card.get("min_successors", 0) > nt + or card.get("min_predecessors", 0) > nf) + check(blocked, + f"{name}: {rid} is claimed excluded by cardinality, but " + f"{card} does not rule out a {nf}:{nt} shape. The guard has " + "to do the excluding, not the note.") + met = True + check(met, f"{name}: preregistered to exhibit {duty!r}, and no expectation " + f"does. {obligation_meanings.get(duty, '')}") + + check(claimed_a == occ_a, + f"{name}: revision A occurrences unaccounted for: {sorted(occ_a - claimed_a)}") + check(claimed_b == occ_b, + f"{name}: revision B occurrences unaccounted for: {sorted(occ_b - claimed_b)}") + + seen_forbid: set[str] = set() + for forbidden in case.get("forbid", []): + check(isinstance(forbidden, str) and len(forbidden) > 5, + f"{name}: empty or stub entry in `forbid`") + check(forbidden not in seen_forbid, + f"{name}: `forbid` repeats an entry verbatim: {forbidden[:60]!r}") + seen_forbid.add(forbidden) + check(bool(seen_forbid), f"{name}: `forbid` is empty - the case rules nothing out") + + # Every outcome a RULE can license must be licensed by some case, or it is + # frozen in name only and a mapper may reach it however it likes. This is the + # analogue of step 0's outcome sweep; it is not a demand for one happy-path + # case per rule, because this matrix is about shapes where two answers are + # available and a rename that simply works is not one. R-CONT-RENAME is + # constrained by the senior corpus, where `rename-with-context-continues` is + # the only rule that licenses it. + for wanted in sorted({rules[r]["outcome"] for r in ids} | {"unresolved"}): + check(wanted in licensed_outcomes, + f"no preregistered case reaches {wanted!r}; the policy can license it and " + "nothing pins how") + # ---- 5. META: the proof checks are checked, in-process. ---------------- # a05edeb was titled "move the arbitration proofs out of the terminal" and # moved the RESULT while the meta-proofs stayed in scratch scripts. These are @@ -510,8 +841,9 @@ def main() -> int: print(f"identity/lineage-decision: FAIL - {len(fails)} check(s) failed") return 1 print(f"identity/lineage-decision: OK - {len(ids)} rules, {len(conflicting)} " - f"different-outcome pairs all classified, {len(results)} subsets total " - f"(totality is a theorem of completeness and acyclicity, swept anyway)") + f"different-outcome pairs all classified, {len(results)} subsets total, " + f"{len(preregistered)} preregistered cases (totality is a theorem of " + "completeness and acyclicity, swept anyway; mapper not implemented)") return 0 From b164f04bcb192fa2302773954cb6ee3d7cc7ce52 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:06:55 +0000 Subject: [PATCH 11/84] fix(identity): stage the decision procedure, and name the right shortage Three corrections found in review of 2f97dce, all of them places where two parts of the work described different things. 1. APPLICABLE_RULES CONTRADICTED ITSELF The contract defined it as the rules with at least one surviving application while also saying a rule firing on several candidate edges appears there once - which reads as the PRE-uniqueness set. The new blunt-rule case and its checker required the opposite: a rule matching two candidates is ABSENT and recorded as unable to choose. Both cannot hold. The staged reading is kept, because the alternative feeds arbitration a rule that licensed no mapping and then has it disagree with rules that did: match (predicate) -> filter (uniqueness, cardinality) -> applicable_rules -> arbitrate -> licensed_by And the recall property moves with it. `applicable_rules` stopped being the recall set the moment a filter appeared in front of it: a rule dropped for matching two candidates is exactly a mapping that would change if that rule were made more discriminating, and it is not in that field at all. So recall = applicable_rules + decision_detail.rules_without_a_unique_candidate + any further explicitly recorded rejected stage which is why the rejected stage is RECORDED and not merely computed. A stage whose output is discarded cannot be recalled from. 2. INSUFFICIENT-EVIDENCE-KIND WAS WIDENED FROM BELOW, AGAIN The previous commit reported a defeat as `insufficient-evidence-kind`. In `defeated-signal-drops-below-the-floor` THREE kinds survive against a floor of two: the kinds are ample, and what is insufficient is the COMBINATION. Naming that a shortage of kinds says the evidence was thin when it was plentiful. So the senior contract is amended in the open - for the second review running, by the junior contract's own rule against widening from below: lineage-id-unavailable:insufficient-evidence-combination `insufficient-evidence-kind` keeps its job: the floor genuinely not cleared. The pair is documented in `limitations_rule` next to ambiguous-candidates and conflicting-evidence, and the distinction is now machine-checked - a case declaring a defeat must declare `evidence_surviving`, and the suite requires the -kind reason to sit below the floor and the -combination reason at or above it. That makes this fixture's real point checkable: two is a necessary minimum, never a sufficient algorithm. 3. THE DEFEATER READER FOLLOWED entry_shape, NOT THE NORMATIVE matches `renamed_symbol_record` matches on `from` alone, but the reader pooled every key of `entry_shape`. A record reading `Other.Symbol -> DocView.Wire` would have passed as a defeater for a predecessor in DocView.Wire - a record saying something ARRIVED at that name, which is the opposite of the evidence a defeat needs. The reader now follows `matches`, using `entry_shape` only to know whether entries are strings or objects, and checks the value against the named role and attribute rather than against any field that happens to be present. Same defect, same fix, for unavailability: the suite hardcoded `unavailable_signals` while the contract declared `unavailable_inputs.observable_from`, leaving that field decorative. It is read from the contract now, so repointing it breaks the suite. 29 vacuity probes bite, covering all three corrections and everything the previous commits added. Two of my own probes lied before they were fixed: one reported VACUOUS because the suite crashed with a KeyError before reaching the checks - a missing senior key now reports the vocabulary diagnosis instead of a traceback - and the harness counted only FAIL lines, so a non-zero exit read as a pass. It counts exit codes now. Separately, a `git checkout -- contracts/` used to clean up after a probe discarded uncommitted contract work, which had to be redone. All 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 121 ++++++++++++------ contracts/finding-lineage-v1.json | 27 +++- ...defeated-signal-drops-below-the-floor.json | 19 ++- ...med-symbol-defeats-structural-context.json | 16 ++- identity/tests/test_lineage_decision.py | 115 ++++++++++++----- 5 files changed, 222 insertions(+), 76 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index f948e23..c3ebcaf 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -17,7 +17,7 @@ "question 'would this mapping still be made under the new rule' stops having", "an answer - which is the promise mapping_provenance exists to keep.", "So a mapping cites RULES BY ID, and the two id lists answer two questions.", - "`applicable_rules` is the RECALL SET for re-judgement. Retiring or changing a", + "`applicable_rules` is the largest PART of the recall set for re-judgement, and `recall_set` names the rest. Retiring or changing a", "rule means finding every mapping it touched - including the ones where it", "LOST, because those are exactly the mappings that change if the winner is", "later retired or if arbitration moves. A recall set built from `licensed_by`", @@ -36,10 +36,19 @@ " be evaluated is not absent - see `unavailable_inputs`.", "2. DEFEAT: remove every signal an explaining record accounts for. A defeated", " signal is REMOVED, never weighed.", - "3. APPLY every rule to the surviving set. All of them, not the first match.", - " The result is `applicable_rules`.", - "4. ARBITRATE by `arbitration`. The result is the outcome and `licensed_by`.", - "5. RECORD both lists, the evidence, `decision_detail`, and the provenance." + "3. MATCH every rule against the surviving set. A rule may match ONE candidate", + " binding or SEVERAL - matching is about the predicate, not about how many", + " partners answer it.", + "4. FILTER by uniqueness and cardinality. A 1:1 rule with anything other than", + " exactly one candidate does not survive; a group rule below its declared", + " minimum does not survive. Rules that matched and died here are recorded in", + " `decision_detail.rules_without_a_unique_candidate`.", + "5. APPLY: the survivors of step 4 are `applicable_rules`. All of them, not the", + " first match.", + "6. ARBITRATE by `arbitration`, over `applicable_rules` only. The result is the", + " outcome and `licensed_by`.", + "7. RECORD every stage's output, the evidence, `decision_detail`, and the", + " provenance." ], "order_independence": [ "Step 3 evaluates a SET, not a sequence, and step 4 resolves by a declared", @@ -322,7 +331,7 @@ ] }, "record_additions": { - "applicable_rules": "the DISTINCT ids of rules having at least one surviving application. A set of ids, not a count of applications: one rule may fire on several candidate edges, and this field does not carry that multiplicity. The bindings live in `evidence` and `decision_detail`.", + "applicable_rules": "the DISTINCT ids of rules with a SURVIVING application - matched, then past uniqueness and cardinality, per `decision_procedure` step 5. A set of ids, not a count of applications. Rules that matched and failed uniqueness are NOT here; they are in `decision_detail.rules_without_a_unique_candidate`, and both are needed for re-judgement - see `recall_set`.", "licensed_by": "the SURVIVING rules the outcome rests on - the applicable rules that no other applicable rule dominates, per `arbitration.licensed_by_rule`. Non-empty for continued, branched and merged; EMPTY on a refused conflict.", "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: conflicting_rules, inputs_unavailable, defeated_signals, and rules_without_a_unique_candidate - the rules that fired on several candidates and so licensed no 1:1 mapping. That last one is not cosmetic: without it a blunt rule losing to a discriminating one leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither; it failed to single anyone out.", "signals_defeated": "signal name -> the record that defeated it. Present whenever step 2 removed anything.", @@ -402,27 +411,29 @@ } ], "applicable_rules_vs_licensed_by": [ - "Two lists, because they answer two different questions, and an earlier draft", - "of this file conflated them into a claim that was simply false.", - "`applicable_rules` - every rule that fired on the surviving evidence set,", - " including the ones that lost arbitration. This is the audit trail: retiring", - " a rule later means finding every mapping it touched, and the losers are", - " exactly the mappings that would change if the winner were retired.", - "`licensed_by` - the SURVIVING rules, and only those: the applicable rules that", - " no other applicable rule dominates. Several may survive together, and all of", - " them are named; see `arbitration.licensed_by_rule`.", - "The draft put both into `licensed_by`, so a branched mapping asserted that", - "R-CONT-SAME-SITE licensed `branched`. It did not: it proposed `continued` and", - "lost. A record that says otherwise is wrong in the same way a fabricated", - "successor is wrong - it attributes a conclusion to something that never", - "reached it.", + "Three sets, because there are three questions, and earlier drafts of this file", + "collapsed them twice - once into a single field, once into two.", + "`matched` - the rules whose predicate held on the surviving evidence. Not", + " stored as such; it is the input to the uniqueness filter.", + "`applicable_rules` - the rules with a SURVIVING application, after uniqueness", + " and cardinality. This is what arbitration sees, and it includes the rules", + " that lost arbitration: they are exactly the mappings that would change if the", + " winner were retired.", + "`licensed_by` - the SURVIVING rules of arbitration, and only those: the", + " applicable rules that no other applicable rule dominates. Several may survive", + " together, and all of them are named; see `arbitration.licensed_by_rule`.", + "A draft put losers and winners both into `licensed_by`, so a branched mapping", + "asserted that R-CONT-SAME-SITE licensed `branched`. It did not: it proposed", + "`continued` and lost. A record that says otherwise is wrong in the same way a", + "fabricated successor is wrong - it attributes a conclusion to something that", + "never reached it.", "On a refused conflict `licensed_by` is EMPTY, and the disagreeing rules appear", "in `applicable_rules` and in `decision_detail.conflicting_rules`. Nothing", "licensed the refusal; the refusal is what happens when nothing can.", - "One more thing this field is NOT: a trace. A rule that fires on two candidate", - "edges appears once. Promising multiplicity in a set of ids would be a claim the", - "shape cannot keep, and the place for per-candidate bindings is the evidence and", - "the decision detail, where they can be recorded without being counted twice." + "One more thing `licensed_by` is NOT: a trace. A rule that survived on one", + "candidate edge appears once. The place for per-candidate bindings is the", + "evidence and the decision detail.", + "And `applicable_rules` is NOT the recall set on its own - see `recall_set`." ], "reason_mapping": { "no_rule_applied": { @@ -430,20 +441,22 @@ "detail": "inputs_unavailable, when the reason nothing applied is that a signal could not be evaluated" }, "no_rule_applied_after_a_defeat": { - "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "reason": "lineage-id-unavailable:insufficient-evidence-combination", "detail": "defeated_signals: signal -> the record that removed it", "why": [ "Distinguished from `no-mapping-evidence` because the two are not the same", - "situation and a consumer reads this field. When a defeater fires, evidence", - "WAS observed - it was removed because a record explained it away - and", - "saying `no mapping evidence` would be false in the plain meaning of the", - "words.", - "`insufficient-evidence-kind` is already in the senior vocabulary and was", - "unused. This is the case it was for: the KINDS that survive do not reach", - "what any rule requires. Note that they may still clear the senior floor -", - "in `defeated-signal-drops-below-the-floor` three kinds survive against a", - "floor of two - which is the sharpest demonstration available that the", - "floor is not the algorithm." + "situation and a consumer reads this field. When a defeater fires, evidence WAS", + "observed - it was removed because a record explained it away - and saying `no", + "mapping evidence` would be false in the plain meaning of the words.", + "And distinguished from `insufficient-evidence-kind`, which this file reported", + "for one review before the senior contract was corrected. In", + "`defeated-signal-drops-below-the-floor` THREE kinds survive against a floor of", + "two: the kinds are ample, and it is the COMBINATION that no declared rule", + "accepts. Naming that a shortage of kinds would have said the evidence was thin", + "when it was plentiful and simply did not add up to a rule anyone declared.", + "`insufficient-evidence-kind` keeps its own job: the senior floor genuinely not", + "cleared. This one is the case where the floor is cleared and the floor was", + "never the algorithm." ] }, "conflicting_rules": { @@ -708,5 +721,41 @@ "of the meanings above. A case added without an obligation is a case nobody", "has said what to expect of, which is how a matrix drifts into decoration." ] - } + }, + "stages_rule": [ + "MATCHED, SURVIVING and LICENSED are three different sets, and an earlier draft", + "of this file used one word for the first two. It defined `applicable_rules` as", + "the rules with at least one surviving application while also saying a rule", + "firing on several candidate edges appears there once - which reads as the", + "PRE-uniqueness set. Then `blunt-rule-loses-to-uniqueness` and its checker", + "required the opposite: a rule matching two candidates must be ABSENT from", + "`applicable_rules` and recorded as unable to choose.", + "Both cannot be true. The staged reading above is the one kept, because the", + "alternative puts a rule into arbitration that licensed no mapping - and", + "arbitration would then have it disagree with rules that did.", + "So: matching is about the predicate. Surviving is about uniqueness and", + "cardinality. `applicable_rules` is the set AFTER step 4, and it is what", + "arbitration sees." + ], + "recall_set": [ + "WHICH MAPPINGS MUST BE REVISITED IF A RULE CHANGES.", + "", + " recall = applicable_rules", + " + decision_detail.rules_without_a_unique_candidate", + " + any further explicitly recorded rejected stage", + "", + "`applicable_rules` alone stopped being the recall set the moment uniqueness", + "became a filter in front of it. A rule that matched two candidates and was", + "dropped there is exactly a mapping that would CHANGE if that rule were made", + "more discriminating - and it does not appear in `applicable_rules` at all. A", + "recall set built from that field alone would miss it, and miss it silently,", + "which is the failure mode this project keeps finding in its own drafts.", + "This is why the rejected stage is RECORDED rather than merely computed and", + "discarded. A stage whose output is thrown away cannot be recalled from, and", + "`rules_without_a_unique_candidate` exists to be part of this union rather than", + "as a courtesy to the reader.", + "Any future stage that rejects a rule joins this union in the same breath as it", + "is added. A stage without a recorded output is a stage that silently narrows", + "re-judgement." + ] } diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index f22b21b..cb96e4a 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -31,6 +31,17 @@ "inventing a value in a vocabulary it declares itself subordinate to. That the", "junior contract had a written rule saying exactly this, and that the rule fired", "on the very next review, is the reason it is written down at all.", + "", + "Amended a third time by slice 2 STEP 1, which added", + "'insufficient-evidence-combination'. The step 1 fixture", + "`defeated-signal-drops-below-the-floor` leaves three evidence kinds standing", + "after a defeater removes a fourth - above this contract's floor of two - and", + "still licenses nothing, because no declared combination survives. The junior", + "contract first reported that as 'insufficient-evidence-kind', which was false", + "in the plain meaning of the words: the kinds were ample.", + "Corrected here rather than below, for the second review running. The junior", + "rule against widening from below has now fired twice, which is a better", + "argument for writing rules down than any amount of agreeing that one should.", "Still frozen-unimplemented: no mapper exists, so nothing is built on the old", "shape and nothing has to be migrated." ], @@ -137,7 +148,8 @@ "conflicting-evidence": "lineage-id-unavailable:conflicting-evidence", "missing-occurrence-id": "lineage-id-unavailable:missing-occurrence-id", "no-predecessor-revision": "lineage-id-unavailable:no-predecessor-revision", - "insufficient-evidence-kind": "lineage-id-unavailable:insufficient-evidence-kind" + "insufficient-evidence-kind": "lineage-id-unavailable:insufficient-evidence-kind", + "insufficient-evidence-combination": "lineage-id-unavailable:insufficient-evidence-combination" }, "limitations_rule": [ "These say why a lineage_id is UNAVAILABLE. They belong to 'unresolved' and to", @@ -150,7 +162,18 @@ "possible partners, nothing prefers one - the shape of two-candidates-unresolved.", "Conflicting evidence: ONE observed structure that supports incompatible", "conclusions about the same relation. The first is a choice with no grounds; the", - "second is grounds that argue with each other." + "second is grounds that argue with each other.", + "'insufficient-evidence-kind' and 'insufficient-evidence-combination' are the", + "second pair of neighbours, and the distinction is the whole reason", + "`minimum_evidence_kinds_for_continued` is called a floor.", + "Insufficient KIND: fewer kinds than the floor, or one kind carrying a mapping", + "no single kind may carry. The count is the problem.", + "Insufficient COMBINATION: the floor is met, sometimes comfortably, and no", + "combination a decision policy declares sufficient survives. Three kinds may be", + "present and the answer still unresolved, because two is the minimum a mapping", + "must clear and never the algorithm that decides which mappings are licensed.", + "Reporting the second as the first would say the evidence was too thin when it", + "was ample and simply did not add up to a rule anyone declared." ], "boundary_evidence_kinds": { "containing-file-deleted": { diff --git a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json index e000db0..c91b201 100644 --- a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json +++ b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json @@ -1,7 +1,7 @@ { "case": "defeated-signal-drops-below-the-floor", "title": "A reformatting pass removes the only signal the rule had left", - "why": "ADVERSARIAL about the FLOOR, not about the signals. Everything matches: same file, same symbol, same line, same text. But the file went through a formatter, so content equality is a property of the formatter rather than of the occurrence, and the defeater removes it before any rule is applied. What survives - same_path, same_pattern_id, structural_context - is THREE kinds against a senior floor of two, and still no rule is satisfied, because no rule asks for that combination. That is the clearest statement available of why the floor is a floor and not the algorithm. The reason is `insufficient-evidence-kind`, not `no-mapping-evidence`: evidence was observed and then explained away, and saying none was seen would be false.", + "why": "ADVERSARIAL about the FLOOR, not about the signals. Everything matches: same file, same symbol, same line, same text. But the file went through a formatter, so content equality is a property of the formatter rather than of the occurrence, and the defeater removes it before any rule is applied. What survives - same_path, same_pattern_id, structural_context - is THREE kinds against a senior floor of two, and still no rule is satisfied, because no rule asks for that combination. That is the clearest statement available of why the floor is a floor and not the algorithm. The reason is `insufficient-evidence-combination`: evidence was observed and then explained away, so `no-mapping-evidence` would be false, and three surviving kinds against a floor of two make `insufficient-evidence-kind` false too. What is insufficient is the combination, which is the distinction the senior contract was amended to carry.", "revision_a": { "revision": "r1", "occurrences": [ @@ -41,12 +41,17 @@ "occ-a1" ], "to": [], - "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "reason": "lineage-id-unavailable:insufficient-evidence-combination", "applicable_rules": [], "licensed_by": [], "signals_defeated": { "anchored_content": "reformatted_paths" }, + "evidence_surviving": [ + "same_path", + "same_pattern_id", + "structural_context" + ], "not_applicable": { "R-CONT-SAME-SITE": "anchored_content was defeated, and the rule requires it", "R-CONT-DRIFT": "line_drift did not fire - the occurrence is at the same line", @@ -64,12 +69,17 @@ "to": [ "occ-b1" ], - "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "reason": "lineage-id-unavailable:insufficient-evidence-combination", "applicable_rules": [], "licensed_by": [], "signals_defeated": { "anchored_content": "reformatted_paths" }, + "evidence_surviving": [ + "same_path", + "same_pattern_id", + "structural_context" + ], "note": "the mirror side. Nothing about revision B says this occurrence is new, and no rule links it back." } ], @@ -77,7 +87,8 @@ "continued on same_path plus structural_context - three surviving kinds clear the senior floor and satisfy no rule", "anchored_content counted at a reduced weight - the contract has no weights", "ended for occ-a1 or new for occ-b1 - neither carries boundary evidence", - "no-mapping-evidence as the reason - evidence was observed and then defeated" + "no-mapping-evidence as the reason - evidence was observed and then defeated", + "insufficient-evidence-kind as the reason - three kinds survive against a floor of two, so the kinds are ample and it is the combination that no rule accepts" ], "contract": "finding-lineage-decision/v1", "status": "preregistered-unimplemented" diff --git a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json index 0d9603e..87de1de 100644 --- a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json +++ b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json @@ -44,12 +44,18 @@ "occ-a1" ], "to": [], - "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "reason": "lineage-id-unavailable:insufficient-evidence-combination", "applicable_rules": [], "licensed_by": [], "signals_defeated": { "structural_context": "renamed_symbol_record" }, + "evidence_surviving": [ + "same_path", + "same_pattern_id", + "anchored_content", + "line_drift" + ], "not_applicable": { "R-CONT-SAME-SITE": "structural_context was defeated", "R-CONT-DRIFT": "structural_context was defeated; line_drift alone carries nothing", @@ -67,12 +73,18 @@ "to": [ "occ-b1" ], - "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "reason": "lineage-id-unavailable:insufficient-evidence-combination", "applicable_rules": [], "licensed_by": [], "signals_defeated": { "structural_context": "renamed_symbol_record" }, + "evidence_surviving": [ + "same_path", + "same_pattern_id", + "anchored_content", + "line_drift" + ], "note": "occ-b1 is not `new` either: nothing records its site as added, and a birth is earned rather than left over." } ], diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 0224a4e..e69b3ff 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -127,19 +127,25 @@ def collect(rev: dict, dotted: str) -> list: return out -def read_signal(rev: dict, spec: dict) -> list: - """Read the values a structural signal matches on, following the catalog's own - `entry_shape`. `observable_from` names the FIELD; whether the entries are bare - strings or objects is `entry_shape`'s to say, and guessing instead is how a - checker silently reads an empty list and reports a record as present.""" +def signal_bindings(spec: dict) -> list: + """(read path, subject role, subject attribute) for each key the catalog's + `matches` names. + + `matches` is the NORMATIVE half and `entry_shape` only says how the entries + are shaped. An earlier version read every key of `entry_shape`, which is a + strictly wider and wrong semantics: `renamed_symbol_record` matches on `from` + alone, so pooling `from` and `to` let a record reading + `Other.Symbol -> DocView.Wire` defeat a predecessor in DocView.Wire - the + rename record says something arrived at that name, which is the opposite of + the evidence the defeat needs.""" field = str(spec.get("observable_from", "")) shape = spec.get("entry_shape") - if isinstance(shape, dict): - out = [] - for key in shape: - out.extend(collect(rev, f"{field}[].{key}")) - return out - return collect(rev, field) + out = [] + for key, subject in (spec.get("matches") or {}).items(): + role, _, attr = str(subject).partition(".") + read = f"{field}[].{key}" if isinstance(shape, dict) else field + out.append((read, role, attr)) + return out def raw_dominance_edges(policy: dict) -> list: @@ -436,6 +442,10 @@ def main() -> int: "first two is what falsified the rule set, so partial credit is not on offer.") senior_limitations_set = set(senior["limitations"].values()) + unavailable_from = str(policy["unavailable_inputs"].get("observable_from", "")) + check(unavailable_from.startswith("revision_b."), + "`unavailable_inputs.observable_from` must name a revision B field, got " + f"{unavailable_from!r}") obligations = policy["case_obligations"]["obligations"] obligation_meanings = policy["case_obligations"]["meanings"] check(sorted(obligations) == sorted(preregistered), @@ -576,38 +586,79 @@ def main() -> int: f"{where}: {signal!r} is defeated by {spec['defeated_by_signal']!r}, " f"not by {source!r}") cat = catalog.get(spec["defeated_by_signal"], {}) - observed = read_signal(rev_b, cat) - check(bool(observed), - f"{where}: claims {signal!r} was defeated, but " - f"{cat.get('observable_from')} carries nothing to match on. A defeat " - "asserted in a note is a note.") - # And it must name THIS occurrence. A record about some other symbol - # in the same file defeats nothing here, which is exactly the trap + bindings = signal_bindings(cat) + check(bool(bindings), + f"{where}: {spec['defeated_by_signal']!r} declares no `matches`, so " + "there is nothing to check the record against") + # The record must name THIS occurrence, on the key the catalog says + # it matches on. A record about another symbol in the same file + # defeats nothing - which is the trap # `renamed-symbol-defeats-structural-context` is built out of. - for side, ids_ in (("a", frm), ("b", to)): - for oid in ids_: - subj = by_id.get(oid, {}) - wanted = {subj.get(attr) for attr in - ("path", "enclosing_symbol") if attr in subj} - if observed and side == "a": - check(bool(wanted & set(observed)), - f"{where}: {spec['defeated_by_signal']!r} holds " - f"{sorted(observed)!r}, which names neither the path nor " - f"the enclosing symbol of {oid}; it defeats nothing here") + for read, role, attr in bindings: + observed = collect(rev_b, read) + check(bool(observed), + f"{where}: claims {signal!r} was defeated, but {read} carries " + "nothing to match on. A defeat asserted in a note is a note.") + subject_ids = frm if role == "predecessor" else to + for oid in subject_ids: + wanted = by_id.get(oid, {}).get(attr) + check(wanted in observed, + f"{where}: {spec['defeated_by_signal']!r} matches on {read} " + f"= {role}.{attr}, which for {oid} is {wanted!r}; the record " + f"holds {sorted(observed)!r} and so defeats nothing here") for rid in app: check(signal not in rules[rid].get("requires_all", []), f"{where}: {rid} is applicable but requires the defeated " f"signal {signal!r}") + # THE TWO NEIGHBOURS, kept apart mechanically. `insufficient-evidence- + # kind` means the senior floor was not cleared; `-combination` means it + # was and no declared combination survives. Without this the fixture + # could name either and the suite would shrug, because both are valid + # senior limitations - and the whole point of + # `defeated-signal-drops-below-the-floor` is which one is true. + surviving = exp.get("evidence_surviving") + if exp.get("signals_defeated"): + check(isinstance(surviving, list), + f"{where}: a defeat must declare `evidence_surviving` - what is " + "left standing is what decides which limitation is honest") + if isinstance(surviving, list): + for kind in surviving: + check(kind in senior["evidence_kinds"], + f"{where}: evidence_surviving names {kind!r}, not a frozen kind") + for kind in (exp.get("signals_defeated") or {}): + check(kind not in surviving, + f"{where}: {kind!r} is both defeated and surviving") + reason = exp.get("reason") + # `.get`, not `[...]`: a missing senior key is a real situation - the + # vocabulary check above is what diagnoses it - and crashing here + # would replace that diagnosis with a traceback. A suite that dies on + # the evidence it came to read has happened in this repo before. + lim = senior["limitations"] + if reason == lim.get("insufficient-evidence-kind"): + check(len(set(surviving)) < floor, + f"{where}: claims insufficient KINDS, but {len(set(surviving))} " + f"survive against a floor of {floor}. The kinds are ample; it is " + "the combination that no rule accepts.") + if reason == lim.get("insufficient-evidence-combination"): + check(len(set(surviving)) >= floor, + f"{where}: claims insufficient COMBINATION, but only " + f"{len(set(surviving))} kind(s) survive against a floor of " + f"{floor}. Below the floor the shortage really is of kinds.") + for sig in as_list(exp.get("inputs_unavailable")): check(sig in senior["evidence_kinds"] or sig in catalog, f"{where}: unavailable input {sig!r} is neither a frozen evidence " "kind nor a catalogued structural signal") - declared = rev_b.get("unavailable_signals", []) + # Read from the field the CONTRACT declares, not a name hardcoded + # here. A hardcoded reader leaves `observable_from` decorative: + # changing it would break nothing, which is the same defect as a + # boundary that lives only in prose. + declared = collect(rev_b, unavailable_from) check(sig in declared, - f"{where}: claims {sig!r} was unavailable, but revision B's " - f"`unavailable_signals` holds {declared!r}. Unavailability is a " - "RECORD, which is the entire point of the case.") + f"{where}: claims {sig!r} was unavailable, but {unavailable_from} " + f"holds {declared!r}. Unavailability is a RECORD, which is the " + "entire point of the case.") for rid in app: check(sig not in rules[rid].get("requires_all", []), f"{where}: {rid} is applicable but requires the unevaluable " From 865fb61c90487d92a643734a9fec086a26733427 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:34:02 +0000 Subject: [PATCH 12/84] docs(identity): align prose with staged decision semantics Two references left stale by the staging commit. No semantics change; both sections now say what the file already does. `order_independence` still described step 3 as the evaluation and step 4 as the resolution. After staging, 3 is MATCH, 4 is FILTER and 6 is ARBITRATE. It now names what is order-free at each stage: 3 and 4 evaluate sets - of rules, and of the candidate bindings each rule matched - and 6 resolves by the declared pairwise relation, which is also how the totality argument is written. `why_rule_ids_and_not_a_confidence_score` still ended the re-judgement paragraph with "splitting the fields moved that property to the other list". That was true for one revision and stopped being true when uniqueness became a filter: the property is not in `applicable_rules` either, it is in the union `recall_set` defines. Splitting the fields distributed it across the stages rather than relocating it, and the correction says so instead of pointing at a field that no longer holds it alone. All 20 python suites green in normal and -O. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 41 +++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index c3ebcaf..9c00ab2 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -16,16 +16,20 @@ "evidence sets scoring 0.8 become indistinguishable in the record, and the", "question 'would this mapping still be made under the new rule' stops having", "an answer - which is the promise mapping_provenance exists to keep.", - "So a mapping cites RULES BY ID, and the two id lists answer two questions.", - "`applicable_rules` is the largest PART of the recall set for re-judgement, and `recall_set` names the rest. Retiring or changing a", - "rule means finding every mapping it touched - including the ones where it", - "LOST, because those are exactly the mappings that change if the winner is", - "later retired or if arbitration moves. A recall set built from `licensed_by`", - "would miss them, and miss them silently.", - "`licensed_by` answers a different question: why this outcome and not another.", - "Neither list substitutes for the other, and an earlier draft of this file", - "claimed the historical-re-judgement property for `licensed_by` while it still", - "held both. Splitting the fields moved that property to the other list.", + "So a mapping cites RULES BY ID, and the recorded id lists answer two", + "different questions.", + "RE-JUDGEMENT: retiring or changing a rule means finding every mapping it", + "touched - including the ones where it LOST arbitration, and the ones where it", + "matched and failed uniqueness. Both are mappings that would change. That", + "property belongs to the UNION defined in `recall_set`, and to no single field:", + "`applicable_rules` is its largest part and not the whole of it.", + "An earlier draft claimed the property for `licensed_by` while that field still", + "held winners and losers together. Splitting the fields did not move the", + "property to the other list, which is what this section said for one revision -", + "it distributed it across the stages, and `recall_set` is where the pieces are", + "added back up.", + "`licensed_by` answers the other question: why this outcome and not another.", + "No list substitutes for another.", "A threshold moving from 0.7 to 0.75 leaves no such trail at all.", "This is a deliberate NO to a confidence class, not an omission. If one is", "ever wanted, it must be derived FROM the cited rules and stored beside them,", @@ -51,12 +55,17 @@ " provenance." ], "order_independence": [ - "Step 3 evaluates a SET, not a sequence, and step 4 resolves by a declared", - "relation rather than by position. This is the whole reason the procedure is", - "written this way: an `if/elif` chain makes the answer depend on the order", - "someone happened to type the branches in, and that order is invisible in the", - "output. Two mappers implementing this contract must agree, and they cannot", - "agree about something neither of them records." + "Nothing in the procedure depends on the order anything was written in.", + "Steps 3 and 4 evaluate SETS - of rules, and of the candidate bindings each", + "rule matched - and neither asks which came first. Step 6 resolves by a", + "DECLARED relation rather than by position: `dominance` is pairwise and", + "explicit, and the theorem in `what_the_subset_sweep_proves` quantifies over a", + "subset and a relation with no sequence anywhere in the argument.", + "This is the whole reason the procedure is written in stages: an `if/elif`", + "chain makes the answer depend on the order someone happened to type the", + "branches in, and that order is invisible in the output. Two mappers", + "implementing this contract must agree, and they cannot agree about something", + "neither of them records." ], "rules": { "R-CONT-SAME-SITE": { From a22cabe047ee4fd839ab69788afc83d31f0bd704 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:37:12 +0000 Subject: [PATCH 13/84] docs(identity): explain the decision policy, and gate the doc on the contract docs/finding-lineage-decision.md. Not a restatement of the JSON: it explains the four things that turned out to be load-bearing, each of them found by trying to write something down precisely and discovering the draft could not survive it. 1. THREE SETS, NOT ONE. match -> filter -> applicable -> licensed, and why the middle stage exists: a rule matching several candidates has singled out nobody, so it never reaches arbitration, where it would "disagree" with a rule that actually licensed something. Uniqueness is per RULE, not per outcome. And the recall set is the union, because a rule dropped at the filter is exactly a mapping that would change if that rule got sharper. 2. THE FLOOR IS NOT THE ALGORITHM. Three kinds survive in defeated-signal-drops-below-the-floor against a floor of two, and nothing is licensed. Two is the minimum a mapping must clear; it never says any two suffice, and same_path plus same_pattern_id describes a twin. 3. CARDINALITY IS A PRECONDITION. One copy record, two readings - a multiplicity or a move - and the guard chooses, instead of one section licensing `branched` and another overturning it. 4. WHAT THE SUITE REFUSES TO COMPUTE. Applicability is the fixtures' to declare; arbitration is mechanical and may not disagree with them. A checker that decided which rules a real evidence record produces would be a second mapper, hidden and unversioned. Plus the rule table, the defeaters, unavailable inputs, arbitration and the theorem, every refusal the policy can emit, and the seven-case matrix with what each case is for. THE DOC IS GATED, same as step 0's. The suite requires it to name the contract, to keep saying no mapper exists, and to mention every preregistered case, every rule id, and every reason value the policy can emit. That last check earned its place immediately: it failed on first run because the doc explained neither `ambiguous-candidates` nor `no-mapping-evidence`, which the policy emits and a reader would have met with no explanation anywhere. Both now have a section, and four probes confirm the gate bites - a renamed case, a renamed rule, a changed status line, a changed contract name. Full CI run locally: both python jobs verbatim, in normal and -O modes, and the SARIF export smoke step. All pass. The two PowerShell job pairs are unaffected by this branch and need a Windows runner. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- docs/finding-lineage-decision.md | 322 ++++++++++++++++++++++++ identity/tests/test_lineage_decision.py | 18 ++ 2 files changed, 340 insertions(+) create mode 100644 docs/finding-lineage-decision.md diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md new file mode 100644 index 0000000..72f0382 --- /dev/null +++ b/docs/finding-lineage-decision.md @@ -0,0 +1,322 @@ +# Lineage decision policy — which evidence licenses which conclusion + +**Status: contract frozen, implementation not started.** This is Own.NET#266 +slice 2, step 1. There is still no mapper, and nothing in `aggregate/`, +`identity/pattern.py`, `identity/occurrence.py` or `runtime/` changes here. + +Step 0 froze [`finding-lineage/v1`](../contracts/finding-lineage-v1.json): *what +a mapping may conclude*, and *what it must show* for the claim. It deliberately +left one question open, and said so — which combinations of evidence are enough. +That is what +[`finding-lineage-decision/v1`](../contracts/finding-lineage-decision-v1.json) +answers, and it is subordinate: where the two appear to disagree, the outcome +contract wins and this one is the thing that is wrong. + +| | question | contract | +|---|---|---| +| step 0 | what may a mapping conclude, and what must it show? | `finding-lineage/v1` | +| step 1 | which evidence licenses which conclusion, and what happens when two answers apply at once? | `finding-lineage-decision/v1` | +| step 2+ | how is it computed? | *no mapper exists* | + +## Four things that turned out to be load-bearing + +Most of this document is those four. Each was found by trying to write something +down precisely and discovering the draft could not survive it. + +### 1. Three sets, not one: match → filter → applicable → licensed + +A mapping does not "find the rule that fits". It runs stages, and each stage's +output is recorded: + +``` +signals evaluated + -> DEFEAT remove signals an explaining record accounts for + -> MATCH every rule whose predicate holds; one or several candidates + -> FILTER uniqueness (1:1) and cardinality (1:N, N:1) + -> applicable_rules + -> ARBITRATE dominance, refusals + -> licensed_by +``` + +The distinction between MATCH and FILTER is the one that matters, and this file +had it wrong for a commit. A rule may match *several* candidate partners — that +is a fact about its predicate, not a defect. `R-CONT-DRIFT` does not ask about +content, so in a symbol holding two occurrences of one pattern it matches both. +It has therefore singled out nobody, and a 1:1 outcome requires exactly one +candidate. So it does not survive the filter, does not enter +`applicable_rules`, and does not reach arbitration — where it would otherwise +"disagree" with a rule that actually licensed something. + +Uniqueness is checked **per rule**, not per outcome. In the same shape +`R-CONT-SAME-SITE` *does* ask about content and singles out one candidate. The +discriminating rule licenses; the blunt one is recorded as unable to choose. +Were uniqueness checked per outcome, the pair would collapse into ambiguity and +a real continuation would be thrown away. + +**And the recall set is the union, not a field.** "Which mappings must I +revisit if I change this rule?" is answered by + +``` +recall = applicable_rules + + decision_detail.rules_without_a_unique_candidate + + any further explicitly recorded rejected stage +``` + +A rule dropped for matching two candidates is *exactly* a mapping that would +change if that rule were made more discriminating — and it is not in +`applicable_rules` at all. This is why a rejected stage is **recorded** rather +than computed and discarded: a stage whose output is thrown away cannot be +recalled from. + +### 2. The floor is not the algorithm + +Step 0 sets `minimum_evidence_kinds_for_continued = 2` and calls it a floor. +The temptation, when the structural rules turned out to need a per-partner +condition, was to write "at least two kinds". It is wrong, and the fixture that +proves it is `defeated-signal-drops-below-the-floor`: + +- a formatter pass defeats `anchored_content`; +- **three** kinds survive — `same_path`, `same_pattern_id`, + `structural_context` — comfortably above a floor of two; +- and no rule is satisfied, because no rule asks for that combination. + +Two kinds is the minimum a mapping must clear. It never says any two suffice: +`same_path` plus `same_pattern_id` is two kinds and describes a twin, which is +the shape the whole contract exists to refuse. Choosing *which* combinations +license *what* is the job step 0 left to step 1, and answering it with a +cardinal number hands the job back. + +That distinction now has its own vocabulary. The senior contract was amended to +carry both: + +| reason | means | +|---|---| +| `insufficient-evidence-kind` | the floor was not cleared. The count is the problem. | +| `insufficient-evidence-combination` | the floor was cleared, sometimes comfortably, and no declared combination survives. | + +Reporting the second as the first says the evidence was thin when it was ample +and simply did not add up to a rule anyone declared. The integrity suite checks +this mechanically: a case declaring a defeat must declare what survived, and +the `-kind` reason must sit below the floor while `-combination` must sit at or +above it. + +### 3. Cardinality is a precondition, not a repair + +Every rule declares its shape — `1:1` for the four continued rules, `1:N` with +`min_successors: 2` for the branch, `N:1` with `min_predecessors: 2` for the +fold — and a rule whose shape does not fit simply does not apply. + +The alternative was to let a lone copy record license `branched` and have +`arbitration.multiplicity` overturn it a section later. That is one question +answered in two places, and the two places are a theological dispute between +halves of a JSON document waiting to be debugged at the worst possible moment. + +The boundary is worth a case of its own, and has one. In +`copy-record-dominates-the-single-match` a copy record explains a *multiplicity* +and licenses `branched` over a perfectly good 1:1 match. In +`copy-at-one-to-one-is-not-a-branch` the same kind of record, with one +successor, explains a *move* — `R-BRANCH-COPY` is guarded out and `R-CONT-COPY` +answers. One record, two readings; cardinality chooses. + +### 4. What the suite refuses to compute + +`identity/tests/test_lineage_decision.py` never decides which rules a real +evidence record produces. That is **applicability**, it is domain reasoning, and +a checker doing it would be a second mapper — hidden, unversioned, and worse +dressed than the first. + +So the work is split, and the split is the point: + +| | who answers | how it is checked | +|---|---|---| +| *does this evidence produce that rule set?* | the fixtures, by declaring it | not computed by anything, yet | +| *given that rule set, what is the outcome?* | the arbitration algebra | mechanically, on every fixture | +| *is the algebra well-defined at all?* | a written proof | axioms checked on the real policy; the theorem falsified against a fixed model | + +A fixture declares `applicable_rules`; the suite then requires its declared +outcome and `licensed_by` to equal what arbitrating that set yields. Fixture and +algebra may not describe two different policies — which is a real failure this +project shipped for one commit, when the contract demanded "exactly one rule +dominates every other" while the proof and the checker used surviving outcome +classes. + +Everything else the suite checks is about records being **carried** rather than +asserted: a claimed defeat must be present in revision B *and name this +occurrence*, on the key the catalog says it matches on; a claimed unavailable +input must appear in the field the contract declares; a rule may not be +applicable while requiring a signal that was defeated or never evaluated. + +## The rules + +Six, deliberately small and deliberately not exhaustive. + +| id | outcome | shape | requires | +|---|---|---|---| +| `R-CONT-SAME-SITE` | `continued` | 1:1 | `same_path`, `structural_context`, `anchored_content` | +| `R-CONT-DRIFT` | `continued` | 1:1 | `same_path`, `structural_context`, `line_drift` | +| `R-CONT-RENAME` | `continued` | 1:1 | `path_rename`, `structural_context`, `anchored_content` | +| `R-CONT-COPY` | `continued` | 1:1 | `copy_record`, `structural_context`, `anchored_content` | +| `R-BRANCH-COPY` | `branched` | 1:N, N>=2 | `copy_record` (group) + per successor: `same_pattern_id`, `anchored_content` | +| `R-MERGE-FOLD` | `merged` | N:1, N>=2 | `merge_record` (group) + per predecessor: `same_pattern_id`, `anchored_content` | + +**No rule licenses `ended` or `new`.** Those are earned by boundary evidence, +which step 0 already governs; a rule for them here would create a second, +competing route to a death or a birth — the exact defect that contract was +corrected for. + +**Rules cite IDs, never a confidence score.** A scalar collapses *which* signals +fired into a number, and then "would this mapping still be made under the new +rule?" stops having an answer. This is a deliberate no, not an omission: if a +confidence class is ever wanted it must be derived *from* the cited rules and +stored beside them, never instead of them. + +### Defeaters act earlier, and on a different object + +| signal | defeated by | why | +|---|---|---| +| `structural_context` | `renamed_symbol_record` | a symbol that was renamed matches by name only by accident | +| `anchored_content` | `reformatted_paths` | equality across a formatter is a property of the formatter | + +A defeated signal is **removed**, not down-weighted — weight is the thing this +contract refuses to have. So defeaters always beat positive signals, not because +they are stronger but because they act *earlier*: they remove an input rather +than argue with a conclusion. + +### An input that could not be evaluated is not an input that failed + +With no diff, `path_rename` cannot be evaluated. `R-CONT-RENAME` therefore does +not apply — and it did not *fail*. An occurrence that really did move with a +renamed file comes out `unresolved`, naming the missing input, instead of `ended` +plus `new`. Slower, and true. This is the absence-of-record doctrine at a third +layer: the runtime witness refuses to read an unread heap as a clean one, the +occurrence contract refuses an ambiguous anchor rather than guessing, and here a +missing diff must not read as "the file did not move". + +## Arbitration + +Rules apply as a **set**. When several apply: + +- **all agree** — that outcome, and `licensed_by` is every applicable rule. +- **they disagree** — `unresolved` / `conflicting-evidence`, *unless* after + removing every rule dominated by another applicable rule the survivors are + non-empty and all name the same outcome. Then that is the outcome and + `licensed_by` is every survivor. +- **a declared refusal pair is present** — `unresolved`, absorbing. + +Dominance is declared **pairwise and explicitly**. There is no global ranking, +no priority number, no tie-break by name — all three would decide conflicts +nobody has looked at. The structural rules dominate the 1:1 readings: a record +read at group cardinality knows why the partners are several, and a 1:1 rule +reached its answer by not looking. + +One pair is deliberately **not** resolved. An occurrence that is both a copy +source and a participant in a fold is N:M, and none of the six outcomes is N:M. +Neither rule is more informed — each holds a record the other does not consult, +and both records are true — so the answer is `unresolved` / +`conflicting-evidence`, with both ids visible in `applicable_rules` and +`decision_detail.conflicting_rules` while `licensed_by` is empty. Nothing +licensed the refusal; the refusal is what happens when nothing can. + +*That pair was not noticed while the rules were being written.* The pair +completeness check found it on its first run. + +### Every refusal this policy can emit + +Four, and all four are drawn from step 0's vocabulary. **The decision layer +invents no reason values.** When a situation is genuinely unsayable in the senior +vocabulary the senior contract is amended in the open — which has now happened +twice, both times because this rule fired on review rather than because anyone +remembered it unprompted. + +| reason | when | +|---|---| +| `no-mapping-evidence` | nothing was observed that could license a link — including the case where the deciding signal could not be *evaluated*, with `inputs_unavailable` naming which | +| `insufficient-evidence-combination` | evidence was observed, some of it defeated, and no declared combination survives | +| `ambiguous-candidates` | several partners, and nothing prefers one — a choice with no grounds | +| `conflicting-evidence` | one observed structure supporting incompatible conclusions — grounds that argue with each other | + +The last two are neighbours and opposites, and the distinction is worth keeping +sharp. A draft of this file reported a rule conflict as `ambiguous-candidates`, +which was closer than inventing a value and still wrong: ambiguous candidates are +several partners you cannot choose between, not two readings that contradict each +other. `conflicting-evidence` was added to the senior contract for that, and +`insufficient-evidence-combination` for the floor case above. + +`ambiguous-candidates` is also what makes twins work without any rule mentioning +twins. Two occurrences of one pattern in one file agree on path, pattern and +content; a rule that does not include `structural_context` is satisfied by both, +and uniqueness turns that into a refusal instead of into whichever candidate was +enumerated first. Where a structural record *explains* the multiplicity, it is a +branch or a fold instead — that record is the whole difference between a +multiplicity and an ambiguity. + +### Two axioms and a theorem + +1. **Pair completeness** — every different-outcome pair of declared ids is + classified exactly once, as a dominance edge or as a deliberate refusal. +2. **Dominance sanity** — edges name existing ids, never self-edge, always span + different outcomes, never point both ways, and form no cycle. +3. **Totality** — *follows from* 1 and 2 plus the absorbing refusal. Survivors + cannot span two outcome classes (any two would form a classified pair, and + whichever way it points one of them has an incoming edge) and cannot be empty + (a finite acyclic digraph has a source). + +The argument never mentions the number of rules. It used to be re-derived by +enumerating this policy's own classification space, which charged an exponential +in *k* for a *k*-independent proof: one extra rule took it from 3^7 x 31 = 67 797 +to 3^9 x 63 = 1 240 029 arbitrations, and adding an ordinary rule became a CI +compute decision. So the theorem is now falsified against a **fixed** four-rule +model, outcomes partitioned A, A, B, C — chosen because it contains the shape +the survivor reading turns on, two rules of one outcome surviving together — and +called what it is: bounded falsification, not proof. The real policy is still +verified directly and completely, at linear cost. + +## The preregistered matrix + +Seven cases in `identity/fixtures/lineage-decision/`, fixed before any mapper, +gated on **exact** equality with `preregistered_cases`. Each is a shape where two +answers are available and the policy has to say which, or say neither. + +| # | case | expected | +|---|---|---| +| 1 | `copy-record-dominates-the-single-match` | `branched` — over a real, applicable 1:1 match | +| 2 | `merge-record-dominates-the-single-match` | `merged` — over a real, applicable 1:1 match | +| 3 | `copy-at-one-to-one-is-not-a-branch` | `continued` — cardinality guards the branch out; the deletion is defeated | +| 4 | `defeated-signal-drops-below-the-floor` | `unresolved` / `insufficient-evidence-combination` | +| 5 | `renamed-symbol-defeats-structural-context` | `unresolved` — the name matched by accident | +| 6 | `unavailable-diff-is-not-a-deletion` | `unresolved` — **not** `ended` plus `new` | +| 7 | `blunt-rule-loses-to-uniqueness` | `continued` — and the blunt rule recorded as unable to choose | + +Cases 1 and 2 carry an extra burden, because a fixture can be right for the +wrong reason: move the losing rule out of `applicable_rules` and the answer is +still `branched`, with dominance doing no work whatever. The suite cannot notice +on its own — that is applicability again — so `case_obligations` preregisters +what each case must *mechanically* exhibit, and both cases fail if their loser is +quietly reclassified. + +**The matrix has already paid for itself.** Trying to write cases 1 and 2 is what +proved the structural rules could not license the outcomes step 0 had already +frozen: their per-partner condition read "a rule of outcome `continued`", every +continued rule requires `structural_context`, and both structural cases change +the enclosing symbol. Three frozen edges had no applicable rule, and a fourth had +one naming the *wrong* outcome — which is worse than a gap. That is also how +`R-CONT-COPY` was found missing. + +A half-matrix of the cases that happened to pass would have hidden all of it, +which is why partial credit is not on offer. + +## What is deliberately not decided here + +- **The mapper.** Still none. This is the decision policy, not the code. +- **Whether a rule set this small is enough.** It is explicitly not exhaustive. + Adding a rule is a contract edit: declare it, declare its cardinality and + partner profile, and classify its conflicts. The completeness law covers + unreachable pairs too, which costs a line and buys the absence of a conflict + nobody looked at. +- **Whether the fixtures resemble real history.** They do not claim to. Every + case here is constructed, and the suite verifies structure rather than + reality — which is exactly the gap the next gate exists to close. Before any + of this becomes production code it has to be run against a real repository's + history and then judged on whether the mappings are *useful* downstream, not + merely well-formed. Architecture that survives only its own fixtures has + classified its author's imagination. diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index e69b3ff..f4c573d 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -82,6 +82,7 @@ SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") FIXDIR = os.path.join(ROOT, "identity", "fixtures", "lineage-decision") +DOC = os.path.join(ROOT, "docs", "finding-lineage-decision.md") # Reviewed cost ceiling for the theorem falsifier, in arbitrations. It bounds a # FIXED four-rule model - 3^5 * 15 = 3645 - and not this policy's own @@ -760,6 +761,23 @@ def main() -> int: f"no preregistered case reaches {wanted!r}; the policy can license it and " "nothing pins how") + # ---- 4c. THE DOC AND THE CONTRACT AGREE. -------------------------------- + # Same gate step 0 uses. A document that stops naming a case, or keeps saying + # a mapper exists when one does not, is worse than no document: it is a + # confident description of something else. + with open(DOC, encoding="utf-8") as fh: + doc = fh.read() + check("finding-lineage-decision/v1" in doc, "the doc must name the contract it freezes") + check("implementation not started" in doc, + "the doc must keep saying no mapper exists, until one does") + for case_name in preregistered: + check(case_name in doc, f"the doc does not mention preregistered case {case_name!r}") + for rid in ids: + check(rid in doc, f"the doc does not mention rule {rid!r}") + for reason in sorted(emitted): + tail = reason.rsplit(":", 1)[-1] + check(tail in doc, f"the doc does not mention the reason {tail!r} the policy emits") + # ---- 5. META: the proof checks are checked, in-process. ---------------- # a05edeb was titled "move the arbitration proofs out of the terminal" and # moved the RESULT while the meta-proofs stayed in scratch scripts. These are From e888b2bb647c32f8adf6ee4ceb6fe8bb9186254a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:06:31 +0000 Subject: [PATCH 14/84] fix(identity): close eight checks that passed while their claim was false External review round on a22cabe: three findings from Codex, two plus three nitpicks from CodeRabbit. All eight verified against the code before being accepted - each one reproduced as a mutation that left the suite green. No false positives from either bot. Every one of them is the same failure: a check that passes while the thing it names is false. That is the class this suite exists to prevent one level up. CODEX 1. A defeat did not mandate its reason. The two arithmetic checks were separate `if`s keyed on the reason, so a fixture naming a THIRD reason entered neither and passed. Switching both defeat fixtures to `no-mapping-evidence` - the exact claim the senior contract was amended last round to stop - left the suite green. The reason is now mandated first, arithmetic second. The mixed shape (a defeat AND an unevaluable input) is left unmandated ON PURPOSE and says so: the contract has not settled it and no case needs it, and deciding it here by whichever branch was typed first is the defect the staged procedure exists to remove. 2. "Linear cost" was false. Removing the 3^k factor took 3^k x (2^n - 1) down to 2^n - 1; it did not make the remainder linear. Six rules sweep 63 subsets, twelve sweep 4095, twenty sweep over a million. The contract, the doc and the suite now say so, and the sweep carries its own reviewed ceiling (SUBSET_SWEEP_BUDGET = 4095) that fails CI rather than growing quietly. The design is unchanged: the real-policy sweep stays. 3. An empty `applicable_rules` was treated as one situation. Nothing matched, and everything matched but singled nobody out, are different refusals with different reasons - and the recorded rejected stage is what tells them apart. A fixture could claim every rule failed uniqueness and still report `no-mapping-evidence`. Now a non-empty `rules_without_a_unique_candidate` requires `ambiguous-candidates`, which is what arbitration.multiplicity says. CODERABBIT A. The continued floor counted list length, not distinct kinds, so `["same_path", "same_path"]` cleared a floor of two with one real kind - in the file whose entire argument is that the floor is a floor. `requires_all` now rejects repeats and counts a set, as `partner_profile` already did. B. `boundary_defeated` computed `role` from the contract's `match` and then ignored it, always taking `frm[0]`. A `successor.*` boundary was therefore checked against the PREDECESSOR: a fixture could defeat a boundary that never applied to the side it names. It now follows the declared role and checks every occurrence on that side, not the first. NITPICKS, one of which was not a nitpick N1. E741: `l` -> `loser`. N3. B905: `zip(..., strict=True)`. N2. The "arbitration input must be a set of distinct rule ids" check could not fail - `itertools.combinations` never repeats an element. Last commit removed a vacuous permutation sweep, wrote a comment about vacuous checks not being evidence, and installed another vacuous check in its place, which is worse than leaving the first because it looks like the lesson was learned. The suggested replacement - checking `policy["rules"]` for duplicate ids - is also vacuous, one step later: `json.load` collapses duplicate object keys silently, so the duplicate is gone by then, and one rule with it. Verified. Replaced with a guard that can actually fail: a raw parse via `object_pairs_hook` over both contracts, so a rule, limitation or evidence kind declared twice is named instead of vanishing between the file and every check below it. Eight probes, one per fix, each confirmed to bite. 20 python suites green in normal and -O; CI python job run verbatim; ASCII-clean, JSON valid. Not done, deliberately: docstrings for `check()` and `load()` to satisfy a coverage threshold. Say so if that should change. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 10 +- docs/finding-lineage-decision.md | 11 +- identity/tests/test_lineage_decision.py | 161 +++++++++++++++++---- 3 files changed, 154 insertions(+), 28 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 9c00ab2..b3eb29f 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -616,7 +616,15 @@ " - THIS POLICY is verified directly and completely: exact pair completeness,", " dominance sanity, the full 2^n - 1 subset sweep of the real rule set,", " absorbing refusals, and mutations proving each checker bites. That cost is", - " linear in the policy and it stays.", + " exponential in the RULE COUNT, and budgeted for it.", + " Calling that cost linear, as an earlier revision of this section did, was", + " simply wrong. Removing the 3^k factor took 3^k x (2^n - 1) down to 2^n - 1;", + " it did not make the remainder linear. Six rules sweep 63 subsets, twelve", + " sweep 4095, twenty sweep over a million - so this sweep carries a reviewed", + " ceiling of its own, failing CI when it is exceeded rather than growing", + " quietly until someone notices the bill. The k-independent argument bought", + " a much smaller exponent, not the absence of one, and the difference is", + " exactly the kind of claim this file exists to stop making.", " - THE THEOREM is falsified against a FIXED synthetic model - four rules", " partitioned A, A, B, C, every classification of its pairs enumerated. Its", " size does not move when a production rule is added, so adding one is a", diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 72f0382..6bb92ba 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -268,8 +268,15 @@ to 3^9 x 63 = 1 240 029 arbitrations, and adding an ordinary rule became a CI compute decision. So the theorem is now falsified against a **fixed** four-rule model, outcomes partitioned A, A, B, C — chosen because it contains the shape the survivor reading turns on, two rules of one outcome surviving together — and -called what it is: bounded falsification, not proof. The real policy is still -verified directly and completely, at linear cost. +called what it is: bounded falsification, not proof. + +The real policy is still verified directly and completely — every subset of its +own rule ids. That sweep is **exponential in the rule count**, `2^n - 1`: six +rules sweep 63 subsets, twelve sweep 4095, twenty sweep over a million. An +earlier revision of this document called it linear, which was wrong — dropping +the `3^k` factor bought a far smaller exponent, not the absence of one. So it +carries a reviewed ceiling of its own and fails CI when exceeded, rather than +growing quietly until someone notices the bill. ## The preregistered matrix diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index f4c573d..898af74 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -90,6 +90,16 @@ # is a FAILURE, not a downgrade: see meta-check 5d. EXHAUSTIVE_PROOF_BUDGET = 250_000 +# Reviewed ceiling for the REAL-POLICY subset sweep, in subsets. This one IS +# exponential in the rule count - 2^n - 1, every subset enumerated and retained. +# What the previous commit removed was the 3^k factor in front of it, not the +# exponential itself, and calling the remainder "linear" was simply wrong: six +# rules sweep 63 subsets, twelve sweep 4095, twenty sweep over a million. +# So it gets the same treatment as the falsifier - a reviewed ceiling that fails +# CI rather than a claim that the cost is fine. 4095 is twelve rules, which is +# double the current set and still trivial to run. +SUBSET_SWEEP_BUDGET = 4095 + fails: list[str] = [] @@ -103,6 +113,27 @@ def load(path: str) -> dict: return json.load(fh) +def duplicate_json_keys(path: str) -> list: + """Keys declared twice in one object, found during the RAW parse. + + Has to be its own read: `json.load` resolves duplicates by keeping the last, + so nothing downstream can tell a duplicated key from a single one. Returns + dotted paths so the message can name where.""" + dups: list = [] + + def hook(pairs): + seen = set() + for key, _ in pairs: + if key in seen: + dups.append(key) + seen.add(key) + return dict(pairs) + + with open(path, encoding="utf-8") as fh: + json.load(fh, object_pairs_hook=hook) + return sorted(set(dups)) + + def as_list(v) -> list: if v is None: return [] @@ -216,18 +247,18 @@ def pair_completeness_failures(ids, outcomes, raw_edges, raw_refusals) -> list: def dominance_sanity_failures(ids, outcomes, raw_edges) -> list: """PROPERTY 2, pure for the same reason.""" out, edges = [], set(raw_edges) - for (w, l) in sorted(edges): + for (w, loser) in sorted(edges): if w not in outcomes: out.append(f"dominance names unknown winner {w!r}") - if l not in outcomes: - out.append(f"dominance names unknown loser {l!r}") - if w == l: + if loser not in outcomes: + out.append(f"dominance names unknown loser {loser!r}") + if w == loser: out.append(f"{w} dominates itself") - if w in outcomes and l in outcomes and outcomes[w] == outcomes[l]: - out.append(f"{w} dominates {l} but both name {outcomes[w]!r}; dominance " + if w in outcomes and loser in outcomes and outcomes[w] == outcomes[loser]: + out.append(f"{w} dominates {loser} but both name {outcomes[w]!r}; dominance " "settles disagreements, and there is none") - if (l, w) in edges: - out.append(f"{w} and {l} dominate each other") + if (loser, w) in edges: + out.append(f"{w} and {loser} dominate each other") if has_cycle(ids, edges): out.append("the dominance graph has a cycle. Every rule in it is dominated, " "nothing survives, and arbitration answers `unresolved` while " @@ -303,9 +334,18 @@ def main() -> int: f"{rid} names outcome {outcomes[rid]!r}, which is not one of the six") check(outcomes[rid] not in ("ended", "new"), f"{rid} licenses {outcomes[rid]!r}; boundary evidence owns that, not a rule") + # The floor counts KINDS, so the check must too. Counting list length let + # `["same_path", "same_path"]` clear a floor of two with one real kind - + # in the same file whose whole argument is that the floor is a floor. + # `partner_profile` already got this right; `requires_all` did not. + req_all = rules[rid].get("requires_all", []) + check(len(set(req_all)) == len(req_all), + f"{rid}: requires_all repeats a kind. The floor counts kinds, so a " + "repeat is either a typo or an attempt to reach the floor twice over " + "the same evidence.") if outcomes[rid] == "continued": - check(len(rules[rid].get("requires_all", [])) >= floor, - f"{rid} licenses continued on {len(rules[rid].get('requires_all', []))} " + check(len(set(req_all)) >= floor, + f"{rid} licenses continued on {len(set(req_all))} distinct " f"kind(s); the frozen floor is {floor}") # Cardinality is a PRECONDITION of the rule, so it is checked as one. The @@ -372,11 +412,22 @@ def main() -> int: check(False, msg) # ---- 3. TOTALITY - a THEOREM of 1 and 2, re-checked exhaustively. ------- + # Exponential, and budgeted for it. See SUBSET_SWEEP_BUDGET. expected = 2 ** len(ids) - 1 + if expected > SUBSET_SWEEP_BUDGET: + check(False, + f"the real-policy subset sweep needs 2^{len(ids)} - 1 = {expected} " + f"subsets, over the reviewed ceiling of {SUBSET_SWEEP_BUDGET}. This sweep " + "is exponential in the rule count - it always was, and the k-independent " + "argument only removed the 3^k factor in front of it. Either raise the " + "reviewed ceiling or rely on the written proof instead of re-checking it " + "by enumeration. Do not skip it silently.") + expected = 0 results = {} - for size in range(1, len(ids) + 1): - for sub in itertools.combinations(ids, size): - results[frozenset(sub)] = arbitrate(sub, outcomes, edges, refusals) + if expected: + for size in range(1, len(ids) + 1): + for sub in itertools.combinations(ids, size): + results[frozenset(sub)] = arbitrate(sub, outcomes, edges, refusals) check(len(results) == expected, f"swept {len(results)} subsets, expected {expected} for {len(ids)} rules") undefined = sorted((sorted(s) for s, r in results.items() if r is None), key=len) @@ -391,12 +442,25 @@ def main() -> int: # asked Python whether a set remembers order. It does not, and confirming # that 720 times is not evidence about this contract. # Order-independence is already carried by the theorem, whose every step is - # stated over subsets and a relation. What IS worth pinning is that the input - # is a set of distinct ids in the first place - the shape the argument needs. - check(all(len(set(sub)) == len(sub) for sub in - itertools.chain.from_iterable(itertools.combinations(ids, n) - for n in range(1, len(ids) + 1))), - "arbitration input must be a set of distinct rule ids") + # stated over subsets and a relation. + # + # A REPLACEMENT THAT WAS ALSO VACUOUS. The first attempt at "something worth + # pinning" checked that every swept subset holds distinct ids - and + # `itertools.combinations` never repeats an element, so it asked Python a + # question with one possible answer. Removing a vacuous check and installing + # another one under a comment about vacuous checks is worse than leaving the + # first: it looks like the lesson was learned. + # `len(set(ids)) == len(ids)` would be vacuous too, one step later: `json.load` + # collapses duplicate object keys silently, so by the time the ids are a dict + # the duplicate is already gone - along with one of the two rules. + # What CAN fail is the raw parse, so that is what is checked, on the contracts + # that carry rules and vocabulary this suite reads by key. + for label, cpath in (("decision policy", POLICY), ("outcome contract", SENIOR)): + for dup in duplicate_json_keys(cpath): + check(False, + f"the {label} declares {dup!r} twice. `json.load` keeps the last one " + "and drops the other without a word, so a rule, a limitation or an " + "evidence kind would vanish between the file and every check below.") for pair in refusals: for subset, res in results.items(): @@ -511,6 +575,21 @@ def main() -> int: check(outcome == "unresolved", f"{where}: no rule applied, so the outcome is unresolved") check(not lic, f"{where}: nothing applied, so nothing licensed") + # AN EMPTY SET IS NOT ONE SITUATION. Nothing matched, and + # everything matched but singled nobody out, are different + # refusals with different reasons - and the recorded rejected + # stage is what tells them apart. Treating every empty + # `applicable_rules` alike let a fixture claim all rules failed + # uniqueness and still report `no-mapping-evidence`. + blunted = ((exp.get("decision_detail") or {}) + .get("rules_without_a_unique_candidate") or []) + if blunted: + check(exp.get("reason") == senior["limitations"] + .get("ambiguous-candidates"), + f"{where}: {sorted(blunted)} matched and failed uniqueness, " + "so several candidates were available with nothing to " + "prefer between them. `arbitration.multiplicity` calls that " + f"ambiguous-candidates, not {exp.get('reason')!r}.") else: got = arbitrate(app, outcomes, edges, refusals) check(got is not None, f"{where}: arbitration has no result for {sorted(app)}") @@ -636,6 +715,24 @@ def main() -> int: # would replace that diagnosis with a traceback. A suite that dies on # the evidence it came to read has happened in this repo before. lim = senior["limitations"] + # MANDATE THE REASON FIRST, then check its arithmetic. These used to + # be two independent `if`s keyed on the reason, so a fixture that + # named a THIRD reason entered neither branch and passed - and + # `no-mapping-evidence` after a defeat is exactly the claim the + # senior contract was amended to stop: evidence was observed, then + # explained away, so saying none was seen is false. + insufficiency = {lim.get("insufficient-evidence-kind"), + lim.get("insufficient-evidence-combination")} + if not as_list(exp.get("inputs_unavailable")): + check(reason in insufficiency, + f"{where}: a defeat removed {sorted(exp['signals_defeated'])} " + f"and the reason is {reason!r}. Evidence was observed and then " + "explained away, so the reason is an insufficiency one: " + "-kind below the floor, -combination at or above it.") + # A defeat AND an unevaluable input together is a shape the contract + # has not settled and no case has yet. It is left unmandated ON + # PURPOSE rather than decided here by whichever branch was typed + # first; settle it in the contract when a case needs it. if reason == lim.get("insufficient-evidence-kind"): check(len(set(surviving)) < floor, f"{where}: claims insufficient KINDS, but {len(set(surviving))} " @@ -683,12 +780,26 @@ def main() -> int: f"contract, which lists {spec['defeated_by']!r}") check(bool(collect(rev_b, defeater)), f"{where}: claims {kind!r} is defeated by {defeater}, which is empty") - subject = by_id.get((frm or to or [None])[0], {}) + # FOLLOW THE DECLARED ROLE. This used to take `frm[0]` and fall + # back to `to[0]`, computing `role` and then ignoring it - so a + # `successor.*` boundary was checked against the PREDECESSOR, and a + # fixture could defeat a boundary that never applied to the side it + # names. Every occurrence on the declared side is checked, not the + # first one. role, attr = spec["match"].split(".") - check(subject.get(attr) in collect(rev_b, spec["observable_from"]), - f"{where}: {kind!r} would not have applied anyway - " - f"{spec['observable_from']} does not name {subject.get(attr)!r}, so " - "defeating it proves nothing") + subjects = frm if role == "predecessor" else to + check(bool(subjects), + f"{where}: {kind!r} matches on {spec['match']}, and this " + f"expectation names no {role}. There is nothing for the boundary " + "to have applied to, so defeating it proves nothing.") + observed = collect(rev_b, spec["observable_from"]) + for oid in subjects: + wanted = by_id.get(oid, {}).get(attr) + check(wanted in observed, + f"{where}: {kind!r} would not have applied to {oid} anyway - " + f"{spec['observable_from']} holds {sorted(observed)!r}, which " + f"does not name its {attr} {wanted!r}, so defeating it proves " + "nothing") # ---- the case's PREREGISTERED OBLIGATION, not just its answer. ------- # A case can reach the right outcome for the wrong reason: move the losing @@ -866,7 +977,7 @@ def main() -> int: satisfying, counterexamples, saw_multi_survivor = 0, [], False for combo in itertools.product((0, 1, 2), repeat=mk): e, rf = [], [] - for (x, y), state in zip(model_pairs, combo): + for (x, y), state in zip(model_pairs, combo, strict=True): if state == 0: e.append((x, y)) elif state == 1: From 340c6ad6e7bd01bae520abff1c503f5768cd84a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:15:19 +0000 Subject: [PATCH 15/84] fix(identity): separate the rejecting stages, and bind refusals to their branch Second Codex round, on e888b2b. Five findings, all confirmed against the code before acceptance, each reproduced as a mutation that left the suite green. No false positives. Three of them are the general case behind fixes that were made point-wise last round, which is the more useful half of the review. 1. CARDINALITY REJECTIONS HAD NO STAGE OF THEIR OWN. Step 4 recorded both filters in `rules_without_a_unique_candidate` - so a group rule dropped for being the wrong shape was labelled an ambiguity, though no ambiguity occurred. Meanwhile the fixture invented `excluded_by_cardinality`, a field absent from `record_additions` and from `recall_set`: a mapper had to either mislabel the rejection or drop it from re-judgement, and dropping it is worse. Now two declared fields, both in the recall union, and the union is stated as a rule that every future rejecting stage joins in the same breath as it is added - a rule this file had already broken once. 2. THE MODULE HEADER STILL CLAIMED LINEAR COST. Last commit corrected the contract and the doc and left the docstring saying the opposite, sixty lines above the constant that budgets the exponential. Two opposite complexity claims in one file is worse than either alone. 3. TWO SPELLINGS, TWO LOCATIONS for defeated signals: `decision_detail` described `defeated_signals` while the record declares top-level `signals_defeated`, and `reason_mapping` repeated the former. Two conforming implementations could have produced different JSON, each following part of the contract. One spelling now, top level, with the abandoned one named. 4. THE DEFEAT REASON WAS UNCONDITIONAL. `reason_mapping` mapped every defeat to `insufficient-evidence-combination`, which is right only while enough kinds survive - and a defeat leaving zero or one kind is below the floor, where the shortage really is of kinds. The entry that asked the senior contract for that distinction was contradicting it. Now conditional on the surviving count, and the vocabulary sweep reads both entry shapes and requires exactly one of them. 5. REFUSAL REASONS WERE CHECKED FOR VOCABULARY, NOT FOR BRANCH. Any of the six passed: `missing-occurrence-id` was accepted on a case whose occurrences both carry ids. `mandated_reason()` now derives the required value from what the fixture declares about its own stages - applied rules, defeats and their surviving count, unavailable inputs, and each rejecting stage - and compares. It returns None, explicitly, when several rejecting stages fired at once: the contract has not ranked them, and a checker that picked one would be settling a contract question in a test. Ten probes, all confirmed to bite, including Codex's own falsification verbatim. 20 python suites green in normal and -O; JSON valid; ASCII clean. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 68 +++++++----- .../copy-at-one-to-one-is-not-a-branch.json | 11 +- identity/tests/test_lineage_decision.py | 103 ++++++++++++++++-- 3 files changed, 139 insertions(+), 43 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index b3eb29f..88b6cb2 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -43,10 +43,17 @@ "3. MATCH every rule against the surviving set. A rule may match ONE candidate", " binding or SEVERAL - matching is about the predicate, not about how many", " partners answer it.", - "4. FILTER by uniqueness and cardinality. A 1:1 rule with anything other than", - " exactly one candidate does not survive; a group rule below its declared", - " minimum does not survive. Rules that matched and died here are recorded in", - " `decision_detail.rules_without_a_unique_candidate`.", + "4. FILTER. Two different rejections happen here and they are RECORDED", + " SEPARATELY, because they are not the same fact and a recall set built from", + " one of them would miss the other:", + " - a 1:1 rule with anything other than exactly one candidate matched but", + " singled nobody out -> `decision_detail.rules_without_a_unique_candidate`", + " - a group rule below its declared minimum matched a shape it does not", + " govern; no ambiguity occurred at all ->", + " `decision_detail.rules_excluded_by_cardinality`", + " An earlier revision put both in the first field. A mapper following it had", + " to either call a cardinality rejection an ambiguity or drop it from the", + " record, and the second is worse.", "5. APPLY: the survivors of step 4 are `applicable_rules`. All of them, not the", " first match.", "6. ARBITRATE by `arbitration`, over `applicable_rules` only. The result is the", @@ -342,8 +349,8 @@ "record_additions": { "applicable_rules": "the DISTINCT ids of rules with a SURVIVING application - matched, then past uniqueness and cardinality, per `decision_procedure` step 5. A set of ids, not a count of applications. Rules that matched and failed uniqueness are NOT here; they are in `decision_detail.rules_without_a_unique_candidate`, and both are needed for re-judgement - see `recall_set`.", "licensed_by": "the SURVIVING rules the outcome rests on - the applicable rules that no other applicable rule dominates, per `arbitration.licensed_by_rule`. Non-empty for continued, branched and merged; EMPTY on a refused conflict.", - "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: conflicting_rules, inputs_unavailable, defeated_signals, and rules_without_a_unique_candidate - the rules that fired on several candidates and so licensed no 1:1 mapping. That last one is not cosmetic: without it a blunt rule losing to a discriminating one leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither; it failed to single anyone out.", - "signals_defeated": "signal name -> the record that defeated it. Present whenever step 2 removed anything.", + "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: `conflicting_rules`, `inputs_unavailable`, `rules_without_a_unique_candidate` (matched, singled nobody out) and `rules_excluded_by_cardinality` (matched, wrong shape - no ambiguity involved). The last two are not cosmetic: without them a rule that lost before arbitration leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither.", + "signals_defeated": "signal name -> the record that defeated it. TOP LEVEL, and spelled this way everywhere: an earlier revision also called it `defeated_signals` inside `decision_detail`, so two conforming implementations could have produced different JSON while each followed part of this contract. Present whenever step 2 removed anything.", "inputs_unavailable": "signal names that could not be evaluated. Present whenever any were." }, "preregistered_cases": [ @@ -450,22 +457,25 @@ "detail": "inputs_unavailable, when the reason nothing applied is that a signal could not be evaluated" }, "no_rule_applied_after_a_defeat": { - "reason": "lineage-id-unavailable:insufficient-evidence-combination", - "detail": "defeated_signals: signal -> the record that removed it", + "reason_by_surviving_kinds": { + "below_the_floor": "lineage-id-unavailable:insufficient-evidence-kind", + "at_or_above_the_floor": "lineage-id-unavailable:insufficient-evidence-combination", + "floor": "finding-lineage/v1 minimum_evidence_kinds_for_continued, counted over DISTINCT surviving kinds" + }, + "detail": "`signals_defeated` (top level): signal name -> the record that removed it", "why": [ "Distinguished from `no-mapping-evidence` because the two are not the same", "situation and a consumer reads this field. When a defeater fires, evidence WAS", "observed - it was removed because a record explained it away - and saying `no", "mapping evidence` would be false in the plain meaning of the words.", - "And distinguished from `insufficient-evidence-kind`, which this file reported", - "for one review before the senior contract was corrected. In", - "`defeated-signal-drops-below-the-floor` THREE kinds survive against a floor of", - "two: the kinds are ample, and it is the COMBINATION that no declared rule", - "accepts. Naming that a shortage of kinds would have said the evidence was thin", - "when it was plentiful and simply did not add up to a rule anyone declared.", - "`insufficient-evidence-kind` keeps its own job: the senior floor genuinely not", - "cleared. This one is the case where the floor is cleared and the floor was", - "never the algorithm." + "CONDITIONAL, not a single value. An earlier revision mapped every defeat to", + "`insufficient-evidence-combination` unconditionally, which is right only while", + "enough kinds survive. A formatter or rename defeat that leaves ZERO or ONE kind", + "is below the senior floor, and there the shortage really is of kinds - which is", + "the distinction the senior contract was amended to carry, contradicted here by", + "the very entry that asked for it.", + "In `defeated-signal-drops-below-the-floor` three kinds survive against a floor", + "of two: ample kinds, and no declared combination accepts them." ] }, "conflicting_rules": { @@ -759,20 +769,18 @@ "", " recall = applicable_rules", " + decision_detail.rules_without_a_unique_candidate", + " + decision_detail.rules_excluded_by_cardinality", " + any further explicitly recorded rejected stage", "", - "`applicable_rules` alone stopped being the recall set the moment uniqueness", - "became a filter in front of it. A rule that matched two candidates and was", - "dropped there is exactly a mapping that would CHANGE if that rule were made", - "more discriminating - and it does not appear in `applicable_rules` at all. A", - "recall set built from that field alone would miss it, and miss it silently,", - "which is the failure mode this project keeps finding in its own drafts.", - "This is why the rejected stage is RECORDED rather than merely computed and", - "discarded. A stage whose output is thrown away cannot be recalled from, and", - "`rules_without_a_unique_candidate` exists to be part of this union rather than", - "as a courtesy to the reader.", - "Any future stage that rejects a rule joins this union in the same breath as it", - "is added. A stage without a recorded output is a stage that silently narrows", - "re-judgement." + "`applicable_rules` alone stopped being the recall set the moment filters", + "appeared in front of it. A rule dropped for matching two candidates is exactly", + "a mapping that would CHANGE if that rule were made more discriminating; a rule", + "dropped for being the wrong shape is exactly a mapping that would change if its", + "cardinality were relaxed. Neither appears in `applicable_rules` at all, and a", + "recall set built from that field alone would miss both, silently.", + "EVERY REJECTING STAGE JOINS THIS UNION IN THE SAME BREATH AS IT IS ADDED.", + "That is the rule, and it has already been broken once: cardinality became a", + "filter without a field of its own, so its rejections were either mislabelled as", + "ambiguities or lost. A stage whose output is discarded cannot be recalled from." ] } diff --git a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json index ba22cc6..3753569 100644 --- a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json +++ b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json @@ -53,9 +53,11 @@ "licensed_by": [ "R-CONT-COPY" ], - "excluded_by_cardinality": [ - "R-BRANCH-COPY" - ], + "decision_detail": { + "rules_excluded_by_cardinality": [ + "R-BRANCH-COPY" + ] + }, "boundary_defeated": { "boundary:containing-file-deleted": "revision_b.copies[].from" }, @@ -74,7 +76,8 @@ "ended for occ-a1 on the strength of deleted_paths - the copy record defeats it", "unresolved - the frozen case forbids it by name", "new for occ-b1", - "R-BRANCH-COPY appearing in applicable_rules - it is guarded out, not out-argued" + "R-BRANCH-COPY appearing in applicable_rules - it is guarded out, not out-argued", + "R-BRANCH-COPY absent from decision_detail - a rule excluded by cardinality must stay in the recall set, or relaxing that guard later cannot find the mappings it would change" ], "contract": "finding-lineage-decision/v1", "status": "preregistered-unimplemented" diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 898af74..4ef8521 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -54,7 +54,10 @@ ------------------------------ The real policy is verified directly and completely: properties 1 and 2 on its own declarations, plus the full 2^n - 1 subset sweep of its own rule ids. That -cost is linear in the policy and it stays. +sweep is EXPONENTIAL in the rule count - six rules sweep 63 subsets, twelve sweep +4095 - and it carries a reviewed ceiling that fails CI rather than growing +quietly. An earlier revision of this docstring called it linear while the code +below said the opposite in the same file, which is worse than either claim alone. The THEOREM is not re-derived from this policy's classification space. It used to be - 3^k over the k conflicting pairs - and that was an exponential in k paid @@ -134,6 +137,47 @@ def hook(pairs): return sorted(set(dups)) +def mandated_reason(exp, lim, floor): + """The reason the policy REQUIRES for this refusal, or None if the contract + has not settled the shape. + + Derived from what the fixture DECLARES about its own stages - not from any + evidence reasoning. Checking only that a reason is in the senior vocabulary + accepted any of the six: `missing-occurrence-id` passed on a case whose + occurrences both carry ids, because nothing tied the value to the branch. + None means deliberately unmandated, and the caller says so rather than + guessing - a shape nobody has settled must not be settled by whichever + condition was typed first.""" + app = exp.get("applicable_rules") or [] + defeated = exp.get("signals_defeated") or {} + unavailable = as_list(exp.get("inputs_unavailable")) + detail = exp.get("decision_detail") or {} + blunted = detail.get("rules_without_a_unique_candidate") or [] + miscard = detail.get("rules_excluded_by_cardinality") or [] + + if app: + # Rules applied and the answer is still a refusal: they disagreed. + return lim.get("conflicting-evidence"), "rules applied and disagreed" + # More than one rejecting stage fired at once. Each has its own reason and + # the contract does not rank them; picking one here would be this file + # deciding a contract question in a checker. + stages = [bool(defeated), bool(unavailable), bool(blunted)] + if sum(stages) > 1: + return None, "several rejecting stages fired; the contract has not ranked them" + if defeated: + surviving = {k for k in (exp.get("evidence_surviving") or [])} + if len(surviving) < floor: + return lim.get("insufficient-evidence-kind"), "a defeat left fewer kinds than the floor" + return lim.get("insufficient-evidence-combination"), "a defeat left the floor cleared" + if blunted: + return lim.get("ambiguous-candidates"), "rules matched and singled nobody out" + if unavailable: + return lim.get("no-mapping-evidence"), "a signal could not be evaluated" + if miscard: + return lim.get("no-mapping-evidence"), "the only matches were the wrong shape" + return lim.get("no-mapping-evidence"), "nothing matched at all" + + def as_list(v) -> list: if v is None: return [] @@ -317,7 +361,22 @@ def main() -> int: "empty is a claim, and it has to be written down as one") senior_limitations = set(senior["limitations"].values()) - emitted = {v["reason"] for v in policy["reason_mapping"].values()} + # A mapping entry declares EITHER one reason or a conditional set of them. + # `no_rule_applied_after_a_defeat` became conditional when a defeat leaving + # fewer kinds than the floor turned out to need the other limitation, so the + # sweep reads both shapes - and requires exactly one of them, because an entry + # carrying both would emit a value nothing selects. + emitted = set() + for key, spec in policy["reason_mapping"].items(): + single, conditional = "reason" in spec, "reason_by_surviving_kinds" in spec + check(single != conditional, + f"reason_mapping[{key}] must declare exactly one of `reason` or " + "`reason_by_surviving_kinds`, not both and not neither") + if single: + emitted.add(spec["reason"]) + if conditional: + emitted |= {v for k, v in spec["reason_by_surviving_kinds"].items() + if str(v).startswith("lineage-id-unavailable:")} emitted.add(policy["arbitration"]["none_applies"]["reason"]) emitted.add(policy["arbitration"]["conflict"]["reason"]) emitted.add(policy["arbitration"]["multiplicity"]["several_candidates_without_one"]["reason"]) @@ -648,6 +707,14 @@ def main() -> int: check(exp.get("reason") in senior_limitations_set, f"{where}: reason {exp.get('reason')!r} is not a senior limitation") check(not lic, f"{where}: a refusal licenses nothing") + # AND it must be the reason the POLICY BRANCH mandates. Vocabulary + # membership alone accepted any of the six - `missing-occurrence-id` + # passed on a case whose occurrences both carry ids. + want, why_branch = mandated_reason(exp, senior["limitations"], floor) + if want is not None: + check(exp.get("reason") == want, + f"{where}: {why_branch}, so `reason_mapping` mandates {want!r}, " + f"not {exp.get('reason')!r}") else: check("reason" not in exp, f"{where}: {outcome} must not carry an identity limitation") @@ -762,12 +829,29 @@ def main() -> int: f"{where}: {rid} is applicable but requires the unevaluable " f"signal {sig!r}") - for rid in ((exp.get("decision_detail") or {}) - .get("rules_without_a_unique_candidate") or []): - check(rid in rules, f"{where}: unknown rule {rid!r} in decision_detail") - check(rid not in app, - f"{where}: {rid} had no unique candidate, so it had no surviving " - "application and must not be in applicable_rules") + # Both rejecting stages are recorded, and neither may overlap the set + # of rules that survived. They are separate fields because they are + # separate facts: matched-but-chose-nobody is an ambiguity, matched- + # but-wrong-shape is not, and `recall_set` is the union of both with + # `applicable_rules`. + detail = exp.get("decision_detail") or {} + for field, why_it_lost in ( + ("rules_without_a_unique_candidate", + "had no unique candidate, so it had no surviving application"), + ("rules_excluded_by_cardinality", + "was excluded by its own cardinality guard")): + for rid in (detail.get(field) or []): + check(rid in rules, + f"{where}: unknown rule {rid!r} in decision_detail.{field}") + check(rid not in app, + f"{where}: {rid} {why_it_lost} and must not be in " + "applicable_rules") + overlap = (set(detail.get("rules_without_a_unique_candidate") or []) + & set(detail.get("rules_excluded_by_cardinality") or [])) + check(not overlap, + f"{where}: {sorted(overlap)} recorded as BOTH unable to choose and " + "excluded by cardinality. A rule lost at one stage, and the two " + "answer different questions when the rule is later changed.") for kind, defeater in (exp.get("boundary_defeated") or {}).items(): spec = next((s for s in senior["boundary_evidence_kinds"].values() @@ -827,7 +911,8 @@ def main() -> int: met |= bool((exp.get("decision_detail") or {}) .get("rules_without_a_unique_candidate")) elif duty == "cardinality_excluded_a_rule": - excluded = exp.get("excluded_by_cardinality") or [] + excluded = ((exp.get("decision_detail") or {}) + .get("rules_excluded_by_cardinality") or []) for rid in excluded: check(rid in rules, f"{name}: unknown rule {rid!r} in " "excluded_by_cardinality") From 0f4236b9eb0232e905585228046481541c030fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:30:00 +0000 Subject: [PATCH 16/84] fix(identity): finish the stage split the previous commit left half-done Third Codex round, on 340c6ad. Five findings, all confirmed, and all five are consequences of the commit before this one. That is the honest summary: adding a rejecting stage touched five places and the previous commit updated three of them. 1. `mandated_reason` computed `miscard` and left it out of the multi-stage count, so a refusal recording BOTH rejecting stages was treated as uniqueness-only and forced to `ambiguous-candidates` - silently ranking cardinality below uniqueness in the one function written to refuse exactly that. Pre-fix and post-fix on the same input: `ambiguous-candidates` vs None. 2. Rule accounting unioned only the uniqueness stage, so a fixture recording a rule solely under the new cardinality stage read as never mentioning it. Hidden today because the copy fixture repeats that rule in `not_applicable`. 3. The doc's "Every refusal" section still said four and listed four, while the policy can now emit five - the below-floor defeat. The gate stayed green because the missing token appears in a different section a hundred lines earlier, which is a weaker check than it looked: token presence anywhere is not the same as a table being exhaustive. The gate now parses that table and requires it to equal what the policy emits, in both directions. 4. `case_obligations` still described the abandoned top-level `excluded_by_cardinality`, so a fixture author following the contract would produce a shape the checker rejects and the recall union never sees. 5. `inputs_unavailable` still had two homes - named inside `decision_detail` and declared top level. The same ambiguity as `signals_defeated`, sitting one line away from it, fixed for one twin and not the other. TWO OF MY OWN PROBES WERE WRONG, in the direction that flatters. Both asserted the mutation should turn the suite red, when a correct fix makes those cases GREEN: the multi-stage refusal is deliberately unmandated, and a rule recorded under the cardinality stage is deliberately accounted for. They reported VACUOUS and were re-run as behaviour comparisons - pre-fix versus post-fix on identical input - which is what shows a change of behaviour rather than a change of colour. The accounting check was separately confirmed to still bite when a rule is named nowhere at all. 20 python suites green in normal and -O; JSON valid; ASCII clean. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 6 ++--- docs/finding-lineage-decision.md | 5 ++-- identity/tests/test_lineage_decision.py | 27 +++++++++++++++++++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 88b6cb2..0fefae6 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -349,9 +349,9 @@ "record_additions": { "applicable_rules": "the DISTINCT ids of rules with a SURVIVING application - matched, then past uniqueness and cardinality, per `decision_procedure` step 5. A set of ids, not a count of applications. Rules that matched and failed uniqueness are NOT here; they are in `decision_detail.rules_without_a_unique_candidate`, and both are needed for re-judgement - see `recall_set`.", "licensed_by": "the SURVIVING rules the outcome rests on - the applicable rules that no other applicable rule dominates, per `arbitration.licensed_by_rule`. Non-empty for continued, branched and merged; EMPTY on a refused conflict.", - "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: `conflicting_rules`, `inputs_unavailable`, `rules_without_a_unique_candidate` (matched, singled nobody out) and `rules_excluded_by_cardinality` (matched, wrong shape - no ambiguity involved). The last two are not cosmetic: without them a rule that lost before arbitration leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither.", + "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: `conflicting_rules`, `rules_without_a_unique_candidate` (matched, singled nobody out) and `rules_excluded_by_cardinality` (matched, wrong shape - no ambiguity involved). The last two are not cosmetic: without them a rule that lost before arbitration leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither. NOT `inputs_unavailable` and NOT `signals_defeated`: both are top level, and listing them here as well gave each two homes - the same ambiguity, twice, and the second survived one review after the first was fixed.", "signals_defeated": "signal name -> the record that defeated it. TOP LEVEL, and spelled this way everywhere: an earlier revision also called it `defeated_signals` inside `decision_detail`, so two conforming implementations could have produced different JSON while each followed part of this contract. Present whenever step 2 removed anything.", - "inputs_unavailable": "signal names that could not be evaluated. Present whenever any were." + "inputs_unavailable": "signal names that could not be evaluated. TOP LEVEL, like `signals_defeated`, and declared in exactly one place. Present whenever any were." }, "preregistered_cases": [ "copy-record-dominates-the-single-match", @@ -738,7 +738,7 @@ }, "meanings": { "dominance_did_work": "some rule in `applicable_rules` is absent from `licensed_by` AND is dominated by a rule that is in it. The conflict is real and arbitration resolved it, rather than there having been one candidate all along.", - "cardinality_excluded_a_rule": "the case names `excluded_by_cardinality`, and each rule there is genuinely unable to fit the declared shape - checked against its own `min_successors` / `min_predecessors`, not against prose.", + "cardinality_excluded_a_rule": "the case names `decision_detail.rules_excluded_by_cardinality`, and each rule there is genuinely unable to fit the declared shape - checked against its own `min_successors` / `min_predecessors`, not against prose. An earlier revision of this entry still named a top-level `excluded_by_cardinality`, abandoned when the stage got a declared home; a fixture author following it would have produced a shape the checker rejects and the recall union never sees.", "defeat_removed_a_signal": "`signals_defeated` is non-empty, the defeating record is carried by revision B, and it names this occurrence.", "input_was_unavailable": "`inputs_unavailable` is non-empty and revision B declares the same signal in `unavailable_signals`.", "blunt_rule_recorded": "`decision_detail.rules_without_a_unique_candidate` is non-empty. The blunt rule must be visible as having failed to choose, not merely absent." diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 6bb92ba..696d9b7 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -222,7 +222,7 @@ completeness check found it on its first run. ### Every refusal this policy can emit -Four, and all four are drawn from step 0's vocabulary. **The decision layer +Five, and all five are drawn from step 0's vocabulary. **The decision layer invents no reason values.** When a situation is genuinely unsayable in the senior vocabulary the senior contract is amended in the open — which has now happened twice, both times because this rule fired on review rather than because anyone @@ -231,7 +231,8 @@ remembered it unprompted. | reason | when | |---|---| | `no-mapping-evidence` | nothing was observed that could license a link — including the case where the deciding signal could not be *evaluated*, with `inputs_unavailable` naming which | -| `insufficient-evidence-combination` | evidence was observed, some of it defeated, and no declared combination survives | +| `insufficient-evidence-kind` | a defeat left **fewer** kinds standing than the senior floor — there the shortage really is of kinds | +| `insufficient-evidence-combination` | a defeat left the floor cleared, and no declared combination survives | | `ambiguous-candidates` | several partners, and nothing prefers one — a choice with no grounds | | `conflicting-evidence` | one observed structure supporting incompatible conclusions — grounds that argue with each other | diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 4ef8521..2677c78 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -76,6 +76,7 @@ import itertools import json import os +import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) @@ -161,7 +162,7 @@ def mandated_reason(exp, lim, floor): # More than one rejecting stage fired at once. Each has its own reason and # the contract does not rank them; picking one here would be this file # deciding a contract question in a checker. - stages = [bool(defeated), bool(unavailable), bool(blunted)] + stages = [bool(defeated), bool(unavailable), bool(blunted), bool(miscard)] if sum(stages) > 1: return None, "several rejecting stages fired; the contract has not ranked them" if defeated: @@ -686,9 +687,15 @@ def main() -> int: # Mirror sides (`side: b`) are exempt: they restate the other half of a # refusal already accounted for and raise no new rule question. if exp.get("side") != "b": + # BOTH rejecting stages count as an account of a rule. Listing only + # the uniqueness one meant a fixture recording a rule solely under + # the new cardinality stage was reported as never mentioning it - + # hidden today only because the copy fixture repeats that rule in + # `not_applicable` as well. + _dd = exp.get("decision_detail") or {} named = (set(app) | set(exp.get("not_applicable") or {}) - | set((exp.get("decision_detail") or {}) - .get("rules_without_a_unique_candidate") or [])) + | set(_dd.get("rules_without_a_unique_candidate") or []) + | set(_dd.get("rules_excluded_by_cardinality") or [])) check(named >= set(ids), f"{where}: says nothing about {sorted(set(ids) - named)}. Every rule " "must be accounted for - applicable, not applicable with a reason, or " @@ -973,6 +980,20 @@ def main() -> int: for reason in sorted(emitted): tail = reason.rsplit(":", 1)[-1] check(tail in doc, f"the doc does not mention the reason {tail!r} the policy emits") + # ...and the section that calls itself exhaustive must actually be. Token + # presence anywhere in the file is too weak: the refusal table went stale + # while staying green, because `insufficient-evidence-kind` happened to appear + # in a different section a hundred lines earlier. + table = doc.partition("### Every refusal this policy can emit")[2].partition("\n## ")[0] + check(bool(table), "the doc must carry a section listing every refusal the policy emits") + listed = set(re.findall(r"^\| `([a-z-]+)` \|", table, re.M)) + want = {r.rsplit(":", 1)[-1] for r in emitted} + check(listed == want, + "the refusal table and the policy disagree.\n" + f" table lists: {sorted(listed)}\n" + f" policy emits: {sorted(want)}\n" + " A section that calls itself exhaustive has to be, or a reader " + "implementing from it misses a branch that exists.") # ---- 5. META: the proof checks are checked, in-process. ---------------- # a05edeb was titled "move the arbitration proofs out of the terminal" and From 218136d89ee7b430fe3da27b1901ab8da0d4fe7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:37:32 +0000 Subject: [PATCH 17/84] fix(identity): one authority for the refusal reason, and fail closed on unranked shapes Fourth Codex round, on 0f4236b. One finding - the first sign of convergence after 8, 5 and 5. `mandated_reason` was added as the single place that decides WHICH reason a refusal requires, and the two per-branch mandates it superseded were left in place. Two authorities, and they disagreed: - uniqueness + a defeat, no unavailable input: the blunted check demanded `ambiguous-candidates` while the defeat check demanded an insufficiency reason. Verified by trying all six senior limitations - every one rejected. That shape was UNSATISFIABLE: no fixture could have been written for it, and the failure message would have named whichever check ran first rather than the real problem. - uniqueness + cardinality: `mandated_reason` abstains because the contract has not ranked those stages, and the leftover blunted check re-imposed exactly the ranking the abstention exists to refuse. Both superseded mandates are gone. What stays beside the single authority is ARITHMETIC - a declared `-kind` reason must sit below the floor and a declared `-combination` at or above it - which holds regardless of which stages fired and so cannot contradict anything. AND ABSTAINING IS NOW A FAILURE, NOT A HOLE. Returning None was right for a checker: the contract has not ranked these stages, and picking one in a test would settle a contract question where nobody can review it. But letting the case through unchecked is the other half of the same mistake - it preregisters an answer nothing licenses. An unranked combination now fails with the actual diagnosis: rank the stages in `reason_mapping` first, then write the case. A fixture is how a decision gets frozen, not how one gets skipped. Probed in all three directions: the previously unsatisfiable shape now yields one clear failure naming the unranked stages; uniqueness + cardinality is no longer forced to `ambiguous-candidates`; single-stage mandates still bite. 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 61 ++++++++++++------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 2677c78..8c5c47c 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -635,21 +635,17 @@ def main() -> int: check(outcome == "unresolved", f"{where}: no rule applied, so the outcome is unresolved") check(not lic, f"{where}: nothing applied, so nothing licensed") - # AN EMPTY SET IS NOT ONE SITUATION. Nothing matched, and + # AN EMPTY SET IS NOT ONE SITUATION - nothing matched, and # everything matched but singled nobody out, are different - # refusals with different reasons - and the recorded rejected - # stage is what tells them apart. Treating every empty - # `applicable_rules` alike let a fixture claim all rules failed - # uniqueness and still report `no-mapping-evidence`. - blunted = ((exp.get("decision_detail") or {}) - .get("rules_without_a_unique_candidate") or []) - if blunted: - check(exp.get("reason") == senior["limitations"] - .get("ambiguous-candidates"), - f"{where}: {sorted(blunted)} matched and failed uniqueness, " - "so several candidates were available with nothing to " - "prefer between them. `arbitration.multiplicity` calls that " - f"ambiguous-candidates, not {exp.get('reason')!r}.") + # refusals. WHICH reason each requires is decided in exactly one + # place, `mandated_reason`, and used at the unresolved branch + # below. A per-branch mandate used to sit here too, and the two + # then contradicted each other: uniqueness plus a defeat made + # the suite UNSATISFIABLE - this check demanded + # `ambiguous-candidates` while the defeat check demanded an + # insufficiency reason, so no value could pass - and uniqueness + # plus cardinality quietly re-imposed the very ranking + # `mandated_reason` exists to refuse. else: got = arbitrate(app, outcomes, edges, refusals) check(got is not None, f"{where}: arbitration has no result for {sorted(app)}") @@ -718,7 +714,18 @@ def main() -> int: # membership alone accepted any of the six - `missing-occurrence-id` # passed on a case whose occurrences both carry ids. want, why_branch = mandated_reason(exp, senior["limitations"], floor) - if want is not None: + if want is None: + # FAIL-CLOSED on an unranked shape. Abstaining was the right call + # for a CHECKER - the contract has not ranked these stages, so + # picking one here would settle a contract question in a test. + # But letting the case through unchecked is the other half of the + # same mistake: it preregisters an answer nothing licenses. + check(False, + f"{where}: {why_branch}. No reason can be mandated, so this " + "shape must not be preregistered yet. Rank the stages in " + "`reason_mapping` first, then write the case - a fixture is " + "how a decision gets frozen, not how one gets skipped.") + else: check(exp.get("reason") == want, f"{where}: {why_branch}, so `reason_mapping` mandates {want!r}, " f"not {exp.get('reason')!r}") @@ -789,24 +796,12 @@ def main() -> int: # would replace that diagnosis with a traceback. A suite that dies on # the evidence it came to read has happened in this repo before. lim = senior["limitations"] - # MANDATE THE REASON FIRST, then check its arithmetic. These used to - # be two independent `if`s keyed on the reason, so a fixture that - # named a THIRD reason entered neither branch and passed - and - # `no-mapping-evidence` after a defeat is exactly the claim the - # senior contract was amended to stop: evidence was observed, then - # explained away, so saying none was seen is false. - insufficiency = {lim.get("insufficient-evidence-kind"), - lim.get("insufficient-evidence-combination")} - if not as_list(exp.get("inputs_unavailable")): - check(reason in insufficiency, - f"{where}: a defeat removed {sorted(exp['signals_defeated'])} " - f"and the reason is {reason!r}. Evidence was observed and then " - "explained away, so the reason is an insufficiency one: " - "-kind below the floor, -combination at or above it.") - # A defeat AND an unevaluable input together is a shape the contract - # has not settled and no case has yet. It is left unmandated ON - # PURPOSE rather than decided here by whichever branch was typed - # first; settle it in the contract when a case needs it. + # WHICH reason a defeat requires is `mandated_reason`'s decision, + # made once and applied at the unresolved branch. What is left here + # is ARITHMETIC: whatever reason the fixture declares must be + # consistent with the count it declares. That holds no matter which + # stages fired, so it is safe next to a single authority - a second + # mandate was not. if reason == lim.get("insufficient-evidence-kind"): check(len(set(surviving)) < floor, f"{where}: claims insufficient KINDS, but {len(set(surviving))} " From 01b09984a6eedbe28c5fba1d82076c988e4919ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:46:33 +0000 Subject: [PATCH 18/84] fix(identity): read the mandate from the contract, and check the claims it records Fifth Codex round, on 218136d. Three findings, all confirmed by mutation before acceptance. Round tally: 8, 5, 5, 1, 3 - not the convergence the previous round suggested. 1. `mandated_reason` PICKED LIMITATION VALUES BY NAME and then told the reader "`reason_mapping` mandates X" - a message claiming an authority the code never consulted. Swapping the reasons the policy assigns to `no_rule_applied` and `several_candidates` left the suite green: the vocabulary sweep compares only the resulting SET, while fixtures were judged against the associations hard-coded in the helper. It now derives every branch from `policy["reason_mapping"]`, including the conditional defeat entry, so repointing either kind of entry is caught. 2. A CLAIMED CARDINALITY EXCLUSION WAS NEVER CHECKED TO BE TRUE. Absence from `applicable_rules` was the only requirement, so a rule whose guard the shape satisfies could be recorded as excluded by it - a 1:1 rule listed against a 1:1 shape passed. The arithmetic existed but lived in `case_obligations`, which reaches exactly one fixture. It now runs wherever the field appears. 3. UNAVAILABLE SIGNALS WERE MATCHED AGAINST `requires_all` ONLY, never the per-partner profile - so declaring `same_pattern_id` unavailable left `R-BRANCH-COPY` applicable although every successor profile requires it. Evidence that could not be evaluated was licensing a resolved mapping. On 3: the defeat path had the identical omission and is fixed in the same helper, but that half has NO witness. In every current fixture where a defeatable signal reaches a group rule's profile it also sits in some applicable 1:1 rule's `requires_all` and is caught there first, so a probe for it is confounded rather than convincing. Same code path, same fix, one demonstrated half - said plainly rather than counted as two. Eight probes bite, including both directions of the cardinality claim and both group rules for the profile requirements. 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 83 +++++++++++++++++++------ 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 8c5c47c..b911832 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -138,17 +138,49 @@ def hook(pairs): return sorted(set(dups)) -def mandated_reason(exp, lim, floor): +def rule_needs(rule) -> set: + """Every signal a rule needs to apply - group requirements AND the per-partner + profile. + + Reading `requires_all` alone missed the profile entirely, so declaring + `same_pattern_id` unavailable left `R-BRANCH-COPY` applicable although every + successor profile requires it: evidence that could not be evaluated was + licensing a resolved mapping. The defeat path had the same omission; it was + not independently demonstrable on the current fixtures, because in every case + where a defeatable signal reaches a group rule's profile it also sits in some + applicable 1:1 rule's `requires_all` and is caught there first. Same code + path, same fix, and only one half has a witness.""" + needs = set(rule.get("requires_all") or []) + profile = rule.get("partner_profile") or {} + return needs | set(profile.get("requires_all") or []) + + +def mandated_reason(exp, mapping, floor): """The reason the policy REQUIRES for this refusal, or None if the contract has not settled the shape. + READ FROM `reason_mapping`, not from the senior vocabulary directly. An + earlier version picked limitation values by name and then told the reader + `reason_mapping mandates X` - a message claiming an authority the code never + consulted. Swapping the values the policy assigns to `no_rule_applied` and + `several_candidates` left the suite green, because the vocabulary sweep only + compares the resulting SET while fixtures were judged against the hard-coded + associations here. + Derived from what the fixture DECLARES about its own stages - not from any - evidence reasoning. Checking only that a reason is in the senior vocabulary - accepted any of the six: `missing-occurrence-id` passed on a case whose - occurrences both carry ids, because nothing tied the value to the branch. - None means deliberately unmandated, and the caller says so rather than - guessing - a shape nobody has settled must not be settled by whichever - condition was typed first.""" + evidence reasoning. None means deliberately unmandated, and the caller fails + on it rather than guessing: a shape nobody has ranked must not be settled by + whichever condition was typed first.""" + def of(key, surviving_kinds=None): + spec = mapping.get(key) or {} + if "reason" in spec: + return spec["reason"] + by = spec.get("reason_by_surviving_kinds") or {} + if surviving_kinds is None: + return None + return by.get("below_the_floor" if surviving_kinds < floor + else "at_or_above_the_floor") + app = exp.get("applicable_rules") or [] defeated = exp.get("signals_defeated") or {} unavailable = as_list(exp.get("inputs_unavailable")) @@ -158,7 +190,7 @@ def mandated_reason(exp, lim, floor): if app: # Rules applied and the answer is still a refusal: they disagreed. - return lim.get("conflicting-evidence"), "rules applied and disagreed" + return of("conflicting_rules"), "rules applied and disagreed" # More than one rejecting stage fired at once. Each has its own reason and # the contract does not rank them; picking one here would be this file # deciding a contract question in a checker. @@ -166,17 +198,16 @@ def mandated_reason(exp, lim, floor): if sum(stages) > 1: return None, "several rejecting stages fired; the contract has not ranked them" if defeated: - surviving = {k for k in (exp.get("evidence_surviving") or [])} - if len(surviving) < floor: - return lim.get("insufficient-evidence-kind"), "a defeat left fewer kinds than the floor" - return lim.get("insufficient-evidence-combination"), "a defeat left the floor cleared" + n = len({k for k in (exp.get("evidence_surviving") or [])}) + return (of("no_rule_applied_after_a_defeat", n), + f"a defeat left {n} kind(s) against a floor of {floor}") if blunted: - return lim.get("ambiguous-candidates"), "rules matched and singled nobody out" + return of("several_candidates"), "rules matched and singled nobody out" if unavailable: - return lim.get("no-mapping-evidence"), "a signal could not be evaluated" + return of("no_rule_applied"), "a signal could not be evaluated" if miscard: - return lim.get("no-mapping-evidence"), "the only matches were the wrong shape" - return lim.get("no-mapping-evidence"), "nothing matched at all" + return of("no_rule_applied"), "the only matches were the wrong shape" + return of("no_rule_applied"), "nothing matched at all" def as_list(v) -> list: @@ -713,7 +744,7 @@ def main() -> int: # AND it must be the reason the POLICY BRANCH mandates. Vocabulary # membership alone accepted any of the six - `missing-occurrence-id` # passed on a case whose occurrences both carry ids. - want, why_branch = mandated_reason(exp, senior["limitations"], floor) + want, why_branch = mandated_reason(exp, policy["reason_mapping"], floor) if want is None: # FAIL-CLOSED on an unranked shape. Abstaining was the right call # for a CHECKER - the contract has not ranked these stages, so @@ -768,7 +799,7 @@ def main() -> int: f"= {role}.{attr}, which for {oid} is {wanted!r}; the record " f"holds {sorted(observed)!r} and so defeats nothing here") for rid in app: - check(signal not in rules[rid].get("requires_all", []), + check(signal not in rule_needs(rules[rid]), f"{where}: {rid} is applicable but requires the defeated " f"signal {signal!r}") @@ -827,7 +858,7 @@ def main() -> int: f"holds {declared!r}. Unavailability is a RECORD, which is the " "entire point of the case.") for rid in app: - check(sig not in rules[rid].get("requires_all", []), + check(sig not in rule_needs(rules[rid]), f"{where}: {rid} is applicable but requires the unevaluable " f"signal {sig!r}") @@ -848,6 +879,20 @@ def main() -> int: check(rid not in app, f"{where}: {rid} {why_it_lost} and must not be in " "applicable_rules") + # A CLAIMED CARDINALITY EXCLUSION MUST BE TRUE. Absence from + # `applicable_rules` was the only requirement, so a rule whose + # guard the shape satisfies could be recorded as excluded by it + # - a 1:1 rule listed against a 1:1 shape passed. The arithmetic + # lived only in `case_obligations`, which reaches one fixture. + if field == "rules_excluded_by_cardinality" and rid in rules: + card = rules[rid].get("cardinality") or {} + nf, nt = len(frm), len(to) + blocked = (card.get("min_successors", 0) > nt + or card.get("min_predecessors", 0) > nf) + check(blocked, + f"{where}: {rid} is recorded as excluded by cardinality, " + f"but {card} does not rule out a {nf}:{nt} shape. The " + "guard has to do the excluding, not the record of it.") overlap = (set(detail.get("rules_without_a_unique_candidate") or []) & set(detail.get("rules_excluded_by_cardinality") or [])) check(not overlap, From aa31810fe7a151b3268efcba35a3d103350e5766 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:54:13 +0000 Subject: [PATCH 19/84] test(identity): pin the refusal the contract froze and nothing exercised Sixth Codex round, on 01b0998. Two findings, both on the refusal path, and a third thing they led to. 1. A `deliberately_unresolved_conflicts` entry's own `outcome` and `reason` were decoration. `raw_refusal_pairs` reads only `between`, `arbitrate` hard-codes `unresolved`, and the vocabulary sweep asked only whether the reason is some senior limitation - so turning the frozen N:M entry into `outcome: continued` with a no-mapping-evidence reason left the suite green while that entry, `arbitration.conflict` and `reason_mapping.conflicting_rules` all disagreed. Both fields are now checked against the algebra and the authoritative mapping. 2. `decision_detail.conflicting_rules` was checked nowhere. A conflict fixture could omit it or name unrelated ids and still pass outcome, reason and arbitration. It must now equal the set that disagreed, and must be ABSENT when nothing was refused for disagreeing. AND THE PART THAT MATTERS MORE. Codex noted there was no conflict fixture to witness (2). There was no conflict fixture at all: the N:M refusal - a decision this contract makes deliberately, found by the pair-completeness check rather than by anyone noticing it - was exercised by no preregistered case. A check now requires every declared refusal to have a witness, and it failed on first run, which is the only reason this is written down rather than discovered later. So the matrix gains an eighth case, `copy-source-that-is-also-a-fold-refuses`: Broker/Doc.cs is copied while WireA and WireB fold into Wire, so every occurrence is at once one predecessor with several successors and one of several predecessors reaching one successor. Both structural rules apply, neither is more informed - each holds a record the other does not consult and both records are true - and the honest answer is to refuse with both ids visible and `licensed_by` empty. The fold target is a NEW symbol precisely so no continued rule applies and the case exercises the conflict rather than a crowd. Nine probes bite, including a refusal that claims a licensing rule, and the witness check with the new fixture removed. 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 9 +- docs/finding-lineage-decision.md | 9 +- ...py-source-that-is-also-a-fold-refuses.json | 171 ++++++++++++++++++ identity/tests/test_lineage_decision.py | 62 +++++++ 4 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 0fefae6..6c4947b 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -360,7 +360,8 @@ "defeated-signal-drops-below-the-floor", "renamed-symbol-defeats-structural-context", "unavailable-diff-is-not-a-deletion", - "blunt-rule-loses-to-uniqueness" + "blunt-rule-loses-to-uniqueness", + "copy-source-that-is-also-a-fold-refuses" ], "preregistration_rule": [ "Fixed before the mapper, same as slice 2 step 0. These are about the POLICY", @@ -734,6 +735,9 @@ ], "blunt-rule-loses-to-uniqueness": [ "blunt_rule_recorded" + ], + "copy-source-that-is-also-a-fold-refuses": [ + "refusal_was_recorded" ] }, "meanings": { @@ -741,7 +745,8 @@ "cardinality_excluded_a_rule": "the case names `decision_detail.rules_excluded_by_cardinality`, and each rule there is genuinely unable to fit the declared shape - checked against its own `min_successors` / `min_predecessors`, not against prose. An earlier revision of this entry still named a top-level `excluded_by_cardinality`, abandoned when the stage got a declared home; a fixture author following it would have produced a shape the checker rejects and the recall union never sees.", "defeat_removed_a_signal": "`signals_defeated` is non-empty, the defeating record is carried by revision B, and it names this occurrence.", "input_was_unavailable": "`inputs_unavailable` is non-empty and revision B declares the same signal in `unavailable_signals`.", - "blunt_rule_recorded": "`decision_detail.rules_without_a_unique_candidate` is non-empty. The blunt rule must be visible as having failed to choose, not merely absent." + "blunt_rule_recorded": "`decision_detail.rules_without_a_unique_candidate` is non-empty. The blunt rule must be visible as having failed to choose, not merely absent.", + "refusal_was_recorded": "the case exercises a pair declared in `deliberately_unresolved_conflicts` - both ids applicable together - and records them in `decision_detail.conflicting_rules` with an EMPTY `licensed_by`. Until this obligation existed the N:M refusal was frozen in the contract and pinned by no case at all, which is the shape of defect this matrix exists to catch." }, "rule": [ "Every preregistered case must appear here, and every obligation must be one", diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 696d9b7..2acde5d 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -281,7 +281,7 @@ growing quietly until someone notices the bill. ## The preregistered matrix -Seven cases in `identity/fixtures/lineage-decision/`, fixed before any mapper, +Eight cases in `identity/fixtures/lineage-decision/`, fixed before any mapper, gated on **exact** equality with `preregistered_cases`. Each is a shape where two answers are available and the policy has to say which, or say neither. @@ -294,6 +294,7 @@ answers are available and the policy has to say which, or say neither. | 5 | `renamed-symbol-defeats-structural-context` | `unresolved` — the name matched by accident | | 6 | `unavailable-diff-is-not-a-deletion` | `unresolved` — **not** `ended` plus `new` | | 7 | `blunt-rule-loses-to-uniqueness` | `continued` — and the blunt rule recorded as unable to choose | +| 8 | `copy-source-that-is-also-a-fold-refuses` | `unresolved` / `conflicting-evidence` — N:M, and none of the six outcomes is N:M | Cases 1 and 2 carry an extra burden, because a fixture can be right for the wrong reason: move the losing rule out of `applicable_rules` and the answer is @@ -302,6 +303,12 @@ on its own — that is applicability again — so `case_obligations` preregister what each case must *mechanically* exhibit, and both cases fail if their loser is quietly reclassified. +Case 8 is the one the contract had already decided and nothing pinned. The N:M +refusal was frozen in `deliberately_unresolved_conflicts` — found, as that section +says, by the pair-completeness check rather than by anyone noticing it — and no +fixture exercised it until a suite check demanded a witness. A declared refusal +with no case is a decision frozen in name only. + **The matrix has already paid for itself.** Trying to write cases 1 and 2 is what proved the structural rules could not license the outcomes step 0 had already frozen: their per-partner condition read "a rule of outcome `continued`", every diff --git a/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json b/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json new file mode 100644 index 0000000..25ec7b8 --- /dev/null +++ b/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json @@ -0,0 +1,171 @@ +{ + "case": "copy-source-that-is-also-a-fold-refuses", + "title": "One site is both a copy source and a participant in a fold", + "why": "The refusal the contract declares and no case exercised until now. Broker/Doc.cs is copied to Broker/DocCopy.cs, and in the same revision WireA and WireB are folded into Wire - so every occurrence here is at once one predecessor with several successors and one of several predecessors reaching one successor. That is N:M, and finding-lineage/v1 has six outcomes among which none is N:M. Neither structural rule is more informed than the other: each holds a record the other does not consult, and both records are true. Picking `branched` drops the predecessors the fold names; picking `merged` drops the successors the copy names. The honest answer is to refuse and keep both readings visible, which is what `deliberately_unresolved_conflicts` froze - a decision that until this case was pinned by nothing at all.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.WireA", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-a2", + "path": "Broker/Doc.cs", + "start_line": 88, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.WireB", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "copies": [ + { + "from": "Broker/Doc.cs", + "to": "Broker/DocCopy.cs", + "similarity": 100 + } + ], + "merged_symbols": [ + { + "from": [ + "DocView.WireA", + "DocView.WireB" + ], + "to": "DocView.Wire" + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 40, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-b2", + "path": "Broker/DocCopy.cs", + "start_line": 40, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocCopyView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a1" + ], + "to": [], + "reason": "lineage-id-unavailable:conflicting-evidence", + "applicable_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ], + "licensed_by": [], + "decision_detail": { + "conflicting_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ] + }, + "not_applicable": { + "R-CONT-SAME-SITE": "structural_context fails - the fold replaced WireA and WireB with Wire", + "R-CONT-DRIFT": "structural_context fails for the same reason; line_drift alone carries nothing", + "R-CONT-RENAME": "there is no rename record - the path relation here is a copy", + "R-CONT-COPY": "structural_context fails, and the shape is not 1:1 in either direction" + } + }, + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a2" + ], + "to": [], + "reason": "lineage-id-unavailable:conflicting-evidence", + "applicable_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ], + "licensed_by": [], + "decision_detail": { + "conflicting_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ] + }, + "not_applicable": { + "R-CONT-SAME-SITE": "structural_context fails - the fold replaced WireA and WireB with Wire", + "R-CONT-DRIFT": "structural_context fails for the same reason; line_drift alone carries nothing", + "R-CONT-RENAME": "there is no rename record - the path relation here is a copy", + "R-CONT-COPY": "structural_context fails, and the shape is not 1:1 in either direction" + } + }, + { + "outcome": "unresolved", + "side": "b", + "frm": [], + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:conflicting-evidence", + "applicable_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ], + "licensed_by": [], + "decision_detail": { + "conflicting_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ] + } + }, + { + "outcome": "unresolved", + "side": "b", + "frm": [], + "to": [ + "occ-b2" + ], + "reason": "lineage-id-unavailable:conflicting-evidence", + "applicable_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ], + "licensed_by": [], + "decision_detail": { + "conflicting_rules": [ + "R-BRANCH-COPY", + "R-MERGE-FOLD" + ] + } + } + ], + "forbid": [ + "branched - it drops the predecessors the fold names", + "merged - it drops the successors the copy names", + "a non-empty licensed_by - nothing licensed this refusal; the refusal is what happens when nothing can", + "ambiguous-candidates as the reason - these are not several partners with nothing to prefer between them, they are one structure read two incompatible ways", + "decision_detail.conflicting_rules omitted - the shape has to stay visible for whoever decides whether a seventh outcome is needed", + "either structural rule dropped from applicable_rules to make the answer look simpler" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index b911832..6ddf4f4 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -418,6 +418,24 @@ def main() -> int: f"the policy emits {reason!r}, which finding-lineage/v1 does not define. " "Amend the senior contract in the open; do not widen it from below.") + # A DECLARED REFUSAL MUST SAY WHAT THE ALGEBRA DOES. `raw_refusal_pairs` reads + # only `between`, and `arbitrate` hard-codes `unresolved`, so the entry's own + # `outcome` and `reason` were decoration: turning the frozen N:M entry into + # `outcome: continued` with a no-mapping-evidence reason left the suite green + # while that entry, `arbitration.conflict` and `reason_mapping.conflicting_rules` + # all disagreed. + conflict_reason = (policy["reason_mapping"].get("conflicting_rules") or {}).get("reason") + for entry in policy["deliberately_unresolved_conflicts"]: + pair = sorted(entry.get("between") or []) + check(entry.get("outcome") == "unresolved", + f"declared refusal {pair} names outcome {entry.get('outcome')!r}. Arbitration " + "answers `unresolved` on a refused pair and nothing else reads this field, " + "so any other value is a claim the algebra contradicts.") + check(entry.get("reason") == conflict_reason, + f"declared refusal {pair} names reason {entry.get('reason')!r}, but " + f"`reason_mapping.conflicting_rules` selects {conflict_reason!r}. One " + "authority, or a mapper reads whichever it happened to open.") + senior_outcomes = set(senior["outcomes"]) floor = senior["minimum_evidence_kinds_for_continued"] for rid in ids: @@ -614,6 +632,7 @@ def main() -> int: check(duty in obligation_meanings, f"{cname}: obligation {duty!r} has no entry in `meanings`") licensed_outcomes: set[str] = set() + exercised_refusals: set = set() for name in sorted(set(on_disk) & set(preregistered)): with open(os.path.join(FIXDIR, f"{name}.json"), encoding="utf-8") as fh: case = json.load(fh) @@ -658,6 +677,9 @@ def main() -> int: f"{where}: licensed_by {sorted(set(lic) - set(app))} is not in " "applicable_rules; a rule cannot license what it never applied to") licensed_outcomes.add(outcome) + for _pair in refusals: + if _pair <= set(app): + exercised_refusals.add(_pair) # THE BINDING CHECK. Applicability is the fixture's to declare; # arbitration is mechanical, so the fixture may not disagree with it. @@ -893,6 +915,24 @@ def main() -> int: f"{where}: {rid} is recorded as excluded by cardinality, " f"but {card} does not rule out a {nf}:{nt} shape. The " "guard has to do the excluding, not the record of it.") + # `conflicting_rules` is the third recorded stage and was checked + # nowhere: a conflict fixture could omit it, or name unrelated ids, and + # still pass its outcome, reason and arbitration checks. + conflicting_ids = detail.get("conflicting_rules") + refused = (outcome == "unresolved" and app) + if refused: + check(conflicting_ids is not None, + f"{where}: rules applied and the answer is a refusal, so the " + "disagreement must be recorded in decision_detail.conflicting_rules") + check(set(conflicting_ids or []) == set(app), + f"{where}: conflicting_rules {sorted(conflicting_ids or [])} is not " + f"the set that disagreed, {sorted(app)}. The record is what a later " + "reader uses to decide whether a seventh outcome is needed.") + else: + check(conflicting_ids is None, + f"{where}: decision_detail.conflicting_rules is set, but nothing " + "was refused for disagreeing here") + overlap = (set(detail.get("rules_without_a_unique_candidate") or []) & set(detail.get("rules_excluded_by_cardinality") or [])) check(not overlap, @@ -957,6 +997,18 @@ def main() -> int: elif duty == "blunt_rule_recorded": met |= bool((exp.get("decision_detail") or {}) .get("rules_without_a_unique_candidate")) + elif duty == "refusal_was_recorded": + dd = exp.get("decision_detail") or {} + for pair in refusals: + if pair <= app_s: + check(set(dd.get("conflicting_rules") or []) == set(app_s), + f"{name}: exercises the declared refusal " + f"{sorted(pair)} but records " + f"{sorted(dd.get('conflicting_rules') or [])}") + check(not lic_s, + f"{name}: a refused conflict licenses nothing, got " + f"{sorted(lic_s)}") + met = True elif duty == "cardinality_excluded_a_rule": excluded = ((exp.get("decision_detail") or {}) .get("rules_excluded_by_cardinality") or []) @@ -999,6 +1051,16 @@ def main() -> int: # available and a rename that simply works is not one. R-CONT-RENAME is # constrained by the senior corpus, where `rename-with-context-continues` is # the only rule that licenses it. + # A declared refusal with no case is a decision nothing pins - the defect this + # project keeps finding in its own drafts. The N:M pair was frozen in the + # contract and exercised by no fixture at all until this check was written. + for pair in sorted(({frozenset(c["between"]) for c in + policy["deliberately_unresolved_conflicts"]}), key=sorted): + check(pair in exercised_refusals, + f"the declared refusal {sorted(pair)} is exercised by no preregistered " + "case. A conflict the contract deliberately refuses is a decision, and a " + "decision with no case is frozen in name only.") + for wanted in sorted({rules[r]["outcome"] for r in ids} | {"unresolved"}): check(wanted in licensed_outcomes, f"no preregistered case reaches {wanted!r}; the policy can license it and " From 41ab38b97adad48eeecd7d98ec441d636834ea8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:00:58 +0000 Subject: [PATCH 20/84] fix(identity): bind all three declarations of a refusal reason to one entry Seventh Codex round, on aa31810. Two findings, and the first had two siblings it did not name. 1. `arbitration.conflict.reason` was never compared with the mapping it implements, so repointing it left the suite green while a mapper reading that section would emit a value the mapping and every fixture reject. Checking the other two branches found the same: `none_applies` and the multiplicity branch were loose as well. This file states each refusal value THREE times - in `arbitration`, in `reason_mapping`, and for a refused pair in `deliberately_unresolved_conflicts` - and only the last was tied down, by the previous commit, one place at a time. Each arbitration branch now DECLARES which `reason_mapping` entry it implements, and the suite requires the two to agree. The correspondence lives in the contract for the same reason `observable_from` does: a checker that knows which branch means which entry holds an opinion nobody can review. 2. `preregistration_rule` still read "SEVEN, not the six first listed" with eight cases declared - prose I wrote when adding the seventh and did not revisit when adding the eighth. Rewritten so the number appears in exactly one place. Two revisions of that paragraph have now gone stale, which is the argument: a count restated in prose goes stale exactly as often as it is restated. The paragraph says the list is the count and names both added cases instead. Six probes bite - each of the three branches repointed, a branch that stops declaring what it implements, one implementing an entry that does not exist, and the mapping moving while the branches stay put. 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 50 ++++++++++++++++------ identity/tests/test_lineage_decision.py | 24 +++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 6c4947b..d131407 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -229,7 +229,8 @@ "arbitration": { "none_applies": { "outcome": "unresolved", - "reason": "lineage-id-unavailable:no-mapping-evidence" + "reason": "lineage-id-unavailable:no-mapping-evidence", + "implements": "no_rule_applied" }, "all_agree": { "outcome": "the shared outcome", @@ -247,6 +248,7 @@ "conflict": { "outcome": "unresolved", "reason": "lineage-id-unavailable:conflicting-evidence", + "implements": "conflicting_rules", "unless": "after removing every rule dominated by another APPLICABLE rule, the surviving rules are non-empty and all name the same outcome; then the outcome is that shared outcome and `licensed_by` is EVERY surviving rule", "licensed_by_on_refusal": "EMPTY. Nothing licensed the refusal.", "why": "two rules reaching different conclusions from the same evidence is a defect in this contract, and a mapper is the wrong place to paper over it. Refusing makes it visible; picking one hides it behind whichever branch was typed first.", @@ -271,7 +273,8 @@ "several_candidates_with_a_structural_record": "branched or merged, per R-BRANCH-COPY / R-MERGE-FOLD, whose `cardinality` guards already require the multiplicity rather than tolerating it", "several_candidates_without_one": { "outcome": "unresolved", - "reason": "lineage-id-unavailable:ambiguous-candidates" + "reason": "lineage-id-unavailable:ambiguous-candidates", + "implements": "several_candidates" }, "why": [ "This is what makes twins work without any rule mentioning twins. Two", @@ -293,6 +296,18 @@ "rule dominates - and it is empty exactly when the outcome is a refusal.", "Dominated rules never appear in it. They stay in `applicable_rules`, where", "the disagreement remains readable without being credited with the answer." + ], + "reason_declarations_rule": [ + "EVERY branch here that names a `reason` also names the `reason_mapping` entry", + "it IMPLEMENTS, and the integrity suite requires the two to agree.", + "The reason is that this file states each refusal value three times - here, in", + "`reason_mapping`, and for a refused pair in `deliberately_unresolved_conflicts`", + "- and for a while nothing tied them together. Repointing any one of the three", + "left the suite green, so a mapper implementing from this section could emit a", + "value the mapping and every fixture reject.", + "The correspondence is declared rather than known by the checker, for the same", + "reason `observable_from` is: a checker that knows which branch means which", + "entry has an opinion nobody can review." ] }, "dominance": { @@ -368,18 +383,27 @@ "rather than the vocabulary: each is a shape where two answers are available", "and the contract has to say which, or say neither.", "The matrix on disk must equal this list EXACTLY - the same gate step 0 uses.", - "It has already paid for itself once: trying to write the first two cases is", + "It has already paid for itself twice: trying to write the first two cases is", "what proved the structural rules could not license the outcomes the senior", - "contract had frozen. A half-matrix of the cases that happened to pass would", - "have hidden that, which is the whole reason partial credit is not offered.", - "SEVEN, not the six first listed. `copy-at-one-to-one-is-not-a-branch` was", - "added during review: `copy-record-dominates-the-single-match` shows a copy", - "record licensing a branch, and nothing showed the same record at 1:1, where", - "the cardinality guard means it explains a MOVE instead. One record, two", - "readings, and cardinality is what chooses - a boundary worth a case of its", - "own rather than a sentence in someone else's. A case may be ADDED with its", - "entry here; none may be removed or renamed to make an implementation look", - "better." + "contract had frozen, and a later check found the declared N:M refusal pinned", + "by no case at all. A half-matrix of the cases that happened to pass would have", + "hidden both, which is why partial credit is not offered.", + "THE LIST ABOVE IS THE COUNT, and this paragraph does not repeat it. Two", + "earlier revisions of these lines said SIX and then SEVEN while the list said", + "otherwise - a number restated in prose goes stale exactly as often as it is", + "restated. Both cases added after the first draft are named here rather than", + "folded in silently:", + " `copy-at-one-to-one-is-not-a-branch` - the same copy record at 1:1, where", + " the cardinality guard means it explains a MOVE rather than a multiplicity.", + " One record, two readings, and cardinality is what chooses.", + " `copy-source-that-is-also-a-fold-refuses` - the N:M shape that", + " `deliberately_unresolved_conflicts` freezes. That refusal was a decision", + " this contract had already made and no fixture exercised; a witness check", + " found it, which is the argument for proofs over the rule set rather than", + " fixtures alone, made twice now.", + "A case may be ADDED with its entry here and an obligation in", + "`case_obligations`; none may be removed or renamed to make an implementation", + "look better." ], "conflict_completeness_rule": [ "PAIR COMPLETENESS. Every pair of DECLARED rule ids naming a different outcome", diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 6ddf4f4..7b47faa 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -424,6 +424,30 @@ def main() -> int: # `outcome: continued` with a no-mapping-evidence reason left the suite green # while that entry, `arbitration.conflict` and `reason_mapping.conflicting_rules` # all disagreed. + # ONE REASON, DECLARED THREE TIMES. Each arbitration branch names the + # `reason_mapping` entry it implements, and the two must agree. Nothing tied + # them together before: repointing `arbitration.conflict.reason` - or + # `none_applies`, or the multiplicity branch, all three were loose - left the + # suite green while a mapper implementing from that section would emit a value + # the mapping and every fixture reject. + branches = [(("arbitration", "none_applies"), policy["arbitration"]["none_applies"]), + (("arbitration", "conflict"), policy["arbitration"]["conflict"]), + (("arbitration", "multiplicity", "several_candidates_without_one"), + policy["arbitration"]["multiplicity"]["several_candidates_without_one"])] + for where_b, node in branches: + key = node.get("implements") + check(key in policy["reason_mapping"], + f"{'.'.join(where_b)} names reason {node.get('reason')!r} and implements " + f"{key!r}, which is not a `reason_mapping` entry. The correspondence is " + "declared, not guessed - a checker that knows which branch means which " + "entry has an opinion nobody can review.") + if key in policy["reason_mapping"]: + want_b = (policy["reason_mapping"][key] or {}).get("reason") + check(node.get("reason") == want_b, + f"{'.'.join(where_b)} says {node.get('reason')!r} but implements " + f"{key!r}, which selects {want_b!r}. One value, stated three times, " + "and all three have to say it.") + conflict_reason = (policy["reason_mapping"].get("conflicting_rules") or {}).get("reason") for entry in policy["deliberately_unresolved_conflicts"]: pair = sorted(entry.get("between") or []) From 480ad1ce5a461d239bac9fb2c3358d332cd283fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:06:52 +0000 Subject: [PATCH 21/84] fix(identity): match the senior anchor shape, and finish the recall formula Eighth Codex round, on 41ab38b. Two findings, both confirmed. 1. THE MATRIX WAS TEACHING A SHAPE THE SENIOR CONTRACT DOES NOT SANCTION. finding-lineage/v1 describes the two refusal sides asymmetrically - side a "names `frm`, leaves `to` empty", side b "names `to`, leaves `frm` null" - and its own corpus follows that exactly. Every b-side expectation here wrote `frm: []`, and `as_list` normalised the difference away before any check saw it. Six anchors corrected, and the raw shape is now asserted BEFORE normalisation, in both directions: a b side must be null, an a side must be an empty list. Normalising a divergence is not the same as allowing it, and a preregistered case is exactly the wrong place to be loose - a mapper or schema consumer reads it as the shape. 2. The documented recall formula still omitted `decision_detail.rules_excluded_by_cardinality`, which the contract's normative definition has carried since the stage was split. Someone implementing recall from the guide would not revisit mappings that change when a cardinality guard is relaxed. Both are the same habit these rounds keep finding: a value or rule updated in one place and not in its twin. That is now the third instance - the reason mapping, the matrix count, and this - so it is worth naming rather than fixing quietly each time. Three probes bite: a b-side anchor back to an empty list, one omitted entirely, and an a-side `to` set to null. The step-0 corpus is untouched and still passes. Doc and contract recall formulas compared term by term. 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- docs/finding-lineage-decision.md | 5 ++++- .../blunt-rule-loses-to-uniqueness.json | 2 +- ...copy-source-that-is-also-a-fold-refuses.json | 4 ++-- .../defeated-signal-drops-below-the-floor.json | 2 +- ...named-symbol-defeats-structural-context.json | 2 +- .../unavailable-diff-is-not-a-deletion.json | 2 +- identity/tests/test_lineage_decision.py | 17 +++++++++++++++++ 7 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 2acde5d..d707003 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -59,11 +59,14 @@ revisit if I change this rule?" is answered by ``` recall = applicable_rules + decision_detail.rules_without_a_unique_candidate + + decision_detail.rules_excluded_by_cardinality + any further explicitly recorded rejected stage ``` A rule dropped for matching two candidates is *exactly* a mapping that would -change if that rule were made more discriminating — and it is not in +change if that rule were made more discriminating; a rule dropped for being the +wrong shape is exactly one that would change if its cardinality were relaxed. +Neither is in `applicable_rules` at all. This is why a rejected stage is **recorded** rather than computed and discarded: a stage whose output is thrown away cannot be recalled from. diff --git a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json index 913d889..f356d48 100644 --- a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json +++ b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json @@ -69,7 +69,7 @@ { "outcome": "unresolved", "side": "b", - "frm": [], + "frm": null, "to": [ "occ-b2" ], diff --git a/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json b/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json index 25ec7b8..50b9f01 100644 --- a/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json +++ b/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json @@ -120,7 +120,7 @@ { "outcome": "unresolved", "side": "b", - "frm": [], + "frm": null, "to": [ "occ-b1" ], @@ -140,7 +140,7 @@ { "outcome": "unresolved", "side": "b", - "frm": [], + "frm": null, "to": [ "occ-b2" ], diff --git a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json index c91b201..ce6006a 100644 --- a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json +++ b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json @@ -65,7 +65,7 @@ { "outcome": "unresolved", "side": "b", - "frm": [], + "frm": null, "to": [ "occ-b1" ], diff --git a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json index 87de1de..4a12a62 100644 --- a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json +++ b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json @@ -69,7 +69,7 @@ { "outcome": "unresolved", "side": "b", - "frm": [], + "frm": null, "to": [ "occ-b1" ], diff --git a/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json b/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json index e1ce637..0e8c576 100644 --- a/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json +++ b/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json @@ -60,7 +60,7 @@ { "outcome": "unresolved", "side": "b", - "frm": [], + "frm": null, "to": [ "occ-b1" ], diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 7b47faa..b1399e8 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -784,6 +784,23 @@ def main() -> int: if outcome == "unresolved": check(exp.get("side") in ("a", "b"), f"{where}: unresolved needs side 'a' or 'b', got {exp.get('side')!r}") + # THE RAW SHAPE, BEFORE `as_list` FLATTENS IT. finding-lineage/v1 + # describes the two sides asymmetrically - side a "leaves `to` + # empty", side b "leaves `frm` null" - and its own corpus follows + # that exactly. This matrix wrote `frm: []` on every b side, and + # `as_list` normalised the difference away, so a preregistered case + # was teaching a shape the senior contract does not sanction to any + # mapper or schema consumer reading it. + if exp.get("side") == "b": + check(exp.get("frm", "missing") is None, + f"{where}: an unresolved(b) leaves `frm` NULL per " + f"finding-lineage/v1, got {exp.get('frm', 'missing')!r}. An " + "empty list is a different claim, and normalising it here " + "would hide the divergence rather than allow it.") + else: + check(exp.get("to") == [], + f"{where}: an unresolved(a) leaves `to` EMPTY per " + f"finding-lineage/v1, got {exp.get('to')!r}") check(exp.get("reason") in senior_limitations_set, f"{where}: reason {exp.get('reason')!r} is not a senior limitation") check(not lic, f"{where}: a refusal licenses nothing") From 9a9baad6a2421e62aa96e1c4a7341fc9cce2e4ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:09:49 +0000 Subject: [PATCH 22/84] fix(identity): make the duplicate-key scan locate, and cover the fixtures CodeRabbit's review of 41ab38b - Merge Risk now Minimal, two findings, both confirmed by mutation. 1. `duplicate_json_keys` promised "dotted paths so the message can name where" and returned bare key names. That is the same defect these rounds keep finding one layer up, in a docstring this time: a description claiming more than the code does. Fixed by making the code locate rather than by trimming the promise - `object_pairs_hook` runs innermost-first and never learns where it is, so each entry now names the key AND the other keys of the object holding it. `reason` appears in a dozen places in the policy; a bare name does not find it. The docstring says what is honestly available and why the path is not. 2. The scan covered the two contracts and not the FIXTURES, which this suite also reads by key. Verified: a case declaring `licensed_by` twice loses the first to `json.load` and the suite stays green, so the case is checked against half of what it says. All eight fixtures are scanned now. CodeRabbit filed the second as a nitpick. It is not one: it is the same silent loss the check was written for, one directory over. Four probes bite - a duplicated `licensed_by` in a fixture, a duplicated `case` at a fixture's top level, a duplicated key in the policy, and one in the senior contract - and each message now names the siblings of the object it sits in. 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 33 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index b1399e8..0dbe223 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -118,18 +118,27 @@ def load(path: str) -> dict: def duplicate_json_keys(path: str) -> list: - """Keys declared twice in one object, found during the RAW parse. + """Keys declared twice in ONE object, found during the RAW parse. Has to be its own read: `json.load` resolves duplicates by keeping the last, - so nothing downstream can tell a duplicated key from a single one. Returns - dotted paths so the message can name where.""" + so nothing downstream can tell a duplicated key from a single one. + + Each entry names the key AND the other keys of the object holding it, because + the bare name is not enough to find it - `reason` appears in a dozen places in + the policy. An earlier docstring promised "dotted paths" while the code + returned bare names, which is the same defect these rounds keep finding one + layer up: a description claiming more than the code does. `object_pairs_hook` + is called innermost-first and never learns where it is, so the siblings are + what can honestly be offered, and the docstring says that instead.""" dups: list = [] def hook(pairs): seen = set() for key, _ in pairs: if key in seen: - dups.append(key) + siblings = sorted({k for k, _ in pairs} - {key}) + dups.append(f"{key!r} (in an object also declaring " + f"{', '.join(siblings) or 'nothing else'})") seen.add(key) return dict(pairs) @@ -588,12 +597,20 @@ def main() -> int: # the duplicate is already gone - along with one of the two rules. # What CAN fail is the raw parse, so that is what is checked, on the contracts # that carry rules and vocabulary this suite reads by key. - for label, cpath in (("decision policy", POLICY), ("outcome contract", SENIOR)): + scanned = [("decision policy", POLICY), ("outcome contract", SENIOR)] + # ...and the FIXTURES, which this suite also reads by key. A case declaring + # `licensed_by` twice loses one to `json.load` in the same silent way, and the + # case is then checked against half of what it says - verified: the first + # declaration vanishes and the suite stays green. + scanned += [(f"fixture {f}", os.path.join(FIXDIR, f)) + for f in sorted(os.listdir(FIXDIR)) if f.endswith(".json")] + for label, cpath in scanned: for dup in duplicate_json_keys(cpath): check(False, - f"the {label} declares {dup!r} twice. `json.load` keeps the last one " - "and drops the other without a word, so a rule, a limitation or an " - "evidence kind would vanish between the file and every check below.") + f"the {label} declares {dup} twice. `json.load` keeps the last one " + "and drops the other without a word, so a rule, a limitation, an " + "evidence kind or half a preregistered expectation would vanish " + "between the file and every check below.") for pair in refusals: for subset, res in results.items(): From 5340f85e82d91ffacdfca1188bcfde6e5bdbba43 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:14:52 +0000 Subject: [PATCH 23/84] fix(identity): reject duplicate ids in licensed_by One of three findings from Codex's review of 480ad1c. The other two are contract questions and are NOT fixed here - see below. `licensed_by` is a SET of surviving rules in the contract, and every validation normalised it through `set(lic)`, so a repeated id passed and the preregistered corpus sanctioned two raw record shapes for one provenance. `applicable_rules` already had the uniqueness check; its twin did not. That is the fourth instance of the same habit in this branch - checked in one place, not in the other. NOT FIXED, AND DELIBERATELY SO. Two findings land on the policy rather than the suite, and inventing answers for them in a checker is exactly what this contract spends its `arbitration` section refusing to do: - SIMULTANEOUS REJECTING STAGES HAVE NO CONTRACT ANSWER. One rule matching several candidates while another misses its cardinality minimum makes both rejecting fields non-empty, and `reason_mapping` selects nothing. The previous commit made the suite REJECT such a fixture, which stops preregistration and does not define production behaviour - a real mapper can meet that evidence and the frozen policy leaves it without a conforming reason. The shape is constructible: two occurrences of one pattern in one symbol, plus a copy record with a single successor. - A B-SIDE REFUSAL CANNOT BIND ITS DEFEAT. The signal catalog binds both defeaters to a `predecessor`, and a b-side refusal has none - `frm` is null, correctly, since the previous commit. So the binding loop runs zero times and the claim is accepted because SOME matching record exists in revision B. Verified: pointing the rename record at an unrelated symbol keeps the suite green. Making it fail-closed shows the true size - exactly the two b-side defeat expectations - but the fix is not mechanical: dropping `signals_defeated` there changes their mandated reason, which is itself the question of whether a b-side refusal inherits the a-side's defeat. Both need a contract decision, not a checker edit. Raised rather than settled. 20 python suites green in normal and -O. Still no mapper. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 0dbe223..f469a92 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -714,6 +714,11 @@ def main() -> int: for rid in app + lic: check(rid in rules, f"{where}: names unknown rule {rid!r}") check(len(set(app)) == len(app), f"{where}: applicable_rules repeats an id") + # `licensed_by` is a SET of surviving rules in the contract, and every + # validation here normalised it through `set(lic)` - so a repeat passed + # and the corpus sanctioned two raw shapes for one provenance record. + # The uniqueness was checked for one field and not its twin, again. + check(len(set(lic)) == len(lic), f"{where}: licensed_by repeats an id") check(set(lic) <= set(app), f"{where}: licensed_by {sorted(set(lic) - set(app))} is not in " "applicable_rules; a rule cannot license what it never applied to") From 49a981b03230a711fa7552fd7db5144db2f145d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:54:33 +0000 Subject: [PATCH 24/84] fix(identity): a refusal must name the occurrence it is about CodeRabbit's review of 5340f85. One finding, confirmed. The raw-shape check added two commits ago verified only the EMPTY counterpart of each refusal side - `frm` null on a b side, `to` empty on an a side - and never that the primary field names anything. So a record like {"outcome": "unresolved", "side": "b", "frm": null, "to": [], "reason": "...no-mapping-evidence", "applicable_rules": [], "licensed_by": []} passed: correct raw shape, and aggregate occurrence accounting did not object because another expectation already claimed the B occurrence. It states an unresolved result about no occurrence at all, which the senior contract does not permit and the step-0 suite has always rejected with `len(frm) == 1` / `len(to) == 1`. Both halves are checked now. Half a rule is the shape of most findings in this branch, and this one is literally that: I ported the empty-counterpart half of a step-0 check and left the anchor half behind. Three probes bite - a b side about no occurrence, an a side about no predecessor, and a b side naming two. 20 python suites green in normal and -O. Still no mapper. The two open contract questions are unchanged and still need a decision: the reason for simultaneous rejecting stages, and whether a b-side refusal inherits the a-side's defeat. Both are in the PR description under Open questions. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index f469a92..a47df68 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -813,16 +813,28 @@ def main() -> int: # `as_list` normalised the difference away, so a preregistered case # was teaching a shape the senior contract does not sanction to any # mapper or schema consumer reading it. + # BOTH HALVES: the empty counterpart AND the anchor. Checking only + # the counterpart accepted a refusal about NO occurrence at all - + # `{side: b, frm: null, to: []}` passed, and aggregate accounting + # did not notice because another expectation already claimed the B + # occurrence. A refusal that names nobody says nothing; step 0 + # requires exactly one on the primary side and so does this. if exp.get("side") == "b": check(exp.get("frm", "missing") is None, f"{where}: an unresolved(b) leaves `frm` NULL per " f"finding-lineage/v1, got {exp.get('frm', 'missing')!r}. An " "empty list is a different claim, and normalising it here " "would hide the divergence rather than allow it.") + check(len(to) == 1, + f"{where}: an unresolved(b) must name exactly one occurrence " + f"in revision B, got {to!r}") else: check(exp.get("to") == [], f"{where}: an unresolved(a) leaves `to` EMPTY per " f"finding-lineage/v1, got {exp.get('to')!r}") + check(len(frm) == 1, + f"{where}: an unresolved(a) must name exactly one predecessor, " + f"got {frm!r}") check(exp.get("reason") in senior_limitations_set, f"{where}: reason {exp.get('reason')!r} is not a senior limitation") check(not lic, f"{where}: a refusal licenses nothing") From 57ec08fe7b7d4122f5bd32a4b1125b4271c7ec3f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 08:57:28 +0000 Subject: [PATCH 25/84] fix(identity): report the real path to a duplicated key CodeRabbit's summary of 5340f85 raised the merge risk over two things. The first - unresolved records with no occupied anchor - was already fixed in 49a981b, one commit after the head it reviewed. The second is mine and stands. `duplicate_json_keys` reported a duplicated key by name plus the sibling keys of its object, and its docstring asserted that a real path was NOT obtainable because `object_pairs_hook` is called innermost-first and never learns where it is. The first half of that is true. The conclusion is false: the hook does not know, but a walk from the root afterwards does, once each parsed object carries what it saw. A five-line dict subclass makes the paths real - `arbitration.conflict.reason`, `expect.[0].licensed_by` - instead of a key and a list of its neighbours. That is worth naming precisely, because it is the third version of this one function and the third time its description and its behaviour disagreed. Version one promised dotted paths and returned bare names. Version two returned siblings and declared the path impossible. Claiming impossibility for something merely inconvenient is the same defect as claiming more than the code does - it just fails in the other direction, and it is the harder one to notice, because a stated limitation reads as rigour. Two probes bite, at depth and through a list: a duplicated `reason` nested three levels into the policy, and a duplicated `licensed_by` inside a fixture's `expect` array. Both now name where. 20 python suites green in normal and -O. Still no mapper. The two open contract questions are unchanged - see the PR description. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 63 +++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index a47df68..47f47b3 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -117,34 +117,57 @@ def load(path: str) -> dict: return json.load(fh) -def duplicate_json_keys(path: str) -> list: - """Keys declared twice in ONE object, found during the RAW parse. +class _Marked(dict): + """A parsed JSON object that remembers the keys declared twice inside it. - Has to be its own read: `json.load` resolves duplicates by keeping the last, - so nothing downstream can tell a duplicated key from a single one. + `json.load` keeps the last of a duplicated key and drops the other without a + word, so the duplication has to be caught during the raw parse. A plain dict + cannot carry that fact to a later walk; this can.""" + __slots__ = ("duplicate_keys",) - Each entry names the key AND the other keys of the object holding it, because - the bare name is not enough to find it - `reason` appears in a dozen places in - the policy. An earlier docstring promised "dotted paths" while the code - returned bare names, which is the same defect these rounds keep finding one - layer up: a description claiming more than the code does. `object_pairs_hook` - is called innermost-first and never learns where it is, so the siblings are - what can honestly be offered, and the docstring says that instead.""" - dups: list = [] +def duplicate_json_keys(path: str) -> list: + """Dotted paths to keys declared twice in ONE object, from the RAW parse. + + Two earlier versions of this function got its own description wrong, in + opposite directions. The first promised dotted paths and returned bare names. + The second returned the key plus its siblings and asserted that a path was not + obtainable, because `object_pairs_hook` is called innermost-first and never + learns where it is. That second claim was false: the hook does not know, but a + walk from the root afterwards does, once each object carries what it saw. So + the paths are real - `arbitration.conflict.reason`, not `reason` - and the + docstring finally matches the code. + + Stating something impossible when it is merely inconvenient is the same defect + this suite keeps finding elsewhere: a description that claims more, or less, + than the code does.""" def hook(pairs): - seen = set() + obj = _Marked(pairs) + seen, dups = set(), [] for key, _ in pairs: if key in seen: - siblings = sorted({k for k, _ in pairs} - {key}) - dups.append(f"{key!r} (in an object also declaring " - f"{', '.join(siblings) or 'nothing else'})") + dups.append(key) seen.add(key) - return dict(pairs) + obj.duplicate_keys = dups + return obj with open(path, encoding="utf-8") as fh: - json.load(fh, object_pairs_hook=hook) - return sorted(set(dups)) + root = json.load(fh, object_pairs_hook=hook) + + found: list = [] + + def walk(node, trail): + if isinstance(node, _Marked): + for key in node.duplicate_keys: + found.append(".".join(trail + [key]) or key) + for key, value in node.items(): + walk(value, trail + [key]) + elif isinstance(node, list): + for i, value in enumerate(node): + walk(value, trail + [f"[{i}]"]) + + walk(root, []) + return sorted(set(found)) def rule_needs(rule) -> set: @@ -607,7 +630,7 @@ def main() -> int: for label, cpath in scanned: for dup in duplicate_json_keys(cpath): check(False, - f"the {label} declares {dup} twice. `json.load` keeps the last one " + f"the {label} declares {dup!r} twice. `json.load` keeps the last one " "and drops the other without a word, so a rule, a limitation, an " "evidence kind or half a preregistered expectation would vanish " "between the file and every check below.") From 5fb95661fe6fedd75b1a7c2807ea31c02d286cec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 09:02:58 +0000 Subject: [PATCH 26/84] test(identity): pin the two refusal reasons nothing exercised Codex on 5340f85. Three findings; one was already fixed two commits earlier, two are real and one of them is larger than reported. STALE: the unresolved-anchor cardinality was fixed in 49a981b. COVERAGE OVER OUTCOMES CANNOT SEE REASONS. The gate treated `unresolved` as covered because some case reaches it. The policy emits FIVE refusal reasons, and enumerating them against the corpus showed two pinned by no case at all: - `ambiguous-candidates` - what `arbitration.multiplicity` selects, the whole twins doctrine, and no fixture reached it; - `insufficient-evidence-kind` - the below-floor half of the distinction the senior contract was amended to carry. Codex named the second. The first was found by enumerating rather than by reading, which is the argument for a gate over reasons instead of a gate over outcomes: several reasons share one outcome and an outcome cannot stand in for them. Both branches could be repointed with the suite still green - and the first probe for that went red for an unrelated reason, the doc table equality check, which pins the VALUE while pinning nothing about the CONDITION that selects it. Distinguishing those two took a second probe. So the matrix gains two cases: - `every-candidate-blunted-is-an-ambiguity` - two successors in one symbol with the same text, so every matching rule matches both and singles nobody out. `applicable_rules` empty while `rules_without_a_unique_candidate` is not, which is a different refusal from nothing having matched. The decision-layer twin of step 0's `two-candidates-unresolved`. - `a-defeat-can-leave-too-few-kinds` - a reformat defeats `anchored_content` and only `same_pattern_id` survives: one kind against a floor of two, which is `insufficient-evidence-kind` in its literal sense. AND THE CANDIDATES NOW HAVE A HOME. `reason_mapping.several_candidates` promised `the candidate ids` as its detail while the record shape defined nowhere to put them - `rules_without_a_unique_candidate` holds RULE ids. A mapper could not implement the promise and a consumer could not see which partners caused the refusal. `decision_detail.ambiguous_candidates` is declared now, required wherever a rule is recorded as unable to choose, and validated: the rule must exist, the candidates must number at least two, and each must be an occurrence of this case. `blunt-rule-loses-to-uniqueness` gained the binding it had been missing since it was written. Six probes bite, including the branch swap that was the whole point. 20 python suites green in normal and -O. Still no mapper. The two open contract questions are unchanged - see the PR description. Refs PhysShell/Own.NET#266 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 14 +- docs/finding-lineage-decision.md | 12 +- .../a-defeat-can-leave-too-few-kinds.json | 86 +++++++++++ .../blunt-rule-loses-to-uniqueness.json | 11 +- ...ery-candidate-blunted-is-an-ambiguity.json | 138 ++++++++++++++++++ identity/tests/test_lineage_decision.py | 35 +++++ 6 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json create mode 100644 identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index d131407..c772a06 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -364,7 +364,7 @@ "record_additions": { "applicable_rules": "the DISTINCT ids of rules with a SURVIVING application - matched, then past uniqueness and cardinality, per `decision_procedure` step 5. A set of ids, not a count of applications. Rules that matched and failed uniqueness are NOT here; they are in `decision_detail.rules_without_a_unique_candidate`, and both are needed for re-judgement - see `recall_set`.", "licensed_by": "the SURVIVING rules the outcome rests on - the applicable rules that no other applicable rule dominates, per `arbitration.licensed_by_rule`. Non-empty for continued, branched and merged; EMPTY on a refused conflict.", - "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: `conflicting_rules`, `rules_without_a_unique_candidate` (matched, singled nobody out) and `rules_excluded_by_cardinality` (matched, wrong shape - no ambiguity involved). The last two are not cosmetic: without them a rule that lost before arbitration leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither. NOT `inputs_unavailable` and NOT `signals_defeated`: both are top level, and listing them here as well gave each two homes - the same ambiguity, twice, and the second survived one review after the first was fixed.", + "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: `conflicting_rules`, `rules_without_a_unique_candidate` (matched, singled nobody out), `rules_excluded_by_cardinality` (matched, wrong shape - no ambiguity involved) and `ambiguous_candidates` (rule id -> the occurrence ids it could not choose between). The rejecting stages are not cosmetic: without them a rule that lost before arbitration leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither. `ambiguous_candidates` is not cosmetic either: `reason_mapping.several_candidates` promised `the candidate ids` as its detail while the record shape defined nowhere to put them, so a mapper could not implement the promise and a consumer could not see which partners caused the refusal. NOT `inputs_unavailable` and NOT `signals_defeated`: both are top level.", "signals_defeated": "signal name -> the record that defeated it. TOP LEVEL, and spelled this way everywhere: an earlier revision also called it `defeated_signals` inside `decision_detail`, so two conforming implementations could have produced different JSON while each followed part of this contract. Present whenever step 2 removed anything.", "inputs_unavailable": "signal names that could not be evaluated. TOP LEVEL, like `signals_defeated`, and declared in exactly one place. Present whenever any were." }, @@ -376,7 +376,9 @@ "renamed-symbol-defeats-structural-context", "unavailable-diff-is-not-a-deletion", "blunt-rule-loses-to-uniqueness", - "copy-source-that-is-also-a-fold-refuses" + "copy-source-that-is-also-a-fold-refuses", + "every-candidate-blunted-is-an-ambiguity", + "a-defeat-can-leave-too-few-kinds" ], "preregistration_rule": [ "Fixed before the mapper, same as slice 2 step 0. These are about the POLICY", @@ -509,7 +511,7 @@ }, "several_candidates": { "reason": "lineage-id-unavailable:ambiguous-candidates", - "detail": "the candidate ids" + "detail": "`decision_detail.ambiguous_candidates`: rule id -> the occurrence ids that rule could not choose between, alongside the rule ids in `rules_without_a_unique_candidate`" } }, "reason_mapping_rule": [ @@ -762,6 +764,12 @@ ], "copy-source-that-is-also-a-fold-refuses": [ "refusal_was_recorded" + ], + "every-candidate-blunted-is-an-ambiguity": [ + "blunt_rule_recorded" + ], + "a-defeat-can-leave-too-few-kinds": [ + "defeat_removed_a_signal" ] }, "meanings": { diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index d707003..fb1b1ae 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -284,7 +284,7 @@ growing quietly until someone notices the bill. ## The preregistered matrix -Eight cases in `identity/fixtures/lineage-decision/`, fixed before any mapper, +Ten cases in `identity/fixtures/lineage-decision/`, fixed before any mapper, gated on **exact** equality with `preregistered_cases`. Each is a shape where two answers are available and the policy has to say which, or say neither. @@ -298,6 +298,8 @@ answers are available and the policy has to say which, or say neither. | 6 | `unavailable-diff-is-not-a-deletion` | `unresolved` — **not** `ended` plus `new` | | 7 | `blunt-rule-loses-to-uniqueness` | `continued` — and the blunt rule recorded as unable to choose | | 8 | `copy-source-that-is-also-a-fold-refuses` | `unresolved` / `conflicting-evidence` — N:M, and none of the six outcomes is N:M | +| 9 | `every-candidate-blunted-is-an-ambiguity` | `unresolved` / `ambiguous-candidates` — every rule matched both, and none preferred one | +| 10 | `a-defeat-can-leave-too-few-kinds` | `unresolved` / `insufficient-evidence-kind` — one kind survives against a floor of two | Cases 1 and 2 carry an extra burden, because a fixture can be right for the wrong reason: move the losing rule out of `applicable_rules` and the answer is @@ -306,6 +308,14 @@ on its own — that is applicability again — so `case_obligations` preregister what each case must *mechanically* exhibit, and both cases fail if their loser is quietly reclassified. +Cases 9 and 10 close a coverage gap an outcome-shaped gate could not see. The +policy emits five refusal reasons; `unresolved` read as covered while two of +them — `ambiguous-candidates`, which `arbitration.multiplicity` selects, and the +below-floor half of the defeat mapping — were pinned by no case at all, so +either branch could be repointed with the suite still green. The gate now +requires every emitted reason to be exercised, because several reasons share one +outcome and an outcome cannot stand in for them. + Case 8 is the one the contract had already decided and nothing pinned. The N:M refusal was frozen in `deliberately_unresolved_conflicts` — found, as that section says, by the pair-completeness check rather than by anyone noticing it — and no diff --git a/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json b/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json new file mode 100644 index 0000000..ea4c324 --- /dev/null +++ b/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json @@ -0,0 +1,86 @@ +{ + "case": "a-defeat-can-leave-too-few-kinds", + "title": "A defeat below the floor, where the shortage really is of kinds", + "why": "The other half of the distinction the senior contract was amended to carry, and the half no case exercised. `defeated-signal-drops-below-the-floor` leaves THREE kinds standing and is therefore about the combination; here the reformat defeats anchored_content and only same_pattern_id survives - one kind against a floor of two. That is `insufficient-evidence-kind` in its literal sense, and without this case the below-floor branch of `reason_mapping` was selected by nothing, so changing which limitation it names left the suite green. A coverage gate over outcomes cannot see this: both cases are `unresolved`.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "reformatted_paths": [ + "Broker/Doc.cs" + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Other.cs", + "start_line": 10, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "Helper.Init", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": "occ-a1", + "to": [], + "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "applicable_rules": [], + "licensed_by": [], + "signals_defeated": { + "anchored_content": "reformatted_paths" + }, + "evidence_surviving": [ + "same_pattern_id" + ], + "not_applicable": { + "R-CONT-SAME-SITE": "the path differs, and anchored_content was defeated anyway", + "R-CONT-DRIFT": "the path differs, so same_path and line_drift never fired", + "R-CONT-RENAME": "no rename record relates the two paths", + "R-CONT-COPY": "no copy record relates the two paths", + "R-BRANCH-COPY": "no copy record in this revision pair", + "R-MERGE-FOLD": "no merge record in this revision pair" + } + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "applicable_rules": [], + "licensed_by": [], + "signals_defeated": { + "anchored_content": "reformatted_paths" + }, + "evidence_surviving": [ + "same_pattern_id" + ] + } + ], + "forbid": [ + "insufficient-evidence-combination as the reason - one surviving kind is below the floor, not a combination nobody declared", + "continued on same_pattern_id alone - one kind, and no kind is sufficient alone", + "ended for occ-a1 or new for occ-b1 - neither carries boundary evidence", + "anchored_content counted after the reformat defeated it" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json index f356d48..efcdbe6 100644 --- a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json +++ b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json @@ -55,7 +55,13 @@ "decision_detail": { "rules_without_a_unique_candidate": [ "R-CONT-DRIFT" - ] + ], + "ambiguous_candidates": { + "R-CONT-DRIFT": [ + "occ-b1", + "occ-b2" + ] + } }, "not_applicable": { "R-CONT-DRIFT": "satisfied by occ-b1 AND occ-b2; a 1:1 outcome needs exactly one candidate, so it licenses nothing and is recorded in rules_without_a_unique_candidate", @@ -85,7 +91,8 @@ "R-CONT-DRIFT named in licensed_by", "R-CONT-DRIFT in applicable_rules - it had no surviving application", "R-CONT-DRIFT dropped without a trace - the tension must stay readable", - "occ-a1 mapped to occ-b2" + "occ-a1 mapped to occ-b2", + "R-CONT-DRIFT recorded as unable to choose without naming the candidates - the tension has to be readable, not merely flagged" ], "contract": "finding-lineage-decision/v1", "status": "preregistered-unimplemented" diff --git a/identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json b/identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json new file mode 100644 index 0000000..ab5fee2 --- /dev/null +++ b/identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json @@ -0,0 +1,138 @@ +{ + "case": "every-candidate-blunted-is-an-ambiguity", + "title": "Two indistinguishable successors, and every rule that matches matches both", + "why": "The reason `arbitration.multiplicity` selects, and until now no case pinned it. Both successors sit in the same file and the same symbol with the same text, so R-CONT-SAME-SITE and R-CONT-DRIFT each match BOTH and single nobody out. `applicable_rules` is therefore empty while `rules_without_a_unique_candidate` is not, which is a different refusal from nothing having matched: candidates existed and nothing preferred one. This is the decision-layer twin of step 0's `two-candidates-unresolved`, and the shape that makes `several_candidates` a mapping entry rather than a sentence. It is also where the candidate ids have to live - the contract promised them in `detail` and had nowhere to put them.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 44, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-b2", + "path": "Broker/Doc.cs", + "start_line": 51, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": "occ-a1", + "to": [], + "reason": "lineage-id-unavailable:ambiguous-candidates", + "applicable_rules": [], + "licensed_by": [], + "decision_detail": { + "rules_without_a_unique_candidate": [ + "R-CONT-DRIFT", + "R-CONT-SAME-SITE" + ], + "ambiguous_candidates": { + "R-CONT-SAME-SITE": [ + "occ-b1", + "occ-b2" + ], + "R-CONT-DRIFT": [ + "occ-b1", + "occ-b2" + ] + } + }, + "not_applicable": { + "R-CONT-RENAME": "no rename record in this revision pair", + "R-CONT-COPY": "no copy record in this revision pair", + "R-BRANCH-COPY": "no copy record, so nothing explains the multiplicity", + "R-MERGE-FOLD": "no merge record, and the shape is 1:N not N:1" + } + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:ambiguous-candidates", + "applicable_rules": [], + "licensed_by": [], + "decision_detail": { + "rules_without_a_unique_candidate": [ + "R-CONT-DRIFT", + "R-CONT-SAME-SITE" + ], + "ambiguous_candidates": { + "R-CONT-SAME-SITE": [ + "occ-b1", + "occ-b2" + ], + "R-CONT-DRIFT": [ + "occ-b1", + "occ-b2" + ] + } + } + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b2" + ], + "reason": "lineage-id-unavailable:ambiguous-candidates", + "applicable_rules": [], + "licensed_by": [], + "decision_detail": { + "rules_without_a_unique_candidate": [ + "R-CONT-DRIFT", + "R-CONT-SAME-SITE" + ], + "ambiguous_candidates": { + "R-CONT-SAME-SITE": [ + "occ-b1", + "occ-b2" + ], + "R-CONT-DRIFT": [ + "occ-b1", + "occ-b2" + ] + } + } + } + ], + "forbid": [ + "continued to either candidate - nothing prefers one, and picking is what uniqueness exists to stop", + "no-mapping-evidence as the reason - candidates existed; what was missing was a preference", + "conflicting-evidence as the reason - the rules agree, they simply do not discriminate", + "either blunted rule in applicable_rules - neither had a surviving 1:1 application", + "decision_detail.ambiguous_candidates omitted - a consumer cannot see which partners caused the refusal" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 47f47b3..ed60759 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -697,6 +697,7 @@ def main() -> int: f"{cname}: obligation {duty!r} has no entry in `meanings`") licensed_outcomes: set[str] = set() exercised_refusals: set = set() + exercised_reasons: set = set() for name in sorted(set(on_disk) & set(preregistered)): with open(os.path.join(FIXDIR, f"{name}.json"), encoding="utf-8") as fh: case = json.load(fh) @@ -746,6 +747,8 @@ def main() -> int: f"{where}: licensed_by {sorted(set(lic) - set(app))} is not in " "applicable_rules; a rule cannot license what it never applied to") licensed_outcomes.add(outcome) + if exp.get("reason"): + exercised_reasons.add(exp["reason"]) for _pair in refusals: if _pair <= set(app): exercised_refusals.add(_pair) @@ -1031,6 +1034,26 @@ def main() -> int: f"{where}: decision_detail.conflicting_rules is set, but nothing " "was refused for disagreeing here") + # The candidate binding the contract promises: rule id -> the ids it + # could not choose between. Checked where it appears, and required + # wherever a rule is recorded as unable to choose - otherwise the + # promise in `reason_mapping.several_candidates` stays unkeepable. + cand = detail.get("ambiguous_candidates") + blunted_ids = detail.get("rules_without_a_unique_candidate") or [] + if blunted_ids: + check(isinstance(cand, dict) and set(cand) == set(blunted_ids), + f"{where}: {sorted(blunted_ids)} could not choose, so " + "decision_detail.ambiguous_candidates must name the candidates for " + f"exactly those rules, got {sorted(cand or {})}") + for rid, ids_ in (cand or {}).items(): + check(rid in rules, f"{where}: ambiguous_candidates names unknown rule {rid!r}") + check(isinstance(ids_, list) and len(set(ids_)) >= 2, + f"{where}: {rid} is recorded as unable to choose between " + f"{ids_!r}; fewer than two candidates is not an ambiguity") + for oid in ids_ or []: + check(oid in occ_a or oid in occ_b, + f"{where}: candidate {oid!r} is in neither revision") + overlap = (set(detail.get("rules_without_a_unique_candidate") or []) & set(detail.get("rules_excluded_by_cardinality") or [])) check(not overlap, @@ -1159,6 +1182,18 @@ def main() -> int: "case. A conflict the contract deliberately refuses is a decision, and a " "decision with no case is frozen in name only.") + # EVERY EMITTED REASON, not merely every outcome. The outcome gate read + # `unresolved` as covered while two of its five reasons - `ambiguous-candidates` + # and `insufficient-evidence-kind` - were pinned by no fixture at all. The + # first is what `arbitration.multiplicity` selects; the second is the + # below-floor half of the distinction the senior contract was amended to + # carry. Both branches could be repointed without turning this suite red. + for reason in sorted(emitted): + check(reason in exercised_reasons, + f"no preregistered case reaches {reason!r}. The policy can emit it, so " + "something has to pin which condition selects it - an outcome gate " + "cannot, because several reasons share one outcome.") + for wanted in sorted({rules[r]["outcome"] for r in ids} | {"unresolved"}): check(wanted in licensed_outcomes, f"no preregistered case reaches {wanted!r}; the policy can license it and " From d41dd10280fcf4fad2dd704779f68d7d8f872533 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 10:30:51 +0000 Subject: [PATCH 27/84] test(identity): pin every rule, not every outcome Two rules were declared, argued for in their own notes, and reached by no preregistered case: R-CONT-RENAME and R-CONT-DRIFT. Either could be rewritten into a different policy with the whole suite green - swap the rename rule's `path_rename` for `same_path`, or the drift rule's `line_drift` for `path_rename`, and nothing went red. The outcome gate could not see it. Four rules license `continued`, so exercising any one of them ticked the outcome off and left the other three free. Worse, the suite carried an argued-for exemption saying this was fine - that a rename which simply works is not the kind of shape this matrix is about, and that the rule was constrained by the senior corpus instead. Every clause was wrong: `finding-lineage/v1` fixtures name no decision rule ids and this suite never arbitrates them. The gap was not an oversight, it was reasoned for, in a comment. - the gate is now per RULE: every id in `rules` must appear in some `licensed_by` - two cases reach the two rules, both carrying a new `rule_licensed_alone` obligation, so the rule reached is the only rule applicable and the conclusion rests on its requirements alone - `a-recorded-rename-is-not-an-unresolved` is deliberately the same physical move as `unavailable-diff-is-not-a-deletion`, differing only in whether the rename record can be read. One answers unresolved, the other continued. That is the absence-of-record doctrine changing an answer rather than asserting itself. - `an-edit-elsewhere-is-still-the-same-defect` rests on the one rule that does not require `anchored_content`, with the content genuinely changed A coverage gate proves a rule is REACHED, not that its requirements are why it applied - and on first measurement the mutations above were still green with the gate in place. Binding requirements to evidence is applicability, which this suite refuses to compute. Two invariants stand in for part of it, neither needing a checker to decide whether a signal holds: - no rule may require kinds the senior contract freezes as mutually exclusive; such a rule can never fire and its outcome degrades to unresolved silently. `same_path` and `path_rename` are the first pair, amended into `finding-lineage/v1` in the open, since it is a fact about the vocabulary. - no two rules may carry identical requirements under one outcome and shape. That is one rule under two names, and arbitration cannot see it because it only ever observes the pair agreeing. Both mutations above now fail, as does a third found while testing them. The residue is real and is left open rather than closed by a checker. Also: `decision_detail.ambiguous_candidates` was bound in one direction only. Candidates named for a rule no stage rejected were accepted, putting a rule's ambiguity in the record while leaving it out of the recall union, with `mandated_reason` still free to demand `no-mapping-evidence`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 49 +++++++++- contracts/finding-lineage-v1.json | 41 ++++++++- docs/finding-lineage-decision.md | 28 ++++++ ...-recorded-rename-is-not-an-unresolved.json | 73 +++++++++++++++ ...it-elsewhere-is-still-the-same-defect.json | 66 +++++++++++++ identity/tests/test_lineage_decision.py | 92 +++++++++++++++++-- 6 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json create mode 100644 identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index c772a06..382034d 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -378,7 +378,9 @@ "blunt-rule-loses-to-uniqueness", "copy-source-that-is-also-a-fold-refuses", "every-candidate-blunted-is-an-ambiguity", - "a-defeat-can-leave-too-few-kinds" + "a-defeat-can-leave-too-few-kinds", + "a-recorded-rename-is-not-an-unresolved", + "an-edit-elsewhere-is-still-the-same-defect" ], "preregistration_rule": [ "Fixed before the mapper, same as slice 2 step 0. These are about the POLICY", @@ -770,6 +772,12 @@ ], "a-defeat-can-leave-too-few-kinds": [ "defeat_removed_a_signal" + ], + "a-recorded-rename-is-not-an-unresolved": [ + "rule_licensed_alone" + ], + "an-edit-elsewhere-is-still-the-same-defect": [ + "rule_licensed_alone" ] }, "meanings": { @@ -778,7 +786,8 @@ "defeat_removed_a_signal": "`signals_defeated` is non-empty, the defeating record is carried by revision B, and it names this occurrence.", "input_was_unavailable": "`inputs_unavailable` is non-empty and revision B declares the same signal in `unavailable_signals`.", "blunt_rule_recorded": "`decision_detail.rules_without_a_unique_candidate` is non-empty. The blunt rule must be visible as having failed to choose, not merely absent.", - "refusal_was_recorded": "the case exercises a pair declared in `deliberately_unresolved_conflicts` - both ids applicable together - and records them in `decision_detail.conflicting_rules` with an EMPTY `licensed_by`. Until this obligation existed the N:M refusal was frozen in the contract and pinned by no case at all, which is the shape of defect this matrix exists to catch." + "refusal_was_recorded": "the case exercises a pair declared in `deliberately_unresolved_conflicts` - both ids applicable together - and records them in `decision_detail.conflicting_rules` with an EMPTY `licensed_by`. Until this obligation existed the N:M refusal was frozen in the contract and pinned by no case at all, which is the shape of defect this matrix exists to catch.", + "rule_licensed_alone": "exactly one rule is in `applicable_rules` and the same rule is in `licensed_by`. Nothing was dominated, nothing was filtered out, and the conclusion therefore rests ENTIRELY on that rule's own requirements - which is what makes the case a pin on them. A rule reachable only in cases where some other rule also applies is a rule whose requirements can be rewritten without any fixture noticing." }, "rule": [ "Every preregistered case must appear here, and every obligation must be one", @@ -819,5 +828,41 @@ "That is the rule, and it has already been broken once: cardinality became a", "filter without a field of its own, so its rejections were either mislabelled as", "ambiguities or lost. A stage whose output is discarded cannot be recalled from." + ], + "rule_coverage_rule": [ + "EVERY DECLARED RULE MUST LICENSE SOMETHING SOMEWHERE.", + "", + "Two rules - R-CONT-RENAME and R-CONT-DRIFT - were declared here, argued for in", + "their own `note` fields, and reached by no preregistered case at all. Both could", + "be rewritten into a different policy with the whole suite staying green:", + "swapping R-CONT-RENAME's `path_rename` for `same_path`, or R-CONT-DRIFT's", + "`line_drift` for `path_rename`, changed nothing any check could see. Outcome", + "coverage did not catch it, because other rules of outcome `continued` were", + "exercised and the outcome was therefore ticked off; and the senior corpus could", + "not catch it either, because `finding-lineage/v1` fixtures name no decision rule", + "ids and this suite does not arbitrate them.", + "", + "So the gate is PER RULE, not per outcome: every id in `rules` must appear in the", + "`licensed_by` of some expectation.", + "", + "WHAT THE GATE DOES NOT DO, stated because a check whose message claims more than", + "its code checks is the defect this branch has found most often. Appearing in", + "`licensed_by` does not verify that the rule's `requires_all` is WHY it applied -", + "that is applicability, which fixtures declare and this suite refuses to compute.", + "The gate guarantees a rule is reached, and `rule_licensed_alone` on the two cases", + "above guarantees the reaching is not incidental to some other rule doing the work.", + "Binding requirements to evidence is a separate question and it is NOT answered", + "here. Two invariants stand in for part of it, both chosen because neither needs", + "a checker to decide whether a signal holds:", + "", + " - no rule may require a combination the senior contract freezes as mutually", + " exclusive, because such a rule can never fire and takes its outcome with it;", + " - no two rules may carry identical requirements under one outcome and shape,", + " because that is one rule under two names.", + "", + "Together they catch every single-signal rewrite tried so far, including the two", + "that motivated this section. They do not catch every possible one, and the", + "residue is recorded as an open question rather than closed by a checker that", + "would have to compute applicability to do it." ] } diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index cb96e4a..fc5cd24 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -43,7 +43,17 @@ "rule against widening from below has now fired twice, which is a better", "argument for writing rules down than any amount of agreeing that one should.", "Still frozen-unimplemented: no mapper exists, so nothing is built on the old", - "shape and nothing has to be migrated." + "shape and nothing has to be migrated.", + "", + "Amended a THIRD time by slice 2 step 1, which added", + "'mutually_exclusive_evidence_kinds'. Step 1's per-rule coverage gate proved that", + "a rule reached by a fixture is still not a rule whose REQUIREMENTS are pinned:", + "swapping a continued rule's `line_drift` for `path_rename` left every check", + "green, because binding requirements to evidence is applicability and the step 1", + "suite deliberately refuses to compute it. Declaring which kinds cannot co-occur", + "closes part of that gap from the vocabulary side instead, where it costs no", + "second mapper. It does not close all of it - see the open question recorded in", + "the step 1 pull request." ], "outcomes": { "continued": { @@ -329,5 +339,34 @@ "this list: a case removed or renamed fails the suite rather than disappearing.", "Every one of the six outcomes must appear somewhere in the matrix, or that", "outcome is frozen in name only and the mapper may implement it as it likes." + ], + "mutually_exclusive_evidence_kinds": [ + { + "between": [ + "same_path", + "path_rename" + ], + "why": [ + "A path that is the SAME did not get renamed, and a path that got renamed", + "is not the same. These two kinds cannot both hold of one predecessor and", + "successor, and no rule in any junior contract may require both.", + "", + "This is frozen here rather than in the decision policy because it is a fact", + "about the VOCABULARY, not about any particular rule that uses it. It was", + "added when slice 2 step 1 found that a continued rule could have its", + "`line_drift` requirement swapped for `path_rename` - producing a rule that", + "demands a file both stay put and move - with the whole integrity suite", + "green. Nothing in either contract said the combination was impossible, so", + "nothing could object." + ] + } + ], + "mutually_exclusive_rule": [ + "A pair listed here is UNSATISFIABLE, so a rule requiring both is dead code that", + "no evidence can ever reach. That is a defect and not a conservative choice: a", + "rule which cannot fire silently removes the conclusion it was meant to license,", + "and the mapping degrades to `unresolved` while the contract still advertises the", + "rule. Junior contracts are checked against this list; they may not add to it from", + "below, for the same reason they may not widen `limitations` from below." ] } diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index fb1b1ae..32202c7 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -300,6 +300,8 @@ answers are available and the policy has to say which, or say neither. | 8 | `copy-source-that-is-also-a-fold-refuses` | `unresolved` / `conflicting-evidence` — N:M, and none of the six outcomes is N:M | | 9 | `every-candidate-blunted-is-an-ambiguity` | `unresolved` / `ambiguous-candidates` — every rule matched both, and none preferred one | | 10 | `a-defeat-can-leave-too-few-kinds` | `unresolved` / `insufficient-evidence-kind` — one kind survives against a floor of two | +| 11 | `a-recorded-rename-is-not-an-unresolved` | `continued` — the twin of case 6, with the record readable | +| 12 | `an-edit-elsewhere-is-still-the-same-defect` | `continued` — the line moved and the text changed | Cases 1 and 2 carry an extra burden, because a fixture can be right for the wrong reason: move the losing rule out of `applicable_rules` and the answer is @@ -308,6 +310,32 @@ on its own — that is applicability again — so `case_obligations` preregister what each case must *mechanically* exhibit, and both cases fail if their loser is quietly reclassified. +Cases 11 and 12 close a coarser one. Four rules license `continued`, so +exercising any of them ticked that outcome off — and `R-CONT-RENAME` and +`R-CONT-DRIFT` were reached by no case at all. Either could be rewritten into a +different policy with the whole suite green: swap the rename rule's `path_rename` +for `same_path`, or the drift rule's `line_drift` for `path_rename`, and nothing +went red. Worse, the suite carried an argued-for exemption saying this was fine — +that the rename rule was constrained by the senior corpus instead. It is not: +`finding-lineage/v1` fixtures name no decision rule ids, and this suite never +arbitrates them. The gate is now **per rule**, and each of the two new cases +carries `rule_licensed_alone`, so the rule it reaches is the only rule applicable +and the conclusion rests on that rule's requirements alone. + +Case 11 is deliberately the same physical move as case 6 — same paths, same +symbol, same content — differing in one respect only: whether `path_rename` could +be evaluated. Case 6 cannot see the rename and answers `unresolved`; case 11 can +and answers `continued`. Holding the movement fixed and varying only the +readability of the record is what makes the absence-of-record doctrine visible as +a decision rather than a paragraph. Case 12 rests on the one rule that does *not* +require `anchored_content`: the text really did change, and a defect may be +edited without ceasing to be the same defect. + +What the per-rule gate does **not** do is bind a rule's `requires_all` to the +evidence a fixture carries. Reaching a rule proves the rule is reachable, not +that its stated requirements are why it applied — that is applicability, which +fixtures declare and this suite refuses to compute. + Cases 9 and 10 close a coverage gap an outcome-shaped gate could not see. The policy emits five refusal reasons; `unresolved` read as covered while two of them — `ambiguous-candidates`, which `arbitration.multiplicity` selects, and the diff --git a/identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json b/identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json new file mode 100644 index 0000000..2c27581 --- /dev/null +++ b/identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json @@ -0,0 +1,73 @@ +{ + "case": "a-recorded-rename-is-not-an-unresolved", + "title": "The same move as `unavailable-diff-is-not-a-deletion`, with the record readable", + "why": "The twin of `unavailable-diff-is-not-a-deletion`, and the pair is the point. The occurrence performs the identical physical move in both - Broker/Old.cs to Broker/New.cs, same symbol, same content - and the two cases differ in exactly one respect: whether `path_rename` could be EVALUATED. There the diff was missing and the honest answer was `unresolved` naming the missing input; here the rename record is present and the same move is a `continued`. Holding the movement fixed and varying only the readability of the record is what shows the absence-of-record doctrine changing an answer, rather than a doctrine asserted in prose. R-CONT-RENAME is the sole applicable rule: the paths differ, so neither R-CONT-SAME-SITE nor R-CONT-DRIFT can fire, and there is no copy or merge record. Nothing dominates anything, and the conclusion rests entirely on this one rule's requirements.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Old.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "renames": [ + { + "from": "Broker/Old.cs", + "to": "Broker/New.cs", + "similarity": 96 + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/New.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "continued", + "frm": [ + "occ-a1" + ], + "to": [ + "occ-b1" + ], + "applicable_rules": [ + "R-CONT-RENAME" + ], + "licensed_by": [ + "R-CONT-RENAME" + ], + "not_applicable": { + "R-CONT-SAME-SITE": "the paths differ, so same_path did not fire", + "R-CONT-DRIFT": "the paths differ, so same_path did not fire; line_drift alone is not this rule", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record", + "R-MERGE-FOLD": "no merge record" + }, + "note": "one rule applicable and the same rule licensing. `arbitration.all_agree` read on a set of one, which is the general law and not a special case for singletons." + } + ], + "forbid": [ + "unresolved - the record that was missing in the twin case is present here", + "R-CONT-SAME-SITE applying across two different paths", + "path_rename licensing this on its own; a rename relates FILES", + "inputs_unavailable naming path_rename - it was evaluated here, and it held" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json b/identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json new file mode 100644 index 0000000..4db811b --- /dev/null +++ b/identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json @@ -0,0 +1,66 @@ +{ + "case": "an-edit-elsewhere-is-still-the-same-defect", + "title": "The line moved and the text changed, in the same file and the same symbol", + "why": "R-CONT-DRIFT is the only rule in this policy that does NOT require `anchored_content`, and its note says why: a defect may be edited without ceasing to be the same defect. That claim is only worth anything if some case actually rests on it, so this one is built to make `anchored_content` FALSE - the argument gained a null check - while the path and the enclosing symbol hold. R-CONT-SAME-SITE therefore cannot fire, and R-CONT-DRIFT is the sole applicable rule. Were the drift rule quietly rewritten to demand content, or to demand a rename, this case is what stops it: nothing else in the matrix reaches it.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "start_line": 42, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "start_line": 57, + "start_column": null, + "pattern_id": "pat-A", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "if (_store != null) _store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "continued", + "frm": [ + "occ-a1" + ], + "to": [ + "occ-b1" + ], + "applicable_rules": [ + "R-CONT-DRIFT" + ], + "licensed_by": [ + "R-CONT-DRIFT" + ], + "not_applicable": { + "R-CONT-SAME-SITE": "the content changed, so anchored_content did not fire", + "R-CONT-RENAME": "the file did not move; there is no rename record", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record", + "R-MERGE-FOLD": "no merge record" + }, + "note": "`line_drift` is doing the work `anchored_content` does elsewhere: the occurrence is in the same file and the same symbol, displaced by an edit above it. Requiring the text to be identical here would end a lineage every time someone touched the line." + } + ], + "forbid": [ + "unresolved - the site is intact and the pattern is the same", + "R-CONT-SAME-SITE applying across changed content", + "ended for occ-a1 plus new for occ-b1 - an edit is not a death and a birth", + "line_drift licensing this on its own; a line number is not an identity" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index ed60759..9ddc8d1 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -561,6 +561,21 @@ def main() -> int: check(len(set(req)) >= floor, f"{rid}: partner_profile names {len(set(req))} kind(s); the frozen " f"floor is {floor}. The floor is checked, never used to generate.") + # NO RULE MAY REQUIRE AN IMPOSSIBLE COMBINATION. The senior contract + # freezes which kinds cannot co-occur; a rule demanding both is dead and + # takes its outcome down quietly with it. This is one of the two things + # standing in for a binding of `requires_all` to evidence, which the + # suite cannot do without becoming a second mapper - see the note on + # `rule_coverage_rule`. + needs = rule_needs(rules[rid]) + for excl in senior.get("mutually_exclusive_evidence_kinds") or []: + pair = set(excl.get("between") or []) + check(not pair <= needs, + f"{rid} requires {sorted(pair)} together, which " + "`finding-lineage/v1` freezes as mutually exclusive. No evidence " + "can satisfy the rule, so it licenses nothing and its outcome " + "silently degrades to `unresolved`.") + # The abandoned wording must not creep back in under its old names. for dead in ("requires_per_successor", "requires_per_predecessor"): check(dead not in rules[rid], @@ -568,6 +583,27 @@ def main() -> int: "frozen corpus: every continued rule needs `structural_context`, and the " "copy and fold cases change the enclosing symbol.") + # TWO RULES MAY NOT BE THE SAME RULE. Identical requirements under one + # outcome make a pair indistinguishable: no evidence can satisfy either + # without satisfying both, so one of them licenses nothing that the other + # does not, and retiring it would change no mapping. The pair is also + # invisible to arbitration, which only ever sees rules AGREEING. + # + # This is the second stand-in for binding `requires_all` to evidence. It is + # what catches a rename rule whose `path_rename` is swapped for `same_path`: + # the result is R-CONT-SAME-SITE under a second name. + by_requirements: dict = {} + for rid in ids: + key = (outcomes[rid], frozenset(rule_needs(rules[rid])), + rules[rid].get("cardinality", {}).get("shape")) + by_requirements.setdefault(key, []).append(rid) + for key, group in sorted(by_requirements.items(), key=lambda kv: sorted(kv[1])): + check(len(group) == 1, + f"{sorted(group)} license {key[0]!r} at {key[2]!r} on identical " + f"requirements {sorted(key[1])}. They are one rule under two names: " + "nothing can satisfy either without satisfying both, so neither can " + "be cited for a mapping the other does not equally license.") + # ---- 1 and 2, via the pure functions the meta-checks also exercise. ----- conflicting = {frozenset(p) for p in itertools.combinations(ids, 2) if outcomes[p[0]] != outcomes[p[1]]} @@ -696,6 +732,7 @@ def main() -> int: check(duty in obligation_meanings, f"{cname}: obligation {duty!r} has no entry in `meanings`") licensed_outcomes: set[str] = set() + licensed_rules: set[str] = set() exercised_refusals: set = set() exercised_reasons: set = set() for name in sorted(set(on_disk) & set(preregistered)): @@ -747,6 +784,7 @@ def main() -> int: f"{where}: licensed_by {sorted(set(lic) - set(app))} is not in " "applicable_rules; a rule cannot license what it never applied to") licensed_outcomes.add(outcome) + licensed_rules.update(lic) if exp.get("reason"): exercised_reasons.add(exp["reason"]) for _pair in refusals: @@ -1038,13 +1076,23 @@ def main() -> int: # could not choose between. Checked where it appears, and required # wherever a rule is recorded as unable to choose - otherwise the # promise in `reason_mapping.several_candidates` stays unkeepable. + # BOTH DIRECTIONS. This required the binding only when a rule was + # recorded as unable to choose, so the converse - candidates named for + # a rule that no stage rejected - passed. That is not cosmetic: the + # recall union is built from the rejecting fields, so candidates + # attached to nothing put a rule's ambiguity in the record while + # leaving it out of the recall set, and `mandated_reason` reads no + # blunted rule and can still demand `no-mapping-evidence`. The field + # is the candidate binding FOR the uniqueness rejection; with no + # rejection there is nothing for it to bind. cand = detail.get("ambiguous_candidates") blunted_ids = detail.get("rules_without_a_unique_candidate") or [] - if blunted_ids: + if blunted_ids or cand: check(isinstance(cand, dict) and set(cand) == set(blunted_ids), - f"{where}: {sorted(blunted_ids)} could not choose, so " - "decision_detail.ambiguous_candidates must name the candidates for " - f"exactly those rules, got {sorted(cand or {})}") + f"{where}: decision_detail.ambiguous_candidates must name the " + "candidates for exactly the rules recorded in " + f"rules_without_a_unique_candidate {sorted(blunted_ids)}, got " + f"{sorted(cand or {})}") for rid, ids_ in (cand or {}).items(): check(rid in rules, f"{where}: ambiguous_candidates names unknown rule {rid!r}") check(isinstance(ids_, list) and len(set(ids_)) >= 2, @@ -1115,6 +1163,12 @@ def main() -> int: met |= bool(exp.get("signals_defeated")) elif duty == "input_was_unavailable": met |= bool(exp.get("inputs_unavailable")) + elif duty == "rule_licensed_alone": + # The rule is not merely present, it is the ONLY thing present. + # `licensed_by` alone would be satisfied by a rule riding along + # with a second one that did the real work, which is exactly the + # incidental reach this obligation exists to rule out. + met |= len(app_s) == 1 and app_s == lic_s elif duty == "blunt_rule_recorded": met |= bool((exp.get("decision_detail") or {}) .get("rules_without_a_unique_candidate")) @@ -1166,12 +1220,18 @@ def main() -> int: check(bool(seen_forbid), f"{name}: `forbid` is empty - the case rules nothing out") # Every outcome a RULE can license must be licensed by some case, or it is - # frozen in name only and a mapper may reach it however it likes. This is the - # analogue of step 0's outcome sweep; it is not a demand for one happy-path - # case per rule, because this matrix is about shapes where two answers are - # available and a rename that simply works is not one. R-CONT-RENAME is - # constrained by the senior corpus, where `rename-with-context-continues` is - # the only rule that licenses it. + # frozen in name only and a mapper may reach it however it likes. + # + # What stood here was an ARGUMENT FOR NOT CHECKING PER RULE: that this matrix + # is about shapes where two answers are available, that a rename which simply + # works is not one, and that R-CONT-RENAME is constrained by the senior corpus + # instead. Every clause of that was wrong. `finding-lineage/v1` fixtures name + # no decision rule ids and this suite never arbitrates them, so the senior + # corpus constrains no rule here at all; R-CONT-RENAME and R-CONT-DRIFT were + # both reached by nothing, and either could be rewritten into a different + # policy - `path_rename` for `same_path`, `line_drift` for `path_rename` - + # with the suite staying green. The exemption was not an oversight; it was + # reasoned for, in this comment, and the reasoning is what let the gap sit. # A declared refusal with no case is a decision nothing pins - the defect this # project keeps finding in its own drafts. The N:M pair was frozen in the # contract and exercised by no fixture at all until this check was written. @@ -1199,6 +1259,18 @@ def main() -> int: f"no preregistered case reaches {wanted!r}; the policy can license it and " "nothing pins how") + # PER RULE, because per outcome is not enough: four rules license `continued`, + # so exercising any one of them ticked the outcome off and left the other + # three free to be rewritten. See `rule_coverage_rule` in the contract for + # what this gate does NOT establish - reaching a rule is not the same as + # binding its `requires_all` to the evidence, which is applicability and is + # not this suite's to compute. + for rid in sorted(ids): + check(rid in licensed_rules, + f"{rid} licenses nothing in the matrix. The policy declares it, so its " + "requirements can be changed into a different rule and no preregistered " + "case would notice - which is how two of these rules were found.") + # ---- 4c. THE DOC AND THE CONTRACT AGREE. -------------------------------- # Same gate step 0 uses. A document that stops naming a case, or keeps saying # a mapper exists when one does not, is worse than no document: it is a From 0b37686aa3fd8bb81bf03ae6f49ca81bf099d503 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 10:35:44 +0000 Subject: [PATCH 28/84] fix(identity): bind four declarations the contract made and nothing read Found by enumerating what the policy DECLARES against what the suite reads, after the last round showed that enumeration finds these and reading does not. All four confirmed by mutation: each left the suite green. 1. `copy_record` and `merge_record` had entirely unbound read paths. Only the two signals that happen to be DEFEATERS were checked, because the defeat loop was the sole caller of `signal_bindings` - so the two carrying `branched` and `merged` could name a revision field nothing has, or match on an occurrence attribute that does not exist. `observable_from` is the contract telling a mapper WHERE to read; pointed at nothing, every structural rule stops applying and the policy degrades to `unresolved` in silence, with fixtures still declaring applicability and the suite still green. `finding-lineage/v1` binds its own `observable_from` this way already; the junior catalog was the looser of the two. A licensing rule's record must now also NAME these occurrences, the same duty the defeat loop already imposed - a copy record about two unrelated files no longer supports a branch. 2. `requires_all_scope` stated the group quantifier in prose nothing read. Dropping it from a group rule changed nothing; adding GROUP scope to the 1:1 R-CONT-SAME-SITE changed nothing either, leaving the contract pointing a mapper at a partner set that does not exist. It is replaced by a machine-readable `record_binding`, not supplemented by one - two authorities for one fact is how this contract has gone wrong before. The quantifiers genuinely differ and the contract had already decided them: copy explains AT LEAST ONE successor not at the predecessor's path, fold names EVERY predecessor. So this makes an existing decision readable rather than taking a new one. Demonstrated on one broken fixture: a fold naming a single predecessor is RED under `every` and GREEN under `at_least_one`. 3. `unavailable_inputs.recorded_as` named the record field in prose while the suite knew `inputs_unavailable` independently. Repointing it at `signals_defeated` - the field for signals that WERE evaluated and removed, which this very section's `distinct_from` explains is a different thing - left every check green. The suite now reads the field name from the contract. 4. `arbitration.all_agree.outcome` says "the shared outcome" because the value is computed from the survivors; replacing it with a literal `continued` turned a general law into a specific claim the algebra contradicts. The sibling rule bound each branch's REASON and left the outcome one field over loose. On (4), the first attempt added an `outcome` to the multiplicity container that nothing visited - a declaration nobody reads, introduced while fixing declarations nobody reads. Enumerating branches by hand is what caused the original gap, so the check now walks `arbitration` instead of listing it. Seventeen probes bite. Two came back vacuous first time and both were the probe's fault: weakening a quantifier cannot fail on a fixture that satisfies the stronger one, and the multiplicity mutation was aimed at a node the check did not reach. Both re-run discriminating. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 70 ++++++- identity/tests/test_lineage_decision.py | 205 ++++++++++++++++++++- 2 files changed, 264 insertions(+), 11 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 382034d..620aa10 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -135,7 +135,6 @@ "requires_all": [ "copy_record" ], - "requires_all_scope": "GROUP. The copy record relates the predecessor's path to the path of at least one successor that is NOT at the predecessor's path - that successor is the one the record explains.", "partner_profile": { "per": "successor", "requires_all": [ @@ -145,7 +144,19 @@ "why": "what every successor must show ON ITS OWN, across a transformation that changed the path and may have changed the enclosing symbol too" }, "means": "one predecessor, several successors, and a RECORD explaining why there are several", - "note": "the copy record is what separates a branch from an ambiguity. Without it, several equally-supported candidates are `unresolved` - see `arbitration.multiplicity`." + "note": "the copy record is what separates a branch from an ambiguity. Without it, several equally-supported candidates are `unresolved` - see `arbitration.multiplicity`.", + "record_binding": { + "predecessor": { + "quantifier": "every" + }, + "successor": { + "quantifier": "at_least_one", + "excluding": "partners_where_same_path_holds" + }, + "why": [ + "The copy record relates the predecessor's path to the path of a successor that is NOT at the predecessor's path - that successor is the one the record explains. The other successor is where the occurrence already was, and it is licensed by a continued rule, not by this record. Demanding EVERY successor appear in `copies[].to` would therefore reject the very shape `copy-record-dominates-the-single-match` was built to preregister." + ] + } }, "R-MERGE-FOLD": { "outcome": "merged", @@ -156,7 +167,6 @@ "requires_all": [ "merge_record" ], - "requires_all_scope": "GROUP. The record's `from` list must carry every predecessor's enclosing symbol and its `to` must be the successor's.", "partner_profile": { "per": "predecessor", "requires_all": [ @@ -165,7 +175,18 @@ ], "why": "the merge record relates the old structural sites to the new one; what each predecessor must show on its own is that it is the same defect, not that it is in a symbol the fold has just renamed out of existence" }, - "means": "several predecessors, one successor, and a RECORD explaining the fold" + "means": "several predecessors, one successor, and a RECORD explaining the fold", + "record_binding": { + "predecessor": { + "quantifier": "every" + }, + "successor": { + "quantifier": "every" + }, + "why": [ + "A fold names all of its sources: the record's `from` must carry every predecessor's enclosing symbol, and its `to` must be the successor's. A merge that named only some of the predecessors would be claiming a fold it cannot see, and the quantifier is what makes that different from the copy case rather than a matter of taste." + ] + } } }, "rules_note": [ @@ -213,7 +234,7 @@ "meaning": "a signal that could not be EVALUATED at all: no symbol index, no diff, no content for the revision", "distinct_from": "a signal that was evaluated and did not hold", "effect": "rules depending on it do not apply; they do not fail, and they certainly do not count against the mapping", - "recorded_as": "inputs_unavailable, listing the signal names", + "recorded_as": "inputs_unavailable", "why": [ "This is the absence-of-record doctrine at a third layer. The runtime witness", "refuses to read an unread heap as a clean one; the occurrence contract", @@ -224,7 +245,8 @@ "comes out `unresolved`, naming the missing input, instead of `ended` plus", "`new`. Slower, and true." ], - "observable_from": "revision_b.unavailable_signals" + "observable_from": "revision_b.unavailable_signals", + "recorded_as_why": "the FIELD NAME, machine-readable, because the integrity suite reads the record through it. It said `inputs_unavailable, listing the signal names` and the suite knew the field independently, so the two could drift: repointing this at `signals_defeated` left every check green while the contract told a mapper to write unevaluable signals into the field reserved for evaluated-and-removed ones." }, "arbitration": { "none_applies": { @@ -288,7 +310,8 @@ "Cardinality is a precondition now, so a lone successor never licenses", "`branched` in the first place; see `cardinality_rule` for why that is a", "different thing from arriving at the same answer two sections later." - ] + ], + "outcome": "unresolved" }, "licensed_by_rule": [ "ONE definition, and the branches above are readings of it: `licensed_by` is", @@ -864,5 +887,36 @@ "that motivated this section. They do not catch every possible one, and the", "residue is recorded as an open question rather than closed by a checker that", "would have to compute applicability to do it." - ] + ], + "record_binding_vocabulary": { + "why": [ + "WHAT THE STRUCTURAL RECORD MUST NAME, MACHINE-READABLY.", + "", + "This replaces `requires_all_scope`, which said the same thing in prose that", + "nothing read. Two probes showed the cost: dropping it from a group rule", + "changed nothing, and adding `GROUP` scope to the 1:1 R-CONT-SAME-SITE - a rule", + "with no group at all - changed nothing either, leaving the contract pointing a", + "mapper at a partner set that does not exist.", + "", + "It is NOT a fourth place naming which side the group is on. The roles here are", + "the roles the signal's own `matches` names, and the suite rejects a binding", + "whose roles disagree with the catalog.", + "", + "The quantifiers differ between the two group rules and that difference is", + "load-bearing, not incidental - see each rule's `why`. Swapping them makes both", + "rules wrong in opposite directions, and the suite fails on both." + ], + "quantifier": { + "every": "the record must name EVERY occurrence in that role for this mapping", + "at_least_one": "the record must name at least one, because the remaining partners are explained by a different rule rather than by this record" + }, + "excluding": { + "partners_where_same_path_holds": "partners at the predecessor's own path are not what the copy record explains; `same_path` holds of them, so a continued rule reaches them and this record does not have to" + }, + "rule": [ + "A quantifier or exclusion not listed here is REJECTED rather than ignored.", + "A binding vocabulary that silently accepts unknown tokens is a binding that stops", + "binding the moment someone invents a word." + ] + } } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 9ddc8d1..697b379 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -85,6 +85,14 @@ SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") + +# The record field for unevaluable signals, read from the contract that DECLARES +# it rather than repeated here. `unavailable_inputs.recorded_as` used to be prose +# and this name was a literal at three call sites, so the two could drift apart +# with nothing to object - the contract could point a mapper at `signals_defeated` +# and every check stayed green. Now there is one source and it is the contract's. +UNAVAILABLE_FIELD = (json.load(open(POLICY, encoding="utf-8")) + ["unavailable_inputs"]["recorded_as"]) FIXDIR = os.path.join(ROOT, "identity", "fixtures", "lineage-decision") DOC = os.path.join(ROOT, "docs", "finding-lineage-decision.md") @@ -215,7 +223,7 @@ def of(key, surviving_kinds=None): app = exp.get("applicable_rules") or [] defeated = exp.get("signals_defeated") or {} - unavailable = as_list(exp.get("inputs_unavailable")) + unavailable = as_list(exp.get(UNAVAILABLE_FIELD)) detail = exp.get("decision_detail") or {} blunted = detail.get("rules_without_a_unique_candidate") or [] miscard = detail.get("rules_excluded_by_cardinality") or [] @@ -480,6 +488,45 @@ def main() -> int: f"{key!r}, which selects {want_b!r}. One value, stated three times, " "and all three have to say it.") + # ...and the same branches' OUTCOME, which that rule left loose one field over. + # `all_agree` says "the shared outcome" because the value is COMPUTED from the + # survivors; replacing it with a literal `continued` turned the general law + # into a specific claim the algebra contradicts, and nothing objected. The + # refusing branches are the mirror: `arbitrate` hard-codes `unresolved` for + # every one of them, so a literal there must be that literal. + senior_outcomes = set(senior["outcomes"]) + agree = policy["arbitration"]["all_agree"] + + # EVERY node under `arbitration` that carries an outcome, not the three the + # reason loop happens to visit. Writing this as a list of branches is what + # left `all_agree` loose in the first place; a first attempt at this check + # then added an `outcome` to the multiplicity CONTAINER, which the branch list + # does not visit either - a declaration nobody reads, introduced while fixing + # declarations nobody reads. So it walks. + def arbitration_nodes(node, trail): + if isinstance(node, dict): + if "outcome" in node: + yield ".".join(trail), node + for k, v in node.items(): + yield from arbitration_nodes(v, trail + [k]) + for path_a, node in arbitration_nodes(policy["arbitration"], ["arbitration"]): + if node is agree: + continue + got = node.get("outcome") + check(got == "unresolved", + f"{path_a} declares outcome {got!r}. Every arbitration branch that is " + "not `all_agree` refuses, and `arbitrate` answers `unresolved` on all of " + "them, so any other value is a claim the algebra contradicts.") + + check("implements" not in agree, + "`arbitration.all_agree` is not a refusal and must not implement a " + "`reason_mapping` entry; the record it produces carries no reason at all.") + check(agree.get("outcome") not in senior_outcomes, + f"`arbitration.all_agree.outcome` names the literal {agree.get('outcome')!r}. " + "It is computed from the surviving rules - naming one outcome freezes a law " + "about every agreeing set into a claim about one of them, and the survivor " + "algebra says otherwise.") + conflict_reason = (policy["reason_mapping"].get("conflicting_rules") or {}).get("reason") for entry in policy["deliberately_unresolved_conflicts"]: pair = sorted(entry.get("between") or []) @@ -715,11 +762,102 @@ def main() -> int: "renamed to make an implementation look better - and trying to write the " "first two is what falsified the rule set, so partial credit is not on offer.") + # THE CATALOG'S READ PATHS, FOR EVERY SIGNAL. These were checked only for the + # two signals that happen to be DEFEATERS, because the defeat loop was the + # only thing that ever called `signal_bindings`. `copy_record` and + # `merge_record` - the two carrying `branched` and `merged` - could name a + # revision field nothing has, or match on an occurrence attribute that does + # not exist, with the suite green. `observable_from` is the contract telling a + # mapper WHERE to read; pointed at nothing, every structural rule stops + # applying and the policy degrades to `unresolved` in silence. + # + # `finding-lineage/v1` binds its own `observable_from` this way already, so + # the junior catalog was simply the looser of the two. + occurrence_attrs = {"path", "enclosing_symbol", "pattern_id", + "anchored_content", "start_line", "start_column", + "occurrence_id"} + for signal, cat_spec in sorted(policy["structural_signals"].items()): + where_s = f"structural_signals.{signal}" + read_from = str(cat_spec.get("observable_from", "")) + check(read_from.startswith("revision_b."), + f"{where_s}.observable_from must name a revision B field, got " + f"{read_from!r}") + matches = cat_spec.get("matches") + check(isinstance(matches, dict) and matches, + f"{where_s} declares no `matches`, so nothing says which occurrence " + "the record is about and any record in revision B would do") + for key, subject in (matches or {}).items(): + role, dot, attr = str(subject).partition(".") + check(role in ("predecessor", "successor"), + f"{where_s}.matches[{key!r}] names role {role!r}; a record relates " + "predecessors to successors and nothing else") + check(bool(dot) and attr in occurrence_attrs, + f"{where_s}.matches[{key!r}] compares {subject!r}, and {attr!r} is " + f"not an occurrence attribute. Known: {sorted(occurrence_attrs)}") + shape = cat_spec.get("entry_shape") + if isinstance(shape, dict): + check(set(matches or {}) <= set(shape), + f"{where_s}.matches names {sorted(set(matches or {}) - set(shape))}, " + "which `entry_shape` does not declare") + + # ...and the group quantifier, which used to live in `requires_all_scope` as + # prose. A 1:1 rule could claim GROUP scope and a group rule could drop the + # declaration entirely, both silently. + vocab = policy["record_binding_vocabulary"] + for rid in sorted(rules): + rule_ = rules[rid] + check("requires_all_scope" not in rule_, + f"{rid} still carries `requires_all_scope`. It was prose nothing read, " + "and `record_binding` replaced it; keeping both is two authorities for " + "one fact, which is how this contract has gone wrong before.") + binding = rule_.get("record_binding") + is_group = (rule_.get("cardinality") or {}).get("shape") != "1:1" + check(bool(binding) == is_group, + f"{rid}: `record_binding` is declared for group rules and only for them; " + f"shape is {(rule_.get('cardinality') or {}).get('shape')!r} and the " + f"binding is {'present' if binding else 'absent'}. A 1:1 rule with one " + "points a mapper at a partner set that does not exist.") + if not binding: + continue + structural = [k for k in rule_.get("requires_all") or [] + if k in policy["structural_signals"]] + check(len(structural) == 1, + f"{rid} is a group rule requiring {structural!r} structural signals; " + "the binding is about exactly one record") + for sig in structural: + roles = {r for _, r, _ in signal_bindings(policy["structural_signals"][sig])} + declared = {k for k in binding if k in ("predecessor", "successor")} + check(declared == roles, + f"{rid}.record_binding names roles {sorted(declared)} but {sig} " + f"matches on {sorted(roles)}. The roles are the catalog's, not a " + "second opinion about which side the group is on.") + for role in ("predecessor", "successor"): + spec_r = binding.get(role) + if not isinstance(spec_r, dict): + continue + check(spec_r.get("quantifier") in vocab["quantifier"], + f"{rid}.record_binding.{role} uses quantifier " + f"{spec_r.get('quantifier')!r}, which the vocabulary does not carry") + if "excluding" in spec_r: + check(spec_r["excluding"] in vocab["excluding"], + f"{rid}.record_binding.{role} excludes {spec_r['excluding']!r}, " + "which the vocabulary does not carry. Unknown tokens are " + "rejected, not ignored.") + senior_limitations_set = set(senior["limitations"].values()) unavailable_from = str(policy["unavailable_inputs"].get("observable_from", "")) check(unavailable_from.startswith("revision_b."), "`unavailable_inputs.observable_from` must name a revision B field, got " f"{unavailable_from!r}") + check(UNAVAILABLE_FIELD in policy["record_additions"], + f"`unavailable_inputs.recorded_as` names {UNAVAILABLE_FIELD!r}, which is not a " + "field `record_additions` declares. The suite reads the record through this " + "name, so an undeclared one silently reads nothing.") + check(UNAVAILABLE_FIELD != "signals_defeated", + "`unavailable_inputs.recorded_as` points at the field for signals that were " + "evaluated and removed. `distinct_from` in this very section says why those " + "are not the same thing: one was never readable, the other was read and did " + "not hold.") obligations = policy["case_obligations"]["obligations"] obligation_meanings = policy["case_obligations"]["meanings"] check(sorted(obligations) == sorted(preregistered), @@ -926,6 +1064,67 @@ def main() -> int: f"{where}: {outcome} must not carry an identity limitation") check(bool(lic), f"{where}: {outcome} must name what licensed it") + # A STRUCTURAL RECORD MUST BE CARRIED TOO, and must name these + # occurrences - the same duty the defeat loop below imposes, which + # until now applied to defeaters alone. A rule may be declared + # applicable while `revision_b.copies` names two unrelated files; + # nothing looked, so `branched` rested on a record about somebody + # else. The quantifier comes from the rule's `record_binding`, never + # from the checker: copy explains ONE successor and fold names EVERY + # predecessor, and a checker picking either on its own would be + # holding an opinion about the policy. + # LICENSED, not merely applicable. An applicable-but-dominated rule + # never named this mapping's group, and on a refused conflict the + # sides are the unresolved record's own - side a names `frm` with `to` + # empty - so there is no group to bind against at all. Checking `app` + # here failed all four expectations of + # `copy-source-that-is-also-a-fold-refuses`, which is the check being + # wrong rather than the fixture. + for rid in lic: + binding = rules[rid].get("record_binding") + if not binding: + continue + for sig in (rules[rid].get("requires_all") or []): + cat_s = catalog.get(sig) + if not cat_s: + continue + for read, role, attr in signal_bindings(cat_s): + observed = collect(rev_b, read) + check(bool(observed), + f"{where}: {rid} applies on {sig!r}, but {read} carries " + "nothing. A record cited in `applicable_rules` and absent " + "from revision B is a record asserted in a note.") + subjects = frm if role == "predecessor" else to + spec_r = binding.get(role) or {} + quant = spec_r.get("quantifier") + excl = spec_r.get("excluding") + pool = list(subjects) + if excl == "partners_where_same_path_holds": + others = to if role == "predecessor" else frm + paths = {by_id.get(o, {}).get("path") for o in others} + pool = [o for o in pool + if by_id.get(o, {}).get("path") not in paths] + check(bool(pool), + f"{where}: {rid} excludes partners at the " + f"predecessor's path and {role}s has none left. The " + "record would then explain nothing this mapping needs.") + named = [o for o in pool + if by_id.get(o, {}).get(attr) in observed] + if quant == "every": + missing = [o for o in pool if o not in named] + check(not missing, + f"{where}: {rid} binds EVERY {role} into {read}, but " + f"{missing!r} " + f"({[by_id.get(o, {}).get(attr) for o in missing]!r}) " + f"is absent; the record holds {sorted(observed)!r}") + elif quant == "at_least_one": + check(bool(named), + f"{where}: {rid} binds at least one {role} into " + f"{read}, and none of {pool!r} " + f"({[by_id.get(o, {}).get(attr) for o in pool]!r}) is " + f"there; the record holds {sorted(observed)!r} and so " + "explains a different transformation than this one") + # A defeat must be CARRIED by revision B, exactly as a boundary is. for signal, source in (exp.get("signals_defeated") or {}).items(): spec = policy["signal_defeaters"].get(signal) @@ -1005,7 +1204,7 @@ def main() -> int: f"{len(set(surviving))} kind(s) survive against a floor of " f"{floor}. Below the floor the shortage really is of kinds.") - for sig in as_list(exp.get("inputs_unavailable")): + for sig in as_list(exp.get(UNAVAILABLE_FIELD)): check(sig in senior["evidence_kinds"] or sig in catalog, f"{where}: unavailable input {sig!r} is neither a frozen evidence " "kind nor a catalogued structural signal") @@ -1162,7 +1361,7 @@ def main() -> int: elif duty == "defeat_removed_a_signal": met |= bool(exp.get("signals_defeated")) elif duty == "input_was_unavailable": - met |= bool(exp.get("inputs_unavailable")) + met |= bool(exp.get(UNAVAILABLE_FIELD)) elif duty == "rule_licensed_alone": # The rule is not merely present, it is the ONLY thing present. # `licensed_by` alone would be satisfied by a rule riding along From 236d9e604dbf7c320e811776e8141fe29e364a4b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 10:44:19 +0000 Subject: [PATCH 29/84] fix(identity): a record relates a pair, or it relates nothing Four review findings, all confirmed by mutation, three of them against the record binding added one commit ago. 1. THE FIELDS WERE POOLED ACROSS RECORDS. Each matched field was collected independently over every entry, which threw away the relation between them. Two copies reading `Doc.cs -> Unrelated.cs` and `Other.cs -> DocCopy.cs` put the predecessor somewhere in the pooled `from` and the successor somewhere in the pooled `to`, and passed - though no single record connects the two. The binding is now conjunctive PER ENTRY: a record relates a pair or it relates nothing, and the quantifiers apply to the pairs a record actually establishes. 2. 1:1 RULES WERE SKIPPED ENTIRELY. `record_binding` is declared only by group rules, and the loop took that as nothing to check - so R-CONT-COPY licensed `continued` on a copy record pointing at an unrelated path. At 1:1 there is one occurrence per role, so `every` and `at_least_one` are the same condition and applying it chooses no policy. Declared in the vocabulary rather than assumed in the checker. The report named R-CONT-COPY. The same hole was open one rule over and is fixed as a class: R-CONT-RENAME names the senior evidence KIND `path_rename` in `requires_all`, not a catalog record, so nothing bound `revision_b.renames` at all and the rename case passed with the record describing two unrelated files. `evidence_kind_records` maps the kind to the record that observes it; no rule's requirements change. 3. AN OUTCOME ON A CONTAINER. The previous commit added `outcome: unresolved` to the `multiplicity` parent, whose two children reach DIFFERENT answers - one is branched or merged per the structural rules, the other refuses. Whatever the parent claims contradicts a child. The walk accepted it by treating every non-`all_agree` node with an outcome as a refusal; it now requires a decision leaf to name a reason or implement a mapping entry, and rejects an outcome on anything else. 4. `all_agree.outcome` WAS CHECKED FOR WHAT IT IS NOT. The assertion proved only that the value is none of the six senior literals, so deleting the field or writing `no outcome` passed - and the field exists precisely to tell a mapper the value is computed. It is now a marker, `{computed_from: surviving_rules, field: outcome}`, checked exactly. (3) and (4) are the same defect a commit apart: a check whose message claims more than its code checks. (3) is worse, because it was introduced while fixing declarations nobody reads, and the fix added one. Twelve probes bite, including the rename record and both earlier quantifier probes as regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 38 +++++- identity/tests/test_lineage_decision.py | 146 +++++++++++++++------ 2 files changed, 144 insertions(+), 40 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 620aa10..5e02d67 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -255,7 +255,11 @@ "implements": "no_rule_applied" }, "all_agree": { - "outcome": "the shared outcome", + "outcome": { + "computed_from": "surviving_rules", + "field": "outcome", + "why": "NOT a literal. The value is whatever outcome the surviving rules share, and this was the prose string `the shared outcome` until review pointed out that a check rejecting only the six senior literals accepts `no outcome`, or the field being deleted. A marker a mapper can read is the whole point of the field." + }, "licensed_by": "EVERY applicable rule, not the first or the strongest", "why": "the record has to survive one of those rules being retired later", "this_is_not_a_separate_law": [ @@ -310,8 +314,7 @@ "Cardinality is a precondition now, so a lone successor never licenses", "`branched` in the first place; see `cardinality_rule` for why that is a", "different thing from arriving at the same answer two sections later." - ], - "outcome": "unresolved" + ] }, "licensed_by_rule": [ "ONE definition, and the branches above are readings of it: `licensed_by` is", @@ -624,6 +627,19 @@ }, "why": "defeats anchored_content: equality across a formatter pass is a property of the formatter", "note": "not exercised by any finding-lineage/v1 fixture; the step 1 fixtures introduce it" + }, + "rename_record": { + "observable_from": "revision_b.renames", + "entry_shape": { + "from": "path", + "to": "path", + "similarity": "int" + }, + "matches": { + "from": "predecessor.path", + "to": "successor.path" + }, + "why": "the record that says a file MOVED rather than being deleted and another born" } }, "structural_signals_rule": [ @@ -917,6 +933,22 @@ "A quantifier or exclusion not listed here is REJECTED rather than ignored.", "A binding vocabulary that silently accepts unknown tokens is a binding that stops", "binding the moment someone invents a word." + ], + "at_one_to_one": "A 1:1 rule declares NO `record_binding`, because at one occurrence per role `every` and `at_least_one` are the same condition and there is nothing to choose. The record must still relate the pair: skipping the check there let R-CONT-COPY license a `continued` on a copy pointing at an unrelated path." + }, + "evidence_kind_records": { + "path_rename": "rename_record", + "why": [ + "Some rules name a senior EVIDENCE KIND in `requires_all` and some name a", + "record from `structural_signals` directly. `path_rename` is the first sort, and", + "so the record behind it was bound by nothing: R-CONT-RENAME could license a", + "`continued` while `revision_b.renames` described two unrelated files. That is", + "the same defect review found for R-CONT-COPY, one rule over, and fixing only", + "the reported instance would have left the class.", + "", + "This maps the kind to the record that OBSERVES it. It does not change what any", + "rule requires - `requires_all` still names `path_rename` - it says where a", + "checker or a mapper must look to see whether the claim is carried." ] } } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 697b379..4e8a96d 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -513,6 +513,17 @@ def arbitration_nodes(node, trail): if node is agree: continue got = node.get("outcome") + # A DECISION LEAF, not a container. `multiplicity` holds two branches that + # reach DIFFERENT answers - one is `branched` or `merged` per the + # structural rules, the other refuses - so an outcome on the parent + # contradicts a child whatever it says. The first version of this check + # accepted `unresolved` there because it treated every non-`all_agree` + # node carrying an outcome as a refusal. + check("reason" in node or "implements" in node, + f"{path_a} declares outcome {got!r} but names no reason and implements " + "no `reason_mapping` entry, so it is a container rather than a decision. " + "Its children reach the outcomes; a parent claiming one contradicts " + "whichever child disagrees.") check(got == "unresolved", f"{path_a} declares outcome {got!r}. Every arbitration branch that is " "not `all_agree` refuses, and `arbitrate` answers `unresolved` on all of " @@ -521,11 +532,23 @@ def arbitration_nodes(node, trail): check("implements" not in agree, "`arbitration.all_agree` is not a refusal and must not implement a " "`reason_mapping` entry; the record it produces carries no reason at all.") - check(agree.get("outcome") not in senior_outcomes, - f"`arbitration.all_agree.outcome` names the literal {agree.get('outcome')!r}. " - "It is computed from the surviving rules - naming one outcome freezes a law " - "about every agreeing set into a claim about one of them, and the survivor " - "algebra says otherwise.") + # A MARKER, not merely a non-literal. This asked only that the value was none + # of the six senior outcomes, so deleting the field or writing `no outcome` + # passed - and the field exists precisely to tell a mapper the value is + # computed. `not in senior_outcomes` is the check claiming more than it + # checks, one more time. + agree_out = agree.get("outcome") + check(isinstance(agree_out, dict), + f"`arbitration.all_agree.outcome` is {agree_out!r}. It must be a machine-" + "readable marker saying the value is COMPUTED; a prose string cannot be " + "distinguished from a missing field or from a wrong one.") + if isinstance(agree_out, dict): + check(agree_out.get("computed_from") == "surviving_rules" + and agree_out.get("field") == "outcome", + f"`arbitration.all_agree.outcome` marks {agree_out.get('computed_from')!r}" + f".{agree_out.get('field')!r}; the outcome is the `outcome` shared by the " + "SURVIVING rules, which is what `licensed_by_rule` and `arbitrate` both " + "implement.") conflict_reason = (policy["reason_mapping"].get("conflicting_rules") or {}).get("reason") for entry in policy["deliberately_unresolved_conflicts"]: @@ -727,6 +750,8 @@ def arbitration_nodes(node, trail): # ---- 4. Structural signals are observable, like boundary evidence. ------ catalog = policy["structural_signals"] + kind_records = {k: v for k, v in (policy.get("evidence_kind_records") or {}).items() + if isinstance(v, str)} for name, spec in catalog.items(): for field in ("observable_from", "matches", "why"): check(field in spec, f"structural signal {name!r} must state {field!r}") @@ -1076,54 +1101,101 @@ def arbitration_nodes(node, trail): # LICENSED, not merely applicable. An applicable-but-dominated rule # never named this mapping's group, and on a refused conflict the # sides are the unresolved record's own - side a names `frm` with `to` - # empty - so there is no group to bind against at all. Checking `app` - # here failed all four expectations of - # `copy-source-that-is-also-a-fold-refuses`, which is the check being - # wrong rather than the fixture. + # empty - so there is no group to bind against at all. + # + # PER ENTRY, CONJUNCTIVELY. The first version of this collected each + # matched field independently across every record, which threw away + # the relation between them: two copies reading + # `Doc.cs -> Unrelated.cs` and `Other.cs -> DocCopy.cs` put the + # predecessor somewhere in the pooled `from` and the successor + # somewhere in the pooled `to`, and passed, though no single record + # connects the two. A record relates a PAIR or it relates nothing. for rid in lic: - binding = rules[rid].get("record_binding") + rule_r = rules[rid] + binding = rule_r.get("record_binding") + shape_r = (rule_r.get("cardinality") or {}).get("shape") + if binding is None and shape_r == "1:1": + # At 1:1 there is exactly one occurrence in each role, so + # `every` and `at_least_one` are the SAME condition and the + # checker chooses no policy by applying it - see + # `record_binding_vocabulary.at_one_to_one`. Skipping instead + # left R-CONT-COPY's record unbound: repointing the copy at an + # unrelated path still licensed `continued`. + binding = {"predecessor": {"quantifier": "every"}, + "successor": {"quantifier": "every"}} if not binding: continue - for sig in (rules[rid].get("requires_all") or []): + for kind in (rule_r.get("requires_all") or []): + sig = kind if kind in catalog else kind_records.get(kind) cat_s = catalog.get(sig) if not cat_s: continue - for read, role, attr in signal_bindings(cat_s): - observed = collect(rev_b, read) - check(bool(observed), - f"{where}: {rid} applies on {sig!r}, but {read} carries " - "nothing. A record cited in `applicable_rules` and absent " - "from revision B is a record asserted in a note.") - subjects = frm if role == "predecessor" else to + read_field = str(cat_s.get("observable_from", "")).split("[].")[0] + entries = (rev_b.get(read_field.partition(".")[2]) or [] + if read_field.startswith("revision_b.") else []) + check(bool(entries), + f"{where}: {rid} rests on {sig!r}, but {read_field} carries " + "nothing. A record cited by a licensing rule and absent from " + "revision B is a record asserted in a note.") + # `signal_bindings` yields the full dotted READ PATH, not the + # bare entry key - `revision_b.copies[].from`, not `from`. + pairs = [(read.split("[].")[-1], role, at) + for read, role, at in signal_bindings(cat_s)] + + # Eligible partners per role, after the declared exclusion. + pools = {} + for _r, role in (("", "predecessor"), ("", "successor")): spec_r = binding.get(role) or {} - quant = spec_r.get("quantifier") - excl = spec_r.get("excluding") - pool = list(subjects) - if excl == "partners_where_same_path_holds": + pool = list(frm if role == "predecessor" else to) + if spec_r.get("excluding") == "partners_where_same_path_holds": others = to if role == "predecessor" else frm paths = {by_id.get(o, {}).get("path") for o in others} pool = [o for o in pool if by_id.get(o, {}).get("path") not in paths] check(bool(pool), f"{where}: {rid} excludes partners at the " - f"predecessor's path and {role}s has none left. The " - "record would then explain nothing this mapping needs.") - named = [o for o in pool - if by_id.get(o, {}).get(attr) in observed] + f"predecessor's path and no {role} is left. The record " + "would then explain nothing this mapping needs.") + pools[role] = pool + + def names(entry: dict, key: str, wanted) -> bool: + """One record entry naming one occurrence, on one key. + A fold's `from` is a LIST of symbols and a copy's is a + single path; `entry_shape` says which, so membership and + equality are the same question asked of two shapes.""" + if not isinstance(entry, dict) or key not in entry: + return False + val = entry[key] + return wanted in val if isinstance(val, list) else wanted == val + + related = set() + for entry in entries: + for p in pools["predecessor"]: + for t in pools["successor"]: + subj = {"predecessor": p, "successor": t} + if all(names(entry, key, + by_id.get(subj[role], {}).get(at)) + for key, role, at in pairs): + related.add((p, t)) + + for role in ("predecessor", "successor"): + spec_r = binding.get(role) or {} + quant = spec_r.get("quantifier") + idx = 0 if role == "predecessor" else 1 + reached = {pair[idx] for pair in related} + pool = pools[role] if quant == "every": - missing = [o for o in pool if o not in named] + missing = [o for o in pool if o not in reached] check(not missing, - f"{where}: {rid} binds EVERY {role} into {read}, but " - f"{missing!r} " - f"({[by_id.get(o, {}).get(attr) for o in missing]!r}) " - f"is absent; the record holds {sorted(observed)!r}") + f"{where}: {rid} binds EVERY {role} through {sig}, but " + f"no single entry of {read_field} relates {missing!r} " + "to a partner on this mapping") elif quant == "at_least_one": - check(bool(named), - f"{where}: {rid} binds at least one {role} into " - f"{read}, and none of {pool!r} " - f"({[by_id.get(o, {}).get(attr) for o in pool]!r}) is " - f"there; the record holds {sorted(observed)!r} and so " - "explains a different transformation than this one") + check(bool(reached), + f"{where}: {rid} binds at least one {role} through " + f"{sig}, and no entry of {read_field} relates any of " + f"{pool!r} to a partner. The record explains a " + "different transformation than this one.") # A defeat must be CARRIED by revision B, exactly as a boundary is. for signal, source in (exp.get("signals_defeated") or {}).items(): From 791967ed6a1e5ab7e7651936437434875f5eb405 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:04:35 +0000 Subject: [PATCH 30/84] fix(identity): one record must carry the whole group Two findings on 236d9e6, both confirmed by mutation. 1. THE QUANTIFIER WAS APPLIED ACROSS THE UNION. The previous commit fixed field pooling within a record and then pooled one level up: pairs were accumulated over every entry before `every` was applied to the total. A fold split into `WireA -> WireA` and `WireB -> WireA` satisfied it between them, though neither record names both predecessors and the two describe SEPARATE folds - so a mapping could be preregistered `merged` on records of two different transformations. R-MERGE-FOLD's own text says "THE RECORD's `from` list must carry every predecessor", singular, so the quantifier belongs inside one entry and now lives there. The copy analogue fails too. Two commits, two versions of one mistake: relating things that are only adjacent. Fixing it at the field level did not make me look for it at the record level. 2. The doc claimed `Ten` cases above a table numbered to 12. The number is now gone from the prose rather than corrected - a count restated beside the list it counts has now gone stale three times on this branch, which is the argument against restating it, not against that particular ten. "The doc mentions every case" was too weak to catch it, since it never looked at the table. The table is now checked row by row against `preregistered_cases`: same cases, same order, numbered 1..N with no gap and no repeat. A numbering gap, a deleted row and a row naming the wrong case all fail. Nine probes bite, four of them earlier ones re-run as regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- docs/finding-lineage-decision.md | 8 ++- identity/tests/test_lineage_decision.py | 88 +++++++++++++++++-------- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 32202c7..33e97fa 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -284,8 +284,12 @@ growing quietly until someone notices the bill. ## The preregistered matrix -Ten cases in `identity/fixtures/lineage-decision/`, fixed before any mapper, -gated on **exact** equality with `preregistered_cases`. Each is a shape where two +The cases below live in `identity/fixtures/lineage-decision/`, fixed before any +mapper, and are gated on **exact** equality with `preregistered_cases`. The table +is the count; this sentence deliberately does not repeat it, because the number +was written here as `Ten` and went stale the moment cases 11 and 12 landed — the +third time on this branch that a count restated in prose has drifted from the +list beside it. Each is a shape where two answers are available and the policy has to say which, or say neither. | # | case | expected | diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 4e8a96d..5fe20bf 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -1168,34 +1168,46 @@ def names(entry: dict, key: str, wanted) -> bool: val = entry[key] return wanted in val if isinstance(val, list) else wanted == val - related = set() - for entry in entries: - for p in pools["predecessor"]: - for t in pools["successor"]: - subj = {"predecessor": p, "successor": t} - if all(names(entry, key, - by_id.get(subj[role], {}).get(at)) - for key, role, at in pairs): - related.add((p, t)) - - for role in ("predecessor", "successor"): - spec_r = binding.get(role) or {} - quant = spec_r.get("quantifier") - idx = 0 if role == "predecessor" else 1 - reached = {pair[idx] for pair in related} - pool = pools[role] - if quant == "every": - missing = [o for o in pool if o not in reached] - check(not missing, - f"{where}: {rid} binds EVERY {role} through {sig}, but " - f"no single entry of {read_field} relates {missing!r} " - "to a partner on this mapping") - elif quant == "at_least_one": - check(bool(reached), - f"{where}: {rid} binds at least one {role} through " - f"{sig}, and no entry of {read_field} relates any of " - f"{pool!r} to a partner. The record explains a " - "different transformation than this one.") + # ONE RECORD MUST CARRY THE WHOLE GROUP. The previous + # version accumulated pairs over every entry and then applied + # the quantifiers to the union, which pools sources one level + # up from the pooling it had just fixed: a fold split into + # `WireA -> WireA` and `WireB -> WireA` satisfied `every` + # between them, though neither record names both predecessors + # and the two describe separate folds. The rule's own `why` + # says "THE RECORD's `from` list must carry every + # predecessor" - singular - so the quantifier belongs inside + # one entry. + def satisfied_by(entry: dict) -> bool: + related = {(p, t) + for p in pools["predecessor"] + for t in pools["successor"] + if all(names(entry, key, + by_id.get({"predecessor": p, + "successor": t}[role], + {}).get(at)) + for key, role, at in pairs)} + for role in ("predecessor", "successor"): + quant = (binding.get(role) or {}).get("quantifier") + idx = 0 if role == "predecessor" else 1 + reached = {pair[idx] for pair in related} + if quant == "every": + if any(o not in reached for o in pools[role]): + return False + elif quant == "at_least_one": + if not reached: + return False + return True + + check(any(satisfied_by(e) for e in entries), + f"{where}: {rid} rests on {sig!r}, and no SINGLE entry of " + f"{read_field} relates this mapping's occurrences as its " + f"`record_binding` requires. Predecessors " + f"{[by_id.get(o, {}).get('enclosing_symbol') or by_id.get(o, {}).get('path') for o in pools['predecessor']]!r}, " + f"successors " + f"{[by_id.get(o, {}).get('enclosing_symbol') or by_id.get(o, {}).get('path') for o in pools['successor']]!r}; " + f"the records hold {entries!r}. Two records describing two " + "transformations do not add up to one transformation.") # A defeat must be CARRIED by revision B, exactly as a boundary is. for signal, source in (exp.get("signals_defeated") or {}).items(): @@ -1555,6 +1567,26 @@ def names(entry: dict, key: str, wanted) -> bool: check(case_name in doc, f"the doc does not mention preregistered case {case_name!r}") for rid in ids: check(rid in doc, f"the doc does not mention rule {rid!r}") + + # THE CASE TABLE, ROW BY ROW. "Mentions every case" left the table free to + # number itself however it liked, and the prose above it claimed `Ten` while + # the rows ran to 12. A count restated in prose goes stale exactly as often as + # it is restated, so the number now lives only in the table - and the table is + # checked against the contract instead of being trusted. + doc_rows = re.findall(r"^\|\s*(\d+)\s*\|\s*`([a-z0-9-]+)`\s*\|", + doc, re.M) + check([int(n) for n, _ in doc_rows] == list(range(1, len(preregistered) + 1)), + f"the doc's case table is numbered {[n for n, _ in doc_rows]}; " + f"`preregistered_cases` holds {len(preregistered)} cases, so the rows must " + "run 1..N with no gap and no repeat") + check([c for _, c in doc_rows] == list(preregistered), + "the doc's case table lists\n " + + "\n ".join(c for _, c in doc_rows) + + "\nbut `preregistered_cases` is\n " + + "\n ".join(preregistered) + + "\nSame cases in the same order, or the table is describing a different " + "matrix than the one that runs.") + for reason in sorted(emitted): tail = reason.rsplit(":", 1)[-1] check(tail in doc, f"the doc does not mention the reason {tail!r} the policy emits") From 0ad1a953cda87cdc58abfd9ed9c66cd2698acb21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:13:44 +0000 Subject: [PATCH 31/84] fix(identity): a refused conflict is still carried by its fixture Two findings on 791967e, one from each reviewer, both confirmed by mutation. 1. A REFUSED CONFLICT LICENSES NOTHING, so the record binding skipped it entirely. `copy-source-that-is-also-a-fold-refuses` kept its `conflicting-evidence` while its copy record pointed at `Other/Nope.cs` - half the conflict the case preregisters was not carried by the fixture at all. Scoping the loop to `licensed_by` one commit ago fixed a real error, the refusal's `frm`/`to` being the unresolved record's own sides rather than a group, and dropped this coverage silently along with it. A fix that removes coverage while fixing a bug is worse than the bug, because the suite gets greener and says less. What is checkable here is weaker than the licensed case, and the limit is recorded rather than papered over: the record must RELATE some pair of this fixture's occurrences, so that it is about them instead of about unrelated files. It is NOT held to the rule's quantifiers, because the group an unlicensed rule would have formed is applicability. Applying the fixture-wide occurrence set to `every` would reject this very case - the fold names neither the copy target nor its symbol, correctly, since those are the other half of the conflict. 2. `evidence_kind_records` could point at a record that does not exist. Resolution ends in `catalog.get`, and a miss there reads as "this requirement has no structural record" - the right answer for `same_path`, the wrong one for a mapping that names nothing. Repointing `path_rename` at `missing_rename_record` left R-CONT-RENAME licensed and its record unchecked: fail-open, introduced in the resolution step added one commit earlier to close a fail-open. Each mapped value must now name a real signal, each key a senior evidence kind, and a key that is already a signal is rejected because it would resolve two ways. The record reader is now one pair of module-level helpers rather than closures rebuilt per rule, so the licensed path and the refused path cannot drift into reading records differently. Eight probes bite, three of them earlier ones re-run as regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 197 +++++++++++++++--------- 1 file changed, 125 insertions(+), 72 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 5fe20bf..b8023c7 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -296,6 +296,45 @@ def signal_bindings(spec: dict) -> list: return out +def record_names(entry: dict, key: str, wanted) -> bool: + """One record entry naming one occurrence, on one key. A fold's `from` is a + LIST of symbols and a copy's is a single path; `entry_shape` says which, so + membership and equality are the same question asked of two shapes.""" + if not isinstance(entry, dict) or key not in entry: + return False + val = entry[key] + return wanted in val if isinstance(val, list) else wanted == val + + +def related_by(entry: dict, pairs: list, preds, succs, by_id: dict) -> set: + """The (predecessor, successor) pairs ONE record entry relates. + + Conjunctive across the keys the catalog matches on: a record relates a pair + or it relates nothing. Collecting the keys separately and intersecting later + accepts `Doc.cs -> Unrelated.cs` beside `Other.cs -> DocCopy.cs` as though + one record connected the two.""" + out = set() + for p in preds: + for t in succs: + subject = {"predecessor": p, "successor": t} + if all(record_names(entry, key, by_id.get(subject[role], {}).get(at)) + for key, role, at in pairs): + out.add((p, t)) + return out + + +def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: + """(field path, entries, match keys) for one structural signal.""" + field = str(cat_spec.get("observable_from", "")).split("[].")[0] + entries = (rev_b.get(field.partition(".")[2]) or [] + if field.startswith("revision_b.") else []) + # `signal_bindings` yields the full dotted READ PATH, not the bare entry key + # - `revision_b.copies[].from`, not `from`. + pairs = [(read.split("[].")[-1], role, at) + for read, role, at in signal_bindings(cat_spec)] + return field, entries, pairs + + def raw_dominance_edges(policy: dict) -> list: """Occurrences, NOT a set. `classified exactly once` is a claim about the declarations, and a set answers a weaker question - it would let @@ -825,6 +864,27 @@ def arbitration_nodes(node, trail): f"{where_s}.matches names {sorted(set(matches or {}) - set(shape))}, " "which `entry_shape` does not declare") + # THE KIND -> RECORD MAP MUST NAME A REAL RECORD. Resolution ends in + # `catalog.get(sig)`, and a miss there reads as "this requirement has no + # structural record", which is the correct answer for `same_path` and the + # wrong one for a mapping that points at nothing. Repointing `path_rename` at + # `missing_rename_record` left R-CONT-RENAME licensed and its record + # unchecked - fail-open, in the resolution step added to close a fail-open. + senior_kinds = set(senior["evidence_kinds"]) + for kind, sig_name in sorted(kind_records.items()): + check(kind in senior_kinds, + f"`evidence_kind_records` maps {kind!r}, which `finding-lineage/v1` does " + "not carry as an evidence kind. The map exists to say where a SENIOR " + "kind is observed; a key that is not one binds nothing.") + check(kind not in policy["structural_signals"], + f"`evidence_kind_records` maps {kind!r}, which is already a structural " + "signal. It would then resolve two ways, and the two could disagree.") + check(sig_name in policy["structural_signals"], + f"`evidence_kind_records` maps {kind!r} to {sig_name!r}, which the " + "structural catalog does not carry. Resolution would find no record and " + "silently skip the binding, which is exactly what the map was added to " + "prevent.") + # ...and the group quantifier, which used to live in `requires_all_scope` as # prose. A 1:1 rule could claim GROUP scope and a group rule could drop the # declaration entirely, both silently. @@ -1098,29 +1158,18 @@ def arbitration_nodes(node, trail): # from the checker: copy explains ONE successor and fold names EVERY # predecessor, and a checker picking either on its own would be # holding an opinion about the policy. - # LICENSED, not merely applicable. An applicable-but-dominated rule - # never named this mapping's group, and on a refused conflict the - # sides are the unresolved record's own - side a names `frm` with `to` - # empty - so there is no group to bind against at all. - # - # PER ENTRY, CONJUNCTIVELY. The first version of this collected each - # matched field independently across every record, which threw away - # the relation between them: two copies reading - # `Doc.cs -> Unrelated.cs` and `Other.cs -> DocCopy.cs` put the - # predecessor somewhere in the pooled `from` and the successor - # somewhere in the pooled `to`, and passed, though no single record - # connects the two. A record relates a PAIR or it relates nothing. + # LICENSED rules are bound against THIS mapping's group. The + # quantifiers live inside one entry: a fold split into + # `WireA -> WireA` and `WireB -> WireA` describes two separate folds, + # and two records do not add up to one transformation. for rid in lic: rule_r = rules[rid] binding = rule_r.get("record_binding") - shape_r = (rule_r.get("cardinality") or {}).get("shape") - if binding is None and shape_r == "1:1": + if binding is None and (rule_r.get("cardinality") or {}).get("shape") == "1:1": # At 1:1 there is exactly one occurrence in each role, so # `every` and `at_least_one` are the SAME condition and the # checker chooses no policy by applying it - see - # `record_binding_vocabulary.at_one_to_one`. Skipping instead - # left R-CONT-COPY's record unbound: repointing the copy at an - # unrelated path still licensed `continued`. + # `record_binding_vocabulary.at_one_to_one`. binding = {"predecessor": {"quantifier": "every"}, "successor": {"quantifier": "every"}} if not binding: @@ -1130,21 +1179,13 @@ def arbitration_nodes(node, trail): cat_s = catalog.get(sig) if not cat_s: continue - read_field = str(cat_s.get("observable_from", "")).split("[].")[0] - entries = (rev_b.get(read_field.partition(".")[2]) or [] - if read_field.startswith("revision_b.") else []) + read_field, entries, pairs = catalog_read(cat_s, rev_b) check(bool(entries), f"{where}: {rid} rests on {sig!r}, but {read_field} carries " "nothing. A record cited by a licensing rule and absent from " "revision B is a record asserted in a note.") - # `signal_bindings` yields the full dotted READ PATH, not the - # bare entry key - `revision_b.copies[].from`, not `from`. - pairs = [(read.split("[].")[-1], role, at) - for read, role, at in signal_bindings(cat_s)] - - # Eligible partners per role, after the declared exclusion. pools = {} - for _r, role in (("", "predecessor"), ("", "successor")): + for role in ("predecessor", "successor"): spec_r = binding.get(role) or {} pool = list(frm if role == "predecessor" else to) if spec_r.get("excluding") == "partners_where_same_path_holds": @@ -1158,56 +1199,68 @@ def arbitration_nodes(node, trail): "would then explain nothing this mapping needs.") pools[role] = pool - def names(entry: dict, key: str, wanted) -> bool: - """One record entry naming one occurrence, on one key. - A fold's `from` is a LIST of symbols and a copy's is a - single path; `entry_shape` says which, so membership and - equality are the same question asked of two shapes.""" - if not isinstance(entry, dict) or key not in entry: - return False - val = entry[key] - return wanted in val if isinstance(val, list) else wanted == val - - # ONE RECORD MUST CARRY THE WHOLE GROUP. The previous - # version accumulated pairs over every entry and then applied - # the quantifiers to the union, which pools sources one level - # up from the pooling it had just fixed: a fold split into - # `WireA -> WireA` and `WireB -> WireA` satisfied `every` - # between them, though neither record names both predecessors - # and the two describe separate folds. The rule's own `why` - # says "THE RECORD's `from` list must carry every - # predecessor" - singular - so the quantifier belongs inside - # one entry. - def satisfied_by(entry: dict) -> bool: - related = {(p, t) - for p in pools["predecessor"] - for t in pools["successor"] - if all(names(entry, key, - by_id.get({"predecessor": p, - "successor": t}[role], - {}).get(at)) - for key, role, at in pairs)} + def carries(entry: dict) -> bool: + rel = related_by(entry, pairs, pools["predecessor"], + pools["successor"], by_id) for role in ("predecessor", "successor"): quant = (binding.get(role) or {}).get("quantifier") - idx = 0 if role == "predecessor" else 1 - reached = {pair[idx] for pair in related} - if quant == "every": - if any(o not in reached for o in pools[role]): - return False - elif quant == "at_least_one": - if not reached: - return False + reached = {p[0 if role == "predecessor" else 1] for p in rel} + if quant == "every" and any(o not in reached + for o in pools[role]): + return False + if quant == "at_least_one" and not reached: + return False return True - check(any(satisfied_by(e) for e in entries), + def shown(ids_): + return [by_id.get(o, {}).get("enclosing_symbol") + or by_id.get(o, {}).get("path") for o in ids_] + check(any(carries(e) for e in entries), f"{where}: {rid} rests on {sig!r}, and no SINGLE entry of " - f"{read_field} relates this mapping's occurrences as its " - f"`record_binding` requires. Predecessors " - f"{[by_id.get(o, {}).get('enclosing_symbol') or by_id.get(o, {}).get('path') for o in pools['predecessor']]!r}, " - f"successors " - f"{[by_id.get(o, {}).get('enclosing_symbol') or by_id.get(o, {}).get('path') for o in pools['successor']]!r}; " - f"the records hold {entries!r}. Two records describing two " - "transformations do not add up to one transformation.") + f"{read_field} relates this mapping as its `record_binding` " + f"requires. Predecessors {shown(pools['predecessor'])!r}, " + f"successors {shown(pools['successor'])!r}; the records hold " + f"{entries!r}. Two records describing two transformations do " + "not add up to one transformation.") + + # A REFUSED CONFLICT LICENSES NOTHING, and the loop above therefore + # skipped it entirely - so `copy-source-that-is-also-a-fold-refuses` + # kept its `conflicting-evidence` while its copy record pointed at + # `Other/Nope.cs`. Half the conflict the case preregisters was not + # carried by the fixture at all. Scoping to `lic` fixed a real error + # (the refusal's `frm`/`to` are the unresolved record's own sides, not + # a group) and quietly dropped this coverage with it. + # + # What is checkable here is weaker, and the limit is the point: the + # record must RELATE some pair of this fixture's occurrences, so it is + # about them rather than about unrelated files. It is NOT checked + # against the rule's quantifiers, because the group an unlicensed rule + # would have formed is applicability, and this suite does not compute + # it. Applying the fixture-wide occurrence set to `every` would reject + # this very case: the fold names neither `DocCopyView.Wire` nor the + # copy target, correctly, because they are the other half of the + # conflict. + for rid in sorted(set(app) - set(lic)): + for kind in (rules[rid].get("requires_all") or []): + sig = kind if kind in catalog else kind_records.get(kind) + cat_s = catalog.get(sig) + if not cat_s: + continue + read_field, entries, pairs = catalog_read(cat_s, rev_b) + check(bool(entries), + f"{where}: {rid} is applicable on {sig!r}, but {read_field} " + "carries nothing") + touched = set() + for entry in entries: + touched |= related_by(entry, pairs, sorted(occ_a), + sorted(occ_b), by_id) + check(bool(touched), + f"{where}: {rid} is declared applicable on {sig!r}, and no " + f"entry of {read_field} relates ANY occurrence of this case " + f"to another. The records hold {entries!r}, which describe a " + "transformation somewhere else. A rule that establishes half " + "of a refused conflict has to be carried by the fixture like " + "any other.") # A defeat must be CARRIED by revision B, exactly as a boundary is. for signal, source in (exp.get("signals_defeated") or {}).items(): From efd30e67986f8413e3a011b6ff4012dca4de6b11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:16:49 +0000 Subject: [PATCH 32/84] fix(identity): a type filter is not a namespace `evidence_kind_records` held its mappings beside a prose `why`, so the suite told them apart by keeping only string values. That filter was itself the fail-open: `path_rename: null` - or a list, or an object - dropped out BEFORE validation, resolved to `catalog.get(None)`, and left R-CONT-RENAME licensed with its rename record never checked. All four shapes were green. The validation added last commit could not see it, because it iterated the already-filtered mapping. A check that runs after the filter can only ever confirm what the filter let through. Fixed at the root rather than by tightening the filter: the mappings now live under `map` and nothing else does, so there is nothing to classify, no filter, and every entry is validated. Emptying or deleting `map` fails too, since an absent map unbinds every rule that names a senior evidence kind. This is the third consecutive commit in which a fix carried its own defect into the mechanism that fixed it: a fail-open closed by a resolution step that failed open, then that step's validation defeated by the filter feeding it. The common shape is a check placed downstream of something that can silently drop its input. Eight probes bite, two of them last commit's re-run as regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 13 +++++++++++-- identity/tests/test_lineage_decision.py | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 5e02d67..f3c53b2 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -937,7 +937,9 @@ "at_one_to_one": "A 1:1 rule declares NO `record_binding`, because at one occurrence per role `every` and `at_least_one` are the same condition and there is nothing to choose. The record must still relate the pair: skipping the check there let R-CONT-COPY license a `continued` on a copy pointing at an unrelated path." }, "evidence_kind_records": { - "path_rename": "rename_record", + "map": { + "path_rename": "rename_record" + }, "why": [ "Some rules name a senior EVIDENCE KIND in `requires_all` and some name a", "record from `structural_signals` directly. `path_rename` is the first sort, and", @@ -948,7 +950,14 @@ "", "This maps the kind to the record that OBSERVES it. It does not change what any", "rule requires - `requires_all` still names `path_rename` - it says where a", - "checker or a mapper must look to see whether the claim is carried." + "checker or a mapper must look to see whether the claim is carried.", + "", + "The mappings live under `map` and nothing else does. They were siblings of this", + "prose, so the suite filtered the object by value type to tell them apart - and", + "that filter was itself a fail-open: `path_rename: null` dropped out before", + "validation and resolved to no record, leaving R-CONT-RENAME licensed with its", + "rename record unchecked. A type filter standing in for a namespace is a check", + "that silently agrees with whatever it cannot classify." ] } } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index b8023c7..e2c3adc 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -789,8 +789,13 @@ def arbitration_nodes(node, trail): # ---- 4. Structural signals are observable, like boundary evidence. ------ catalog = policy["structural_signals"] - kind_records = {k: v for k, v in (policy.get("evidence_kind_records") or {}).items() - if isinstance(v, str)} + # NO TYPE FILTER. This read the whole object and kept the string values, to + # skip the prose sibling - so a mapping whose target was `null`, a list or an + # object vanished BEFORE validation and resolved to `catalog.get(None)`, + # leaving the rule licensed and its record unchecked. The mappings now live + # under `map` and the prose does not, so there is nothing to classify and + # every entry is validated. + kind_records = (policy.get("evidence_kind_records") or {}).get("map") or {} for name, spec in catalog.items(): for field in ("observable_from", "matches", "why"): check(field in spec, f"structural signal {name!r} must state {field!r}") @@ -871,7 +876,14 @@ def arbitration_nodes(node, trail): # `missing_rename_record` left R-CONT-RENAME licensed and its record # unchecked - fail-open, in the resolution step added to close a fail-open. senior_kinds = set(senior["evidence_kinds"]) + check(isinstance(kind_records, dict) and bool(kind_records), + "`evidence_kind_records.map` must carry the kind -> record mappings; an " + "empty or missing map silently unbinds every rule naming a senior kind") for kind, sig_name in sorted(kind_records.items()): + check(isinstance(sig_name, str), + f"`evidence_kind_records.map[{kind!r}]` is {sig_name!r}. A non-string " + "target resolves to no record and skips the binding, which is the " + "fail-open this map exists to close.") check(kind in senior_kinds, f"`evidence_kind_records` maps {kind!r}, which `finding-lineage/v1` does " "not carry as an evidence kind. The map exists to say where a SENIOR " From 1f4532699c6fa290ea00166a26ebbcd5b9c4c4b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:36:52 +0000 Subject: [PATCH 33/84] fix(identity): four checks that could not see an absence All four confirmed by mutation, and all four are one shape: a check reading something downstream of a step that silently drops, collapses or omits its input. 1. AN OCCURRENCE ID CLAIMED TWICE. `{o["occurrence_id"] for o in ...}` collapses a repeated id and so does `by_id`, while the raw LIST length still satisfied the 1:N minimum - so `to: ["occ-b1", "occ-b1"]` froze a branch with one distinct successor. Duplicates are now rejected in each revision and in each `frm`/`to`, before anything converts them to a set. 2. AN OCCURRENCE DECIDED TWICE. `claimed_a.update(frm)` proves an occurrence is claimed at least once and forgets who claimed it, so a second, separately well-formed expectation over the same occurrences was accepted - `branched` and `continued` preregistered for one predecessor at once. Claims are counted and owned now; one occurrence gets one decision. 3. AN ARBITRATION LEAF WITH NO OUTCOME. The walker yielded only nodes that already carried `outcome`, so DELETING `none_applies.outcome` removed that leaf from the checks entirely. A validator keyed on the presence of what it validates cannot see an absence. Leaves are now enumerated by what makes them decisions - a reason, or a `reason_mapping` entry they implement - and the outcome is required of each. 4. A MAP THAT WAS VALID AND UNUSED. Every entry of `evidence_kind_records.map` was checked, and the map as a whole was not: replacing it with `same_pattern_id -> rename_record` - a real kind, a real signal, consumed by nothing, since that kind is reached only through a partner profile - passed every per-entry check while `path_rename` resolved to nothing and the rename record went unchecked. The map must now be TOTAL over the senior kinds rules require directly, with `no_structural_record` written out for those no record witnesses. That escape hatch is itself pointable at a kind a record DOES witness, and `path_rename: no_structural_record` was green. Which kinds are pair properties and which need a record is a fact about the vocabulary rather than about any rule, so `evidence_kind_observation` is frozen in `finding-lineage/v1` - the fourth amendment, in the open - and the junior map is checked against it. Reclassifying `path_rename` there now fails too. Fifteen probes bite, two of them earlier ones re-run as regressions. One probe first came back RED for an unrelated reason - stub prose in a synthetic fixture tripping a different check - which would have recorded a real defect as absent; re-run with well-formed input it is green, and the finding stands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 17 ++++- contracts/finding-lineage-v1.json | 38 +++++++++- identity/tests/test_lineage_decision.py | 88 +++++++++++++++++++++- 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index f3c53b2..add03b7 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -938,7 +938,11 @@ }, "evidence_kind_records": { "map": { - "path_rename": "rename_record" + "anchored_content": "no_structural_record", + "line_drift": "no_structural_record", + "path_rename": "rename_record", + "same_path": "no_structural_record", + "structural_context": "no_structural_record" }, "why": [ "Some rules name a senior EVIDENCE KIND in `requires_all` and some name a", @@ -957,7 +961,14 @@ "that filter was itself a fail-open: `path_rename: null` dropped out before", "validation and resolved to no record, leaving R-CONT-RENAME licensed with its", "rename record unchecked. A type filter standing in for a namespace is a check", - "that silently agrees with whatever it cannot classify." - ] + "that silently agrees with whatever it cannot classify.", + "", + "TOTAL, not merely valid. Every senior evidence kind that a rule names directly in", + "`requires_all` must appear here exactly once, with either a record name or", + "`no_structural_record`. Kinds reached only through a `partner_profile` are NOT in", + "the domain, and a key outside it is rejected: an unused-but-valid mapping is how", + "this map was first made to unbind the rule it exists for." + ], + "no_structural_record": "the explicit value for a kind that is a property of the occurrence PAIR rather than a record in revision B. `same_path` compares two paths; no entry in the diff witnesses it. Written out because the map must be TOTAL over the kinds rules require directly - a map that is merely well-formed can be replaced with a valid entry nobody consumes, and then `path_rename` resolves to nothing and its record goes unchecked while every per-entry check still passes." } } diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index fc5cd24..9188447 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -53,7 +53,14 @@ "suite deliberately refuses to compute it. Declaring which kinds cannot co-occur", "closes part of that gap from the vocabulary side instead, where it costs no", "second mapper. It does not close all of it - see the open question recorded in", - "the step 1 pull request." + "the step 1 pull request.", + "", + "Amended a FOURTH time by slice 2 step 1, which added", + "'evidence_kind_observation'. Step 1's kind-to-record map needed a value for", + "kinds that no record witnesses, and any such escape hatch can be pointed at a", + "kind that a record DOES witness. Which kinds those are is a fact about this", + "vocabulary rather than about any rule, so it is settled here and the junior map", + "is checked against it." ], "outcomes": { "continued": { @@ -368,5 +375,32 @@ "and the mapping degrades to `unresolved` while the contract still advertises the", "rule. Junior contracts are checked against this list; they may not add to it from", "below, for the same reason they may not widen `limitations` from below." - ] + ], + "evidence_kind_observation": { + "same_pattern_id": "pair_property", + "same_path": "pair_property", + "path_rename": "revision_record", + "structural_context": "pair_property", + "anchored_content": "pair_property", + "line_drift": "pair_property", + "why": [ + "HOW EACH KIND IS OBSERVED, which decides whether anything in revision B has", + "to be produced to support a claim about it.", + "", + "A `pair_property` is read off the two occurrences themselves: `same_path`", + "compares two paths, `anchored_content` two strings. Nothing in the diff", + "witnesses it and nothing can be checked against a record.", + "", + "A `revision_record` is not visible in the pair at all. Two occurrences at", + "different paths look identical whether the file MOVED or whether one ended and", + "another began; only a record in revision B distinguishes them. That is the", + "whole of the absence-of-record doctrine at the evidence layer, and it is why", + "`path_rename` is the one kind a junior contract must bind to a record.", + "", + "Frozen here, above the decision policy, because a junior contract listing its", + "own record-less kinds could declare `path_rename` record-less and unbind the", + "rename rule while every check on the mapping still passed. That mutation was", + "green until this section existed." + ] + } } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index e2c3adc..b465bc2 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -548,6 +548,23 @@ def arbitration_nodes(node, trail): yield ".".join(trail), node for k, v in node.items(): yield from arbitration_nodes(v, trail + [k]) + # ENUMERATE THE LEAVES, not the nodes that happen to carry the field being + # checked. `arbitration_nodes` yields only nodes that already have `outcome`, + # so DELETING `none_applies.outcome` removed that leaf from the checks + # entirely and left a refusal branch with no declared result. A validator + # keyed on the presence of what it validates cannot see an absence. + def arbitration_leaves(node, trail): + if isinstance(node, dict): + if "reason" in node or "implements" in node: + yield ".".join(trail), node + for k, v in node.items(): + yield from arbitration_leaves(v, trail + [k]) + for path_a, node in arbitration_leaves(policy["arbitration"], ["arbitration"]): + check("outcome" in node, + f"{path_a} names a reason and implements a `reason_mapping` entry, so it " + "is a decision, but declares no `outcome`. A mapper reading it learns " + "what to call the refusal and not that it IS one.") + for path_a, node in arbitration_nodes(policy["arbitration"], ["arbitration"]): if node is agree: continue @@ -879,11 +896,41 @@ def arbitration_nodes(node, trail): check(isinstance(kind_records, dict) and bool(kind_records), "`evidence_kind_records.map` must carry the kind -> record mappings; an " "empty or missing map silently unbinds every rule naming a senior kind") + # TOTAL OVER WHAT RULES REQUIRE DIRECTLY. Validating each entry proves only + # that the entries present are well-formed; the map could be replaced with a + # valid mapping nobody consumes - `same_pattern_id`, reached only through a + # partner profile - and `path_rename` then resolved to nothing while every + # per-entry check passed. Domain equality is the fail-closed form. + NO_RECORD = "no_structural_record" + direct = {k for r in rules.values() for k in (r.get("requires_all") or [])} + want_domain = {k for k in direct if k in senior_kinds} + check(set(kind_records) == want_domain, + "`evidence_kind_records.map` must name exactly the senior evidence kinds " + f"some rule requires DIRECTLY. Missing {sorted(want_domain - set(kind_records))}, " + f"unexpected {sorted(set(kind_records) - want_domain)}. A missing kind " + "resolves to no record and skips its binding; an extra one is a mapping " + "nothing consumes, which is how this map was first made to unbind a rule.") for kind, sig_name in sorted(kind_records.items()): check(isinstance(sig_name, str), f"`evidence_kind_records.map[{kind!r}]` is {sig_name!r}. A non-string " "target resolves to no record and skips the binding, which is the " "fail-open this map exists to close.") + # The escape hatch cannot be aimed at a kind a record DOES witness. + # `path_rename: no_structural_record` unbound the rename rule and stayed + # green: an explicit "nothing observes this" is only honest for kinds + # `finding-lineage/v1` classifies as pair properties. + observed_as = (senior.get("evidence_kind_observation") or {}).get(kind) + check(observed_as in ("pair_property", "revision_record"), + f"`finding-lineage/v1` does not classify {kind!r} as observed from a " + "pair or from a record, so nothing here can say whether a record is " + "owed for it") + check((sig_name == NO_RECORD) == (observed_as == "pair_property"), + f"`evidence_kind_records.map[{kind!r}]` is {sig_name!r} while the senior " + f"contract observes that kind as a {observed_as!r}. A " + f"{'pair property takes ' + NO_RECORD if observed_as == 'pair_property' else 'revision record takes a signal name'}" + ", and the other way round either invents a record or unbinds a real one.") + if sig_name == NO_RECORD: + continue check(kind in senior_kinds, f"`evidence_kind_records` maps {kind!r}, which `finding-lineage/v1` does " "not carry as an evidence kind. The map exists to say where a SENIOR " @@ -892,8 +939,8 @@ def arbitration_nodes(node, trail): f"`evidence_kind_records` maps {kind!r}, which is already a structural " "signal. It would then resolve two ways, and the two could disagree.") check(sig_name in policy["structural_signals"], - f"`evidence_kind_records` maps {kind!r} to {sig_name!r}, which the " - "structural catalog does not carry. Resolution would find no record and " + f"`evidence_kind_records` maps {kind!r} to {sig_name!r}, which is neither " + f"a structural signal nor {NO_RECORD!r}. Resolution would find no record and " "silently skip the binding, which is exactly what the map was added to " "prevent.") @@ -982,10 +1029,30 @@ def arbitration_nodes(node, trail): check(bool(case.get("expect")), f"{name}: nothing is preregistered") rev_a, rev_b = case.get("revision_a", {}), case.get("revision_b", {}) + # BEFORE THE SETS. `{o["occurrence_id"] for o in ...}` silently collapses + # a repeated id, and so does `by_id`; the raw LIST length then still + # satisfied a 1:N minimum, so `to: ["occ-b1", "occ-b1"]` froze a branch + # with one distinct successor. An occurrence id is an identity, and two + # records sharing one is malformed input rather than a duplicate. + for side, rev in (("A", rev_a), ("B", rev_b)): + raw_ids = [o.get("occurrence_id") for o in rev.get("occurrences", [])] + dupes = sorted({i for i in raw_ids if raw_ids.count(i) > 1}) + check(not dupes, + f"{name}: revision {side} declares {dupes!r} more than once. An " + "occurrence id is an identity; the sets below would silently keep " + "one and drop the other.") occ_a = {o["occurrence_id"] for o in rev_a.get("occurrences", [])} occ_b = {o["occurrence_id"] for o in rev_b.get("occurrences", [])} by_id = {o["occurrence_id"]: o for o in rev_a.get("occurrences", []) + rev_b.get("occurrences", [])} + # COUNTED, not collected. `update` proves an occurrence is claimed at + # least once and forgets which expectation claimed it, so a second, + # separately well-formed expectation over the same occurrences was + # accepted - preregistering `branched` and `continued` for one predecessor + # at once. The matrix is a set of decisions about a corpus; two decisions + # about one occurrence is a contradiction frozen into a fixture. + claim_a: dict = {} + claim_b: dict = {} claimed_a, claimed_b = set(), set() for i, exp in enumerate(case.get("expect", [])): @@ -994,6 +1061,16 @@ def arbitration_nodes(node, trail): check(outcome in senior_outcomes, f"{where}: outcome {outcome!r} is not in the frozen vocabulary") frm, to = as_list(exp.get("frm")), as_list(exp.get("to")) + for oid in frm: + claim_a.setdefault(oid, []).append(where) + for oid in to: + claim_b.setdefault(oid, []).append(where) + check(len(set(frm)) == len(frm), + f"{where}: `frm` repeats {sorted({o for o in frm if frm.count(o) > 1})!r}; " + "a repeated id is one occurrence written twice, not two partners") + check(len(set(to)) == len(to), + f"{where}: `to` repeats {sorted({o for o in to if to.count(o) > 1})!r}; " + "a repeated id is one occurrence written twice, not two partners") claimed_a.update(frm) claimed_b.update(to) for o in frm: @@ -1553,6 +1630,13 @@ def shown(ids_): check(met, f"{name}: preregistered to exhibit {duty!r}, and no expectation " f"does. {obligation_meanings.get(duty, '')}") + for side, claims in (("A", claim_a), ("B", claim_b)): + for oid, wheres in sorted(claims.items()): + check(len(wheres) == 1, + f"{name}: revision {side} occurrence {oid!r} is claimed by " + f"{len(wheres)} expectations ({', '.join(wheres)}). One " + "occurrence gets one decision; two is a contradiction " + "preregistered as though it were a matrix.") check(claimed_a == occ_a, f"{name}: revision A occurrences unaccounted for: {sorted(occ_a - claimed_a)}") check(claimed_b == occ_b, From 4ae83f564fcf6f249813890518a4873cf501968d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:45:54 +0000 Subject: [PATCH 34/84] fix(identity): validate the declaration, not the set built from it Four findings on 1f45326, all confirmed by mutation, all the same shape as the four before them: a check reading a normalised view of something the contract actually froze, so a malformed declaration and a well-formed one become indistinguishable before anything looks. 1. AN OCCURRENCE ID IN BOTH REVISIONS. The per-revision duplicate check added last commit sees nothing wrong when A and B each declare `occ-a1` once, and the combined `by_id` then lets the B record overwrite the A record - a self-edge preregistered, and the structural binding reading a successor's attributes while believing they are a predecessor's. `finding-lineage/v1` says an occurrence id cannot span runs; the intersection is now rejected. 2. A REFUSAL DECLARING THREE RULES. `frozenset(["R-A", "R-B", "R-A"])` is a valid two-rule pair, so the declaration passed every downstream check while a mapper reading the contract directly saw a different shape. The raw list is now checked for being a list, for naming exactly two, and for repeating neither - the same reason `raw_dominance_edges` keeps occurrences rather than a set. 3. DUPLICATES IN EVERY DECISION-DETAIL ARRAY. `conflicting_rules`, `rules_without_a_unique_candidate`, `rules_excluded_by_cardinality` and each `ambiguous_candidates` value are all consumed as sets, so a repeated id vanished and two different serialised records both passed. A record is what a mapper writes down; the checker must not be more forgiving about its shape than whatever reads it back. 4. THE SENIOR OBSERVATION WAS NOT TOTAL. `evidence_kind_observation` was added one commit ago to stop a junior escape hatch being aimed at a kind a record witnesses - and it was checked only for kinds the junior map carries. That domain deliberately excludes partner-profile-only kinds, so deleting `same_pattern_id` left it unclassified with everything green. The valid-but-not-total defect, recreated one layer up, inside the section written to fix it. It must now classify every kind in the senior vocabulary and nothing else. Eleven probes bite, two of them earlier ones re-run as regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index b465bc2..121747c 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -344,6 +344,31 @@ def raw_dominance_edges(policy: dict) -> list: for loser in p["loses"]] +def refusal_shape_failures(policy: dict) -> list: + """Complaints about the RAW `between` lists, before any frozenset sees them. + + `frozenset(["R-A", "R-B", "R-A"])` is a valid two-rule pair, so a declaration + with three members passed every downstream check while a mapper reading the + contract directly saw something else. The same reason `raw_dominance_edges` + keeps occurrences rather than a set: the claim is about the DECLARATION.""" + out = [] + for entry in policy["deliberately_unresolved_conflicts"]: + raw = entry.get("between") + if not isinstance(raw, list): + out.append(f"a declared refusal has `between` = {raw!r}, not a list") + continue + if len(raw) != 2: + out.append(f"the declared refusal {raw!r} names {len(raw)} rules; a " + "refusal is a PAIR, and the frozenset below would silently " + "make one out of any repetition") + rep = sorted({r for r in raw if raw.count(r) > 1}) + if rep: + out.append(f"the declared refusal {raw!r} repeats {rep!r}; a rule cannot " + "conflict with itself, and the repetition disappears the " + "moment this becomes a set") + return out + + def raw_refusal_pairs(policy: dict) -> list: return [frozenset(c["between"]) for c in policy["deliberately_unresolved_conflicts"]] @@ -451,6 +476,8 @@ def main() -> int: ids = sorted(rules) outcomes = {r: rules[r]["outcome"] for r in ids} raw_edges = raw_dominance_edges(policy) + for msg in refusal_shape_failures(policy): + check(False, msg) raw_refusals = raw_refusal_pairs(policy) edges = set(raw_edges) refusals = {frozenset(r) for r in raw_refusals} @@ -901,6 +928,22 @@ def arbitration_leaves(node, trail): # valid mapping nobody consumes - `same_pattern_id`, reached only through a # partner profile - and `path_rename` then resolved to nothing while every # per-entry check passed. Domain equality is the fail-closed form. + # TOTAL OVER THE SENIOR VOCABULARY, not over this map's domain. The loop + # below visits only kinds the map carries, and the map deliberately excludes + # partner-profile-only kinds - so removing `same_pattern_id` from the frozen + # observation left it unclassified with everything green. That is the same + # valid-but-not-total defect this map was just fixed for, one layer up, in the + # section added to fix it. + observation = {k: v for k, v in + (senior.get("evidence_kind_observation") or {}).items() + if isinstance(v, str)} + check(set(observation) == set(senior["evidence_kinds"]), + "`finding-lineage/v1.evidence_kind_observation` must classify EVERY evidence " + f"kind. Missing {sorted(set(senior['evidence_kinds']) - set(observation))}, " + f"unexpected {sorted(set(observation) - set(senior['evidence_kinds']))}. A " + "kind left unclassified is one a partner profile would have to guess about, " + "which is the fact this section exists to settle.") + NO_RECORD = "no_structural_record" direct = {k for r in rules.values() for k in (r.get("requires_all") or [])} want_domain = {k for k in direct if k in senior_kinds} @@ -1043,6 +1086,17 @@ def arbitration_leaves(node, trail): "one and drop the other.") occ_a = {o["occurrence_id"] for o in rev_a.get("occurrences", [])} occ_b = {o["occurrence_id"] for o in rev_b.get("occurrences", [])} + # ACROSS the revisions too. Per-revision duplicate checks see nothing + # wrong when A and B each declare `occ-a1` once, and the combined `by_id` + # then lets the B record overwrite the A record - preregistering a + # self-edge, and making the structural binding read a successor's + # attributes while believing they are a predecessor's. An occurrence id + # cannot span runs at all; `finding-lineage/v1` says so. + shared = sorted(occ_a & occ_b) + check(not shared, + f"{name}: {shared!r} appears in BOTH revisions. An occurrence id names " + "one occurrence in one run, so a shared id is two different occurrences " + "under one name - and `by_id` keeps only the second.") by_id = {o["occurrence_id"]: o for o in rev_a.get("occurrences", []) + rev_b.get("occurrences", [])} # COUNTED, not collected. `update` proves an occurrence is claimed at @@ -1510,6 +1564,27 @@ def shown(ids_): # blunted rule and can still demand `no-mapping-evidence`. The field # is the candidate binding FOR the uniqueness rejection; with no # rejection there is nothing for it to bind. + # RAW LISTS FIRST. Every consumer below turns these into sets, so a + # repeated id vanished and two different serialised records - one + # naming a rule once, one twice - both passed. A record is what a + # mapper writes down; the checker must not be more forgiving about + # its shape than the thing reading it. + for field in ("conflicting_rules", "rules_without_a_unique_candidate", + "rules_excluded_by_cardinality"): + raw_f = detail.get(field) + if isinstance(raw_f, list): + rep = sorted({i for i in raw_f if raw_f.count(i) > 1}) + check(not rep, + f"{where}: decision_detail.{field} repeats {rep!r}. The set " + "comparisons below cannot see it, and a consumer that counts " + "the array disagrees with this suite.") + for _rid, _ids in (detail.get("ambiguous_candidates") or {}).items(): + if isinstance(_ids, list): + rep = sorted({i for i in _ids if _ids.count(i) > 1}) + check(not rep, + f"{where}: ambiguous_candidates[{_rid!r}] repeats {rep!r}; one " + "candidate written twice is not two candidates") + cand = detail.get("ambiguous_candidates") blunted_ids = detail.get("rules_without_a_unique_candidate") or [] if blunted_ids or cand: From b9759addca8f942afd6d6c4ad6be17211a22e7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 11:49:16 +0000 Subject: [PATCH 35/84] fix(identity): total is not the same as meaningful `evidence_kind_observation` was checked for classifying every evidence kind and never for classifying them into anything. `same_pattern_id: "sometimes"` is a string, so it survived the filter, counted toward the key set, and satisfied totality - and its VALUE was validated nowhere, because that kind is reached only through a partner profile and is therefore deliberately outside the junior map's domain, which is where the only value check lived. Found while verifying a reviewer's claim rather than from a report. The review pass on this section was clean and correct as far as it went: it tested `null`, an int, a list and an object, all of which fail closed, and concluded the filter does not fail open here. It does not - for non-strings. A string that means nothing was the case neither of us tested until the fix was already written. So the vocabulary is closed where the section lives, not where one consumer happens to read it: every value must be `pair_property` or `revision_record`. The per-kind check downstream now leans on that instead of repeating a weaker version of it. This is the same family the last four commits have been - present-and-total standing in for meaningful, one notch over from valid-per-entry standing in for total, which was itself one notch over from the filter that dropped what it could not classify. Five probes bite, two of them earlier ones re-run as regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 121747c..ee8a6dc 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -943,6 +943,19 @@ def arbitration_leaves(node, trail): f"unexpected {sorted(set(observation) - set(senior['evidence_kinds']))}. A " "kind left unclassified is one a partner profile would have to guess about, " "which is the fact this section exists to settle.") + # ...and the VALUES, here rather than only where the junior map consumes them. + # Totality alone accepted `same_pattern_id: "sometimes"`: a string, so it + # survived the filter and counted toward the key set, and its value was + # checked nowhere because that kind is reached only through a partner profile + # and so is deliberately outside the map's domain. Present-and-total is not + # the same as meaningful, which is this family of defect one notch over. + OBSERVATION_CLASSES = ("pair_property", "revision_record") + for kind, how in sorted(observation.items()): + check(how in OBSERVATION_CLASSES, + f"`evidence_kind_observation[{kind!r}]` is {how!r}, which is not one of " + f"{list(OBSERVATION_CLASSES)}. An unrecognised class says a kind was " + "classified without saying how it is observed, and every consumer must " + "then guess exactly what this section exists to settle.") NO_RECORD = "no_structural_record" direct = {k for r in rules.values() for k in (r.get("requires_all") or [])} @@ -962,11 +975,7 @@ def arbitration_leaves(node, trail): # `path_rename: no_structural_record` unbound the rename rule and stayed # green: an explicit "nothing observes this" is only honest for kinds # `finding-lineage/v1` classifies as pair properties. - observed_as = (senior.get("evidence_kind_observation") or {}).get(kind) - check(observed_as in ("pair_property", "revision_record"), - f"`finding-lineage/v1` does not classify {kind!r} as observed from a " - "pair or from a record, so nothing here can say whether a record is " - "owed for it") + observed_as = observation.get(kind) check((sig_name == NO_RECORD) == (observed_as == "pair_property"), f"`evidence_kind_records.map[{kind!r}]` is {sig_name!r} while the senior " f"contract observes that kind as a {observed_as!r}. A " From 8c583110eba14b22fb2349de7f5904120ad4a24b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:10:21 +0000 Subject: [PATCH 36/84] fix(identity): a partner profile must face the repeated side `partner_profile.per` was checked for being one of two vocabulary words and never for being the RIGHT one, so a 1:N rule could profile the predecessor - the side it has exactly one of - and license a branch whose successors showed none of the required evidence. The senior contract defines `branched` as several EQUALLY SUPPORTED successors, and a profile aimed at the singleton supports none of them. The symmetric swap on N:1 was green too. The repeated side is now read off `cardinality` rather than trusted: `1:N` means one predecessor and N successors, and there is nothing to choose. That it is DERIVED here while the quantifier beside it is DECLARED is deliberate, and recorded in `partner_profile_rule`. How many partners a record must name is a policy choice - copy explains one, fold names every - and a checker choosing it would hold an opinion nobody reviewed. Which side is repeated is not a choice. Deriving a fact and declaring a decision are different acts. Three related mutations were probed and were already bound: the exclusion token moved to the singleton side, the exclusion added to the fold, and a raised `min_successors`. This is the one that was open. Same family as the last five, one notch further along it: a filter dropping what it could not classify, then valid-per-entry standing in for total, then present-and-total standing in for meaningful, and now a valid vocabulary word standing in for the right one. Three probes bite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 18 +++++++++++++++++- identity/tests/test_lineage_decision.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index add03b7..6b30232 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -752,7 +752,23 @@ "transformation that moved the file, the symbol, or both. The integrity suite", "checks the profile clears the senior floor and that no kind in it is", "`sufficient_alone` - it does NOT derive the profile, because a derived profile", - "would be the cardinal number again with extra steps." + "would be the cardinal number again with extra steps.", + "", + "WHICH SIDE, AND WHY IT IS DERIVED RATHER THAN DECLARED.", + "", + "`per` names the REPEATED side, and the suite reads it off `cardinality` instead", + "of trusting the declaration: `per` accepted either value for either shape, so a", + "1:N rule could profile the predecessor - the side it has exactly one of - and", + "license a branch whose successors showed none of the required evidence. The", + "senior contract defines `branched` as several EQUALLY SUPPORTED successors, and", + "a profile aimed at the singleton supports none of them.", + "", + "This is derived where the QUANTIFIER is declared, and the difference is the", + "point. How many partners a record must name is a policy choice - copy explains", + "one, fold names every - and a checker choosing it would hold an opinion nobody", + "reviewed. Which side is repeated is not a choice at all: `1:N` means one", + "predecessor and N successors. Deriving a fact and declaring a decision are", + "different acts, and this file should keep doing both rather than picking one." ], "cardinality_rule": [ "Cardinality is a machine-readable PRECONDITION of the rule, not something", diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index ee8a6dc..ea17904 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -698,6 +698,26 @@ def arbitration_leaves(node, trail): if prof is not None: check(isinstance(prof, dict) and prof.get("per") in ("successor", "predecessor"), f"{rid}: partner_profile must say which side it is `per`") + # ...and it must be the REPEATED side. `per` accepted either value for + # either shape, so a 1:N rule could profile the predecessor - the one + # occurrence there is exactly one of - and license a branch whose + # successors show none of the required evidence. The senior contract + # defines `branched` as several EQUALLY SUPPORTED successors, and a + # profile aimed at the singleton side supports none of them. + # + # The repeated side is read off the shape, not chosen: `1:N` means one + # predecessor and N successors. There is no policy here to hold an + # opinion about, unlike the quantifier, which is why this one is + # derived where that one is declared. + repeated = {"1:N": "successor", "N:1": "predecessor"}.get( + (rules[rid].get("cardinality") or {}).get("shape")) + if repeated: + check(prof.get("per") == repeated, + f"{rid} is {(rules[rid].get('cardinality') or {}).get('shape')!r}, so " + f"the group is its {repeated}s, but `partner_profile.per` is " + f"{prof.get('per')!r} - the side there is exactly one of. A profile " + "checked against the singleton says nothing about the partners the " + "outcome rests on.") req = prof.get("requires_all") if isinstance(prof, dict) else None check(isinstance(req, list) and req, f"{rid}: partner_profile must NAME the kinds; the contract refuses to " From 29c0eab22eccff9e73821f68bdaf16e8fcf6a02a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:13:18 +0000 Subject: [PATCH 37/84] fix(identity): declared where the shape needs it, and nowhere else Every rule-level structure keyed on `cardinality.shape` was required where the shape needs it and forbidden nowhere. Four such fields; one was reported and three were siblings. - `partner_profile` on a 1:1 rule was accepted. It names the REPEATED side, of which a 1:1 rule has none, and `rule_needs()` folds its kinds into the rule's requirements - so the declaration silently added requirements to a non-group rule that no mapper has semantics for. - `min_successors` on a 1:1 or N:1 rule, and `min_predecessors` on a 1:1 or 1:N rule, were all accepted. Not inert: the `cardinality_excluded_a_rule` obligation reads exactly these fields to decide whether a rule is GENUINELY unable to fit a shape, so a spurious minimum makes that obligation satisfiable for a rule nothing excluded. All four are now biconditional, which is how the neighbouring `record_binding` check was already written - four lines away from the `partner_profile` one that was not. Writing the same condition twice and getting the strength different each time is the recurring failure this file keeps producing; the two now read alike on purpose. Nine probes bite, four of them the omission direction and two earlier ones re-run as regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 30 +++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index ea17904..bf77fd7 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -688,13 +688,39 @@ def arbitration_leaves(node, trail): check(card.get("min_predecessors", 0) >= 2, f"{rid} licenses merged without requiring two predecessors") + # IF AND ONLY IF, for the minima too. Each was required where the + # shape needs it and forbidden nowhere, so a 1:1 rule could carry + # `min_successors` and a 1:N rule `min_predecessors`. Not inert: the + # `cardinality_excluded_a_rule` obligation reads exactly these fields + # to decide whether a rule is GENUINELY unable to fit a shape, so a + # spurious minimum makes that obligation satisfiable for a rule + # nothing excluded. + for field, needed_by in (("min_successors", "1:N"), + ("min_predecessors", "N:1")): + check((field in card) == (shape == needed_by), + f"{rid} is {shape!r} and {'declares' if field in card else 'omits'} " + f"`{field}`, which belongs to {needed_by!r} and only there. A " + "minimum on the side a shape has exactly one of describes nothing, " + "and the cardinality-exclusion obligation still reads it.") + # A rule whose cardinality is not 1:1 says something about a GROUP, so it # must also say what each partner shows on its own. `a rule of outcome # continued` used to stand here and was unsatisfiable on the very frozen # cases these rules exist for - see `rules_note`. prof = rules[rid].get("partner_profile") - if isinstance(card, dict) and card.get("shape") != "1:1": - check(isinstance(prof, dict), f"{rid} is a group rule with no `partner_profile`") + if isinstance(card, dict): + # IF AND ONLY IF. This required a profile of group rules and forbade + # one nowhere, so a 1:1 rule could declare `partner_profile` - which + # names the REPEATED side, of which it has none - and `rule_needs` + # would silently fold those kinds into its requirements. The sibling + # `record_binding` check was written biconditional; this one was not, + # and the two sat four lines apart. + check(isinstance(prof, dict) == (card.get("shape") != "1:1"), + f"{rid} is {card.get('shape')!r} and " + f"{'declares' if prof is not None else 'omits'} a `partner_profile`. " + "A profile says what each partner in a GROUP shows on its own; a 1:1 " + "rule has no group, and a group rule that omits one licenses partners " + "nothing was asked of.") if prof is not None: check(isinstance(prof, dict) and prof.get("per") in ("successor", "predecessor"), f"{rid}: partner_profile must say which side it is `per`") From f0b5381daabe381ff5dcb0bbce38137fe2877e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:16:01 +0000 Subject: [PATCH 38/84] fix(identity): a demand for a record must name the record `evidence_kind_observation` accepted either class token for any kind, checking only vocabulary membership. Reclassifying `same_pattern_id` - reached solely through `partner_profile`, and so deliberately outside the kind-to-record map's domain - as `revision_record` left the frozen contract demanding a revision record while naming nowhere to read it, with the suite green. Both group rules rest on that kind, so legitimate branch and merge evidence would be refused or resolved ad hoc. No new declaration closes this, because the existing ones already contradict each other: a `revision_record` classification IS a demand for a record, so the contract has to say which. Requiring it to be named makes the classification unsatisfiable for a kind the map cannot carry, which is the right answer rather than a second opinion about which kinds those are. This is the notch after the last one, and it was named before it was found: a valid vocabulary word standing in for the right one, now one layer up - the token was correct for the vocabulary and wrong for the kind it was attached to. Five probes bite. All five first reported RED against a baseline that was already broken - the check referenced the `no_structural_record` sentinel above its definition, so every run died on an UnboundLocalError before any check ran. Each of those REDs was a lie, and only re-running them against a green baseline made them evidence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index bf77fd7..e2b1dc2 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -980,6 +980,7 @@ def arbitration_leaves(node, trail): # observation left it unclassified with everything green. That is the same # valid-but-not-total defect this map was just fixed for, one layer up, in the # section added to fix it. + NO_RECORD = "no_structural_record" observation = {k: v for k, v in (senior.get("evidence_kind_observation") or {}).items() if isinstance(v, str)} @@ -1002,8 +1003,27 @@ def arbitration_leaves(node, trail): f"{list(OBSERVATION_CLASSES)}. An unrecognised class says a kind was " "classified without saying how it is observed, and every consumer must " "then guess exactly what this section exists to settle.") + # A `revision_record` classification is a DEMAND for a record, so the + # contract has to say which one. It said only that the token was in the + # vocabulary: reclassifying `same_pattern_id` - reached solely through + # `partner_profile`, and so deliberately outside the kind-to-record map's + # domain - left the frozen contract demanding a revision record while + # naming nowhere to read it, with the suite green. Both group rules rest + # on that kind, so legitimate branch and merge evidence would be refused + # or resolved ad hoc. + # + # No new declaration closes this; the existing ones already contradict + # each other. Requiring the record to be NAMED makes the classification + # unsatisfiable for a kind the map cannot carry, which is the right + # answer rather than a second opinion about which kinds those are. + if how == "revision_record": + check(kind_records.get(kind) not in (None, NO_RECORD), + f"`evidence_kind_observation[{kind!r}]` says a revision record " + "observes it, and `evidence_kind_records.map` names no record for it " + f"(got {kind_records.get(kind)!r}). A kind reached only through a " + "`partner_profile` is outside that map by design, so classifying one " + "this way demands evidence the contract gives no way to read.") - NO_RECORD = "no_structural_record" direct = {k for r in rules.values() for k in (r.get("requires_all") or [])} want_domain = {k for k in direct if k in senior_kinds} check(set(kind_records) == want_domain, From 6be9d9ce8e08bbd44b98be1b884d2bc82cce8221 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:18:43 +0000 Subject: [PATCH 39/84] fix(identity): the twins are one function now `record_binding: {}` on a 1:1 rule was accepted, because that check tested `bool(v)` while its twin tested `isinstance(v, dict)`. Two siblings found while confirming it: `record_binding: null` and `partner_profile: null` were accepted too - `isinstance(None, dict)` is False, so the null declaration read as an absence to one check and the empty object read as an absence to the other. An empty object and a null are DECLARATIONS whose value is empty, and a mapper reading the contract finds a second way to spell "no binding". The provenance is the finding. The previous commit fixed exactly this defect - two checks expressing one relation at different strengths - and its fix reintroduced it: both were made biconditional, both were written "to read alike", and they still disagreed, because reading alike is not being alike. One tested type, the other truthiness, and neither tested what the claim is actually about. So the claim is asked once, of a key: `partner_profile` and `record_binding` are declared if and only if the rule has a group, and DECLARED means the key is present whatever the value. Group rules additionally need a populated object. Both fields now go through one function, which is the only version of "make them consistent" that a later edit cannot undo by halves. Twenty probes bite - five value shapes per field per side, plus the omission direction and two earlier regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 52 ++++++++++++++++++------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index e2b1dc2..c87b70f 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -335,6 +335,39 @@ def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: return field, entries, pairs +def group_only_field_failures(rid: str, rule: dict, field: str) -> list: + """One predicate for every field a rule may declare only when it has a GROUP. + + `partner_profile` and `record_binding` are the same claim about two fields, + and they were written twice. The first time they diverged one was + biconditional and the other was not; the second time both were biconditional + and they still disagreed, because one tested `isinstance(v, dict)` and the + other `bool(v)` - so `{}` was rejected by one and accepted by the other, and + `null` by neither. Reading alike was not enough. They are one function now. + + PRESENCE, not truthiness and not type. Whether a field is DECLARED is a + question about the key; `{}` and `null` are declarations whose value happens + to be empty, and a mapper reading the contract sees a second way of spelling + "no binding".""" + out = [] + shape = (rule.get("cardinality") or {}).get("shape") + is_group = shape != "1:1" + declared = field in rule + if declared != is_group: + out.append( + f"{rid} is {shape!r} and {'declares' if declared else 'omits'} `{field}`, " + f"which belongs to group rules and only to them. A 1:1 rule has no group " + f"to describe, and a group rule without one leaves its partners unasked. " + f"The KEY is the declaration - `{{}}` and `null` are declarations too.") + elif declared and is_group: + value = rule.get(field) + if not isinstance(value, dict) or not value: + out.append(f"{rid} declares `{field}` as {value!r}. A group rule needs a " + "populated object there; an empty one is a second way of " + "spelling nothing.") + return out + + def raw_dominance_edges(policy: dict) -> list: """Occurrences, NOT a set. `classified exactly once` is a claim about the declarations, and a set answers a weaker question - it would let @@ -708,6 +741,8 @@ def arbitration_leaves(node, trail): # continued` used to stand here and was unsatisfiable on the very frozen # cases these rules exist for - see `rules_note`. prof = rules[rid].get("partner_profile") + for msg in group_only_field_failures(rid, rules[rid], "partner_profile"): + check(False, msg) if isinstance(card, dict): # IF AND ONLY IF. This required a profile of group rules and forbade # one nowhere, so a 1:1 rule could declare `partner_profile` - which @@ -715,12 +750,7 @@ def arbitration_leaves(node, trail): # would silently fold those kinds into its requirements. The sibling # `record_binding` check was written biconditional; this one was not, # and the two sat four lines apart. - check(isinstance(prof, dict) == (card.get("shape") != "1:1"), - f"{rid} is {card.get('shape')!r} and " - f"{'declares' if prof is not None else 'omits'} a `partner_profile`. " - "A profile says what each partner in a GROUP shows on its own; a 1:1 " - "rule has no group, and a group rule that omits one licenses partners " - "nothing was asked of.") + pass if prof is not None: check(isinstance(prof, dict) and prof.get("per") in ("successor", "predecessor"), f"{rid}: partner_profile must say which side it is `per`") @@ -1073,13 +1103,9 @@ def arbitration_leaves(node, trail): "and `record_binding` replaced it; keeping both is two authorities for " "one fact, which is how this contract has gone wrong before.") binding = rule_.get("record_binding") - is_group = (rule_.get("cardinality") or {}).get("shape") != "1:1" - check(bool(binding) == is_group, - f"{rid}: `record_binding` is declared for group rules and only for them; " - f"shape is {(rule_.get('cardinality') or {}).get('shape')!r} and the " - f"binding is {'present' if binding else 'absent'}. A 1:1 rule with one " - "points a mapper at a partner set that does not exist.") - if not binding: + for msg in group_only_field_failures(rid, rule_, "record_binding"): + check(False, msg) + if not isinstance(binding, dict) or not binding: continue structural = [k for k in rule_.get("requires_all") or [] if k in policy["structural_signals"]] From edc8191a27a200ac462c60304206f067c8b5deec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:22:38 +0000 Subject: [PATCH 40/84] fix(identity): the other pair of twins Found by hunting the shape the last round ended on, rather than from a report: `requires_all` and `partner_profile.requires_all` are the same claim - a list of evidence a rule rests on - and were validated twice, by hand, at different strengths. The profile rejected an unknown kind and a `sufficient_alone` one; the rule's own list rejected neither. A comment above the rule's list recorded noticing one half of this and fixing only the half it named: "`partner_profile` already got this right; `requires_all` did not." That was written about the repeats check. The two other asymmetries were three lines away and went unmentioned. One function now validates both, which is what the previous commit established as the only durable form of "make them consistent". The `sufficient_alone` exclusion is thereby EXTENDED from profiles to rule requirements. Flagging that rather than burying it: it is a real widening of the check, chosen because it is fail-closed - it can reject a contract, never license a mapping - and because the argument already written for profiles generalises, this file existing to say which COMBINATIONS license what. No frozen kind is `sufficient_alone` today, so nothing in the corpus turns on it; a future contract wanting one will be told to decide it explicitly rather than have it pass unseen. Ten probes bite, six of them earlier checks re-run through the shared function to confirm neither side lost anything. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 76 +++++++++++++++++-------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index c87b70f..728788a 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -335,6 +335,48 @@ def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: return field, entries, pairs +def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, + floor: int, allow_records: bool, need_floor: bool) -> list: + """One validation for every list of evidence a rule rests on. + + `requires_all` and `partner_profile.requires_all` are the same kind of claim + and were checked twice, by hand, at different strengths - the profile + rejected an unknown kind and a `sufficient_alone` one, the rule's own list + rejected neither. A comment above the rule's list even recorded noticing one + half of that gap ("`partner_profile` already got this right") and fixed only + the half it named. + + The `sufficient_alone` exclusion is applied to BOTH here, which extends it + from profiles to rule requirements. That is deliberate and fail-closed: it can + reject a contract, never license a mapping. The argument is the one already + written for profiles - this file exists to say which COMBINATIONS license + what, and a kind the senior contract calls sufficient on its own needs no + combination. No frozen kind is currently `sufficient_alone`, so nothing in the + corpus turns on it today.""" + out = [] + if not isinstance(kinds, list) or not kinds: + out.append(f"{where} must NAME the kinds it rests on, got {kinds!r}") + return out + for kind in kinds: + known = kind in senior["evidence_kinds"] or (allow_records and kind in catalog) + if not known: + out.append(f"{where} names {kind!r}, which is neither a frozen evidence " + "kind nor a structural signal") + if senior["evidence_kinds"].get(kind, {}).get("sufficient_alone"): + out.append(f"{where} rests on {kind!r}, which `finding-lineage/v1` calls " + "sufficient alone. A combination built around a kind that needs " + "no combination is not a combination.") + if len(set(kinds)) != len(kinds): + rep = sorted({k for k in kinds if kinds.count(k) > 1}) + out.append(f"{where} repeats {rep!r}. The floor counts KINDS, so a repeat is " + "either a typo or an attempt to reach the floor twice over the same " + "evidence.") + if need_floor and len(set(kinds)) < floor: + out.append(f"{where} names {len(set(kinds))} distinct kind(s); the frozen floor " + f"is {floor}") + return out + + def group_only_field_failures(rid: str, rule: dict, field: str) -> list: """One predicate for every field a rule may declare only when it has a GROUP. @@ -690,14 +732,10 @@ def arbitration_leaves(node, trail): # in the same file whose whole argument is that the floor is a floor. # `partner_profile` already got this right; `requires_all` did not. req_all = rules[rid].get("requires_all", []) - check(len(set(req_all)) == len(req_all), - f"{rid}: requires_all repeats a kind. The floor counts kinds, so a " - "repeat is either a typo or an attempt to reach the floor twice over " - "the same evidence.") - if outcomes[rid] == "continued": - check(len(set(req_all)) >= floor, - f"{rid} licenses continued on {len(set(req_all))} distinct " - f"kind(s); the frozen floor is {floor}") + for msg in evidence_list_failures( + f"{rid}: requires_all", req_all, senior, policy["structural_signals"], + floor, allow_records=True, need_floor=outcomes[rid] == "continued"): + check(False, msg) # Cardinality is a PRECONDITION of the rule, so it is checked as one. The # alternative - letting `arbitration.multiplicity` overturn a structural @@ -775,21 +813,13 @@ def arbitration_leaves(node, trail): "checked against the singleton says nothing about the partners the " "outcome rests on.") req = prof.get("requires_all") if isinstance(prof, dict) else None - check(isinstance(req, list) and req, - f"{rid}: partner_profile must NAME the kinds; the contract refuses to " - "derive them, because a derived profile is the floor's cardinal number " - "with extra steps") - if isinstance(req, list): - for kind in req: - check(kind in senior["evidence_kinds"], - f"{rid}: partner_profile names {kind!r}, not a frozen evidence kind") - check(not senior["evidence_kinds"].get(kind, {}).get("sufficient_alone"), - f"{rid}: partner_profile rests on {kind!r}, which is sufficient " - "alone; a profile of one strong signal is not a profile") - check(len(set(req)) == len(req), f"{rid}: partner_profile repeats a kind") - check(len(set(req)) >= floor, - f"{rid}: partner_profile names {len(set(req))} kind(s); the frozen " - f"floor is {floor}. The floor is checked, never used to generate.") + # A profile names SENIOR kinds only: a structural record is what the + # group rule itself rests on, not what each partner shows alone. + for msg in evidence_list_failures( + f"{rid}: partner_profile", req, senior, + policy["structural_signals"], floor, + allow_records=False, need_floor=True): + check(False, msg) # NO RULE MAY REQUIRE AN IMPOSSIBLE COMBINATION. The senior contract # freezes which kinds cannot co-occur; a rule demanding both is dead and # takes its outcome down quietly with it. This is one of the two things From 20ba7903add04a957558a979150f904b4e2faefb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:24:58 +0000 Subject: [PATCH 41/84] fix(identity): the same claim one level in A group rule could keep both `record_binding` role keys and set either to `null`, `[]` or `0`; the role loop skipped them with `if not isinstance(v, dict): continue`, so the binding froze with no `every`/`at_least_one` semantics for a mapper to read. Three of five empty spellings passed. This is the defect fixed two commits ago, one level in, in the SAME field. That commit unified the OUTER question - is `record_binding` declared, and if so is it a populated object - into one function, and left the nested role values to a hand-written type test that let three shapes through. Fixing a claim at one level did not prompt me to ask it at the next, and a reviewer found the inner half the round after the outer half landed. So "declared, and not empty" is now one function that both levels call, rather than one function plus a hand-written variant of it. Every accepted-empty case this branch has produced - `{}` from truthiness, `null` from `isinstance`, `[]` and `0` from either - is one predicate away from the others, and the only fix that holds is not writing the predicate twice. Ten probes bite, both roles and each role independently, across all five value shapes, plus two earlier regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 30 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 728788a..e6d6bbe 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -377,6 +377,23 @@ def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, return out +def populated_object_failure(where: str, value) -> str: + """"Declared, and not empty" - asked once, wherever it is asked. + + The outer field check learned this the hard way: `{}` and `null` are + declarations whose value is empty, and every hand-written variant of the test + let a different one through. The nested ROLE values under `record_binding` + were then left to `if not isinstance(v, dict): continue`, so `null`, `[]` and + `0` skipped quantifier validation in silence - the same defect one level in, + in the same field, found by a reviewer the round after the outer half was + fixed. Both levels call this now.""" + if not isinstance(value, dict) or not value: + return (f"{where} is {value!r}. It must be a populated object; `null`, `[]`, " + "`0` and `{}` are all ways of declaring nothing, and each has been " + "accepted by some hand-written version of this check.") + return "" + + def group_only_field_failures(rid: str, rule: dict, field: str) -> list: """One predicate for every field a rule may declare only when it has a GROUP. @@ -402,11 +419,9 @@ def group_only_field_failures(rid: str, rule: dict, field: str) -> list: f"to describe, and a group rule without one leaves its partners unasked. " f"The KEY is the declaration - `{{}}` and `null` are declarations too.") elif declared and is_group: - value = rule.get(field) - if not isinstance(value, dict) or not value: - out.append(f"{rid} declares `{field}` as {value!r}. A group rule needs a " - "populated object there; an empty one is a second way of " - "spelling nothing.") + bad = populated_object_failure(f"{rid}.{field}", rule.get(field)) + if bad: + out.append(bad) return out @@ -1151,7 +1166,10 @@ def arbitration_leaves(node, trail): "second opinion about which side the group is on.") for role in ("predecessor", "successor"): spec_r = binding.get(role) - if not isinstance(spec_r, dict): + if role in binding: + bad = populated_object_failure(f"{rid}.record_binding.{role}", spec_r) + check(not bad, bad or "") + if not isinstance(spec_r, dict) or not spec_r: continue check(spec_r.get("quantifier") in vocab["quantifier"], f"{rid}.record_binding.{role} uses quantifier " From 1dc7d8ec8d87cbf3dce8a68270720bd0465d84ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:27:33 +0000 Subject: [PATCH 42/84] fix(identity): revert a widening both reviewers refused Two commits ago I unified the two evidence-list validations and, in doing so, extended the `sufficient_alone` exclusion from partner profiles to rule requirements. I flagged it and asked to be challenged. Both reviewers challenged it independently, and both were right. The senior contract says, in `evidence_rule`, in terms: "If a kind is ever promoted to `sufficient_alone: true`, that kind alone satisfies the floor and the count stops applying to it." My check forbade such a kind from `requires_all` outright, and the raw-count floor then rejected a singleton rule as well - so the decision policy could not express an evolution its senior contract explicitly provides for. A junior contract contradicting its senior FROM BELOW is the one thing this layering forbids, and I introduced it while arguing that unification made things safer. - the exclusion is a parameter now: kept for `partner_profile`, which rests on a different claim already argued in `partner_profile_rule` - a profile describes what each repeated partner shows on its own, and one strong signal is not a profile - and dropped for `requires_all`, where it never had an argument. - the floor honours the senior sentence: a list carrying a `sufficient_alone` kind clears it regardless of count. "Fail-closed" was my justification and it was not a reason. A check that can only reject still decides what the contract may say; rejecting a shape the senior contract promises is not conservative, it is wrong in the direction that looks careful. Nine probes: three confirming the promoted-kind evolution is now representable, two that the floor still binds without it, one that the profile keeps its exclusion, three regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 52 ++++++++++++++++++------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index e6d6bbe..6d39979 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -336,7 +336,8 @@ def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, - floor: int, allow_records: bool, need_floor: bool) -> list: + floor: int, allow_records: bool, need_floor: bool, + forbid_sufficient_alone: bool) -> list: """One validation for every list of evidence a rule rests on. `requires_all` and `partner_profile.requires_all` are the same kind of claim @@ -346,13 +347,25 @@ def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, half of that gap ("`partner_profile` already got this right") and fixed only the half it named. - The `sufficient_alone` exclusion is applied to BOTH here, which extends it - from profiles to rule requirements. That is deliberate and fail-closed: it can - reject a contract, never license a mapping. The argument is the one already - written for profiles - this file exists to say which COMBINATIONS license - what, and a kind the senior contract calls sufficient on its own needs no - combination. No frozen kind is currently `sufficient_alone`, so nothing in the - corpus turns on it today.""" + The `sufficient_alone` exclusion is a PARAMETER, not a shared rule, and the + reason is worth keeping. Unifying these two lists, an earlier revision applied + the profile's exclusion to rule requirements as well - reasoning that it was + fail-closed and that the argument generalised. Both reviewers rejected it, and + both were right: `evidence_rule` in the senior contract says in terms that "if + a kind is ever promoted to `sufficient_alone: true`, that kind alone satisfies + the floor and the count stops applying to it". Forbidding such a kind from + `requires_all` made the decision policy unable to express an evolution the + senior contract explicitly provides for - a junior contract contradicting its + senior from below, which is the one thing this layering forbids. + + The profile keeps the exclusion because it rests on a different claim, already + argued in `partner_profile_rule`: a profile describes what each repeated + partner shows ON ITS OWN, and one strong signal is not a profile. That does + not generalise to a rule-level combination, and "fail-closed" was not a reason + to assume it did. + + Sharing structure is not sharing every rule. The point of one function is that + the common checks cannot drift apart, not that the differences vanish.""" out = [] if not isinstance(kinds, list) or not kinds: out.append(f"{where} must NAME the kinds it rests on, got {kinds!r}") @@ -362,18 +375,25 @@ def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, if not known: out.append(f"{where} names {kind!r}, which is neither a frozen evidence " "kind nor a structural signal") - if senior["evidence_kinds"].get(kind, {}).get("sufficient_alone"): + if (forbid_sufficient_alone + and senior["evidence_kinds"].get(kind, {}).get("sufficient_alone")): out.append(f"{where} rests on {kind!r}, which `finding-lineage/v1` calls " - "sufficient alone. A combination built around a kind that needs " - "no combination is not a combination.") + "sufficient alone; a profile of one strong signal is not a " + "profile") if len(set(kinds)) != len(kinds): rep = sorted({k for k in kinds if kinds.count(k) > 1}) out.append(f"{where} repeats {rep!r}. The floor counts KINDS, so a repeat is " "either a typo or an attempt to reach the floor twice over the same " "evidence.") - if need_floor and len(set(kinds)) < floor: + # THE FLOOR STOPS APPLYING to a kind promoted to `sufficient_alone`, which is + # the senior contract's own sentence and not a reading of it. A count-only + # floor rejected a rule resting on one such kind, so the policy could not + # represent the evolution `evidence_rule` provides for. + carries_alone = any(senior["evidence_kinds"].get(k, {}).get("sufficient_alone") + for k in kinds) + if need_floor and not carries_alone and len(set(kinds)) < floor: out.append(f"{where} names {len(set(kinds))} distinct kind(s); the frozen floor " - f"is {floor}") + f"is {floor}, and none of them is `sufficient_alone`") return out @@ -749,7 +769,8 @@ def arbitration_leaves(node, trail): req_all = rules[rid].get("requires_all", []) for msg in evidence_list_failures( f"{rid}: requires_all", req_all, senior, policy["structural_signals"], - floor, allow_records=True, need_floor=outcomes[rid] == "continued"): + floor, allow_records=True, need_floor=outcomes[rid] == "continued", + forbid_sufficient_alone=False): check(False, msg) # Cardinality is a PRECONDITION of the rule, so it is checked as one. The @@ -833,7 +854,8 @@ def arbitration_leaves(node, trail): for msg in evidence_list_failures( f"{rid}: partner_profile", req, senior, policy["structural_signals"], floor, - allow_records=False, need_floor=True): + allow_records=False, need_floor=True, + forbid_sufficient_alone=True): check(False, msg) # NO RULE MAY REQUIRE AN IMPOSSIBLE COMBINATION. The senior contract # freezes which kinds cannot co-occur; a rule demanding both is dead and From efbbb426c4556be18c241dfef87167448742792b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:31:14 +0000 Subject: [PATCH 43/84] fix(identity): the floor is one question, asked in four places The previous commit taught the rule validator the senior contract's sentence - that a kind promoted to `sufficient_alone: true` clears the floor by itself and the count stops applying - and taught it to exactly one of the four places that ask. `mandated_reason` and the two arithmetic checks behind a defeat's reason kept counting distinct kinds. The result was a contradiction inside one run: a fixture whose defeat leaves a single promoted kind is above the floor to the rule checks and below it to the reason checks, so the policy would mandate `insufficient-evidence-kind` for evidence the senior contract calls sufficient. That is the same shape as the last three rounds - a claim fixed at one site and not asked at the adjacent one - occurring inside the commit that was itself correcting a contradiction with this same sentence. Writing the fix did not prompt me to ask where else the question is asked, even with that failure named in my own commit message one commit earlier. `clears_floor()` is now the only place the question is answered, and all four sites call it. Three probes: a promoted kind whose fixture still claims `-kind` now fails; a single unpromoted kind claiming `-combination` still fails; three surviving kinds claiming `-kind` still fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 59 +++++++++++++++++-------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 6d39979..c4adce5 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -195,7 +195,7 @@ def rule_needs(rule) -> set: return needs | set(profile.get("requires_all") or []) -def mandated_reason(exp, mapping, floor): +def mandated_reason(exp, mapping, floor, senior): """The reason the policy REQUIRES for this refusal, or None if the contract has not settled the shape. @@ -218,8 +218,9 @@ def of(key, surviving_kinds=None): by = spec.get("reason_by_surviving_kinds") or {} if surviving_kinds is None: return None - return by.get("below_the_floor" if surviving_kinds < floor - else "at_or_above_the_floor") + return by.get("at_or_above_the_floor" + if clears_floor(surviving_kinds, senior, floor) + else "below_the_floor") app = exp.get("applicable_rules") or [] defeated = exp.get("signals_defeated") or {} @@ -238,9 +239,9 @@ def of(key, surviving_kinds=None): if sum(stages) > 1: return None, "several rejecting stages fired; the contract has not ranked them" if defeated: - n = len({k for k in (exp.get("evidence_surviving") or [])}) - return (of("no_rule_applied_after_a_defeat", n), - f"a defeat left {n} kind(s) against a floor of {floor}") + surv = {k for k in (exp.get("evidence_surviving") or [])} + return (of("no_rule_applied_after_a_defeat", surv), + f"a defeat left {len(surv)} kind(s) against a floor of {floor}") if blunted: return of("several_candidates"), "rules matched and singled nobody out" if unavailable: @@ -335,6 +336,28 @@ def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: return field, entries, pairs +def clears_floor(kinds, senior: dict, floor: int) -> bool: + """Does this evidence clear `minimum_evidence_kinds_for_continued`? + + Asked in four places - a rule's `requires_all`, a partner profile, the reason + a defeat mandates, and the arithmetic behind that reason - and until now + written four times. Three of them counted distinct kinds and stopped there, + which is right only while every kind is `sufficient_alone: false`. + `evidence_rule` in the senior contract says the rest: "if a kind is ever + promoted to `sufficient_alone: true`, that kind alone satisfies the floor and + the count stops applying to it." + + The fix for that sentence landed at ONE of the four sites. A fixture whose + defeat left a single promoted kind was then classified below the floor by the + reason checks and above it by the rule checks, in one run. Fixed at one level + and not asked at the next, in the commit that was itself correcting a + contradiction with this same sentence.""" + kinds = set(kinds or ()) + if any(senior["evidence_kinds"].get(k, {}).get("sufficient_alone") for k in kinds): + return True + return len(kinds) >= floor + + def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, floor: int, allow_records: bool, need_floor: bool, forbid_sufficient_alone: bool) -> list: @@ -389,9 +412,7 @@ def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, # the senior contract's own sentence and not a reading of it. A count-only # floor rejected a rule resting on one such kind, so the policy could not # represent the evolution `evidence_rule` provides for. - carries_alone = any(senior["evidence_kinds"].get(k, {}).get("sufficient_alone") - for k in kinds) - if need_floor and not carries_alone and len(set(kinds)) < floor: + if need_floor and not clears_floor(kinds, senior, floor): out.append(f"{where} names {len(set(kinds))} distinct kind(s); the frozen floor " f"is {floor}, and none of them is `sufficient_alone`") return out @@ -1442,7 +1463,7 @@ def arbitration_leaves(node, trail): # AND it must be the reason the POLICY BRANCH mandates. Vocabulary # membership alone accepted any of the six - `missing-occurrence-id` # passed on a case whose occurrences both carry ids. - want, why_branch = mandated_reason(exp, policy["reason_mapping"], floor) + want, why_branch = mandated_reason(exp, policy["reason_mapping"], floor, senior) if want is None: # FAIL-CLOSED on an unranked shape. Abstaining was the right call # for a CHECKER - the contract has not ranked these stages, so @@ -1644,16 +1665,18 @@ def shown(ids_): # consistent with the count it declares. That holds no matter which # stages fired, so it is safe next to a single authority - a second # mandate was not. + cleared = clears_floor(surviving, senior, floor) if reason == lim.get("insufficient-evidence-kind"): - check(len(set(surviving)) < floor, - f"{where}: claims insufficient KINDS, but {len(set(surviving))} " - f"survive against a floor of {floor}. The kinds are ample; it is " - "the combination that no rule accepts.") + check(not cleared, + f"{where}: claims insufficient KINDS, but {sorted(set(surviving))} " + f"clears the floor of {floor} - by count, or because one of them " + "is `sufficient_alone`. The kinds are ample; it is the " + "combination that no rule accepts.") if reason == lim.get("insufficient-evidence-combination"): - check(len(set(surviving)) >= floor, - f"{where}: claims insufficient COMBINATION, but only " - f"{len(set(surviving))} kind(s) survive against a floor of " - f"{floor}. Below the floor the shortage really is of kinds.") + check(cleared, + f"{where}: claims insufficient COMBINATION, but " + f"{sorted(set(surviving))} does not clear the floor of {floor}. " + "Below the floor the shortage really is of kinds.") for sig in as_list(exp.get(UNAVAILABLE_FIELD)): check(sig in senior["evidence_kinds"] or sig in catalog, From 8d36e5768ee08a3c27bfca17312843d1a0267950 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:34:15 +0000 Subject: [PATCH 44/84] test(identity): two record fields nothing asked about repeats Found by enumerating the predicates this suite evaluates and counting how many places evaluate each - the hunt the last four rounds all argued for and none of them performed. "Does this declared list repeat an entry?" was asked in eleven hand-written places, in three spellings, and TWO record fields a mapper writes were never asked at all: `evidence_surviving` and the unavailable-inputs list. Both are consumed only through `set()`, so a repeat changes no verdict here and still leaves two raw spellings of one provenance record, with anything counting the array disagreeing with this suite. That is word for word the argument review made for the `decision_detail` arrays two rounds ago. I accepted it, fixed the three fields the report named, and did not ask which other lists it applied to - so the class stayed open in exactly the way the report's own reasoning predicted. `repeats_failure()` answers it once now, over every declared list on an expectation and over the decision-detail arrays. Eight probes: the two previously unasked fields, and six regressions covering every list that already had a check, to confirm the shared predicate lost none of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 57 ++++++++++++++++--------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index c4adce5..5d9be92 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -336,6 +336,30 @@ def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: return field, entries, pairs +def repeats_failure(where: str, seq) -> str: + """"Does this declared list repeat an entry?" - asked once. + + It was asked in eleven hand-written places, in three spellings, and two + record fields a mapper writes were never asked at all: `evidence_surviving` + and the unavailable-inputs list. Both are consumed through `set()`, so a + repeat changed no verdict here while sanctioning two raw shapes for one + provenance record - the identical argument review made for the + `decision_detail` arrays, which was accepted and fixed in the three fields it + named. The class was not closed then; this closes it. + + Returns "" when the value is not a list: whether a field must BE a list is a + different question, asked where that field is read.""" + if not isinstance(seq, list): + return "" + rep = sorted({i for i in seq if seq.count(i) > 1}) + if rep: + return (f"{where} repeats {rep!r}. Every consumer normalises this through " + "`set()`, so the repeat changes no verdict and still leaves two raw " + "spellings of one record - and anything that counts the array " + "disagrees with this suite.") + return "" + + def clears_floor(kinds, senior: dict, floor: int) -> bool: """Does this evidence clear `minimum_evidence_kinds_for_continued`? @@ -1311,12 +1335,14 @@ def arbitration_leaves(node, trail): claim_a.setdefault(oid, []).append(where) for oid in to: claim_b.setdefault(oid, []).append(where) - check(len(set(frm)) == len(frm), - f"{where}: `frm` repeats {sorted({o for o in frm if frm.count(o) > 1})!r}; " - "a repeated id is one occurrence written twice, not two partners") - check(len(set(to)) == len(to), - f"{where}: `to` repeats {sorted({o for o in to if to.count(o) > 1})!r}; " - "a repeated id is one occurrence written twice, not two partners") + # EVERY declared list on this expectation, including the two that + # were never asked - `evidence_surviving` and the unavailable-inputs + # list are records a mapper writes, and both were consumed only + # through `set()`. + for _field in ("frm", "to", "applicable_rules", "licensed_by", + "evidence_surviving", UNAVAILABLE_FIELD): + bad = repeats_failure(f"{where}: `{_field}`", exp.get(_field)) + check(not bad, bad or "") claimed_a.update(frm) claimed_b.update(to) for o in frm: @@ -1332,12 +1358,10 @@ def arbitration_leaves(node, trail): continue for rid in app + lic: check(rid in rules, f"{where}: names unknown rule {rid!r}") - check(len(set(app)) == len(app), f"{where}: applicable_rules repeats an id") # `licensed_by` is a SET of surviving rules in the contract, and every # validation here normalised it through `set(lic)` - so a repeat passed # and the corpus sanctioned two raw shapes for one provenance record. # The uniqueness was checked for one field and not its twin, again. - check(len(set(lic)) == len(lic), f"{where}: licensed_by repeats an id") check(set(lic) <= set(app), f"{where}: licensed_by {sorted(set(lic) - set(app))} is not in " "applicable_rules; a rule cannot license what it never applied to") @@ -1765,19 +1789,12 @@ def shown(ids_): # its shape than the thing reading it. for field in ("conflicting_rules", "rules_without_a_unique_candidate", "rules_excluded_by_cardinality"): - raw_f = detail.get(field) - if isinstance(raw_f, list): - rep = sorted({i for i in raw_f if raw_f.count(i) > 1}) - check(not rep, - f"{where}: decision_detail.{field} repeats {rep!r}. The set " - "comparisons below cannot see it, and a consumer that counts " - "the array disagrees with this suite.") + bad = repeats_failure(f"{where}: decision_detail.{field}", + detail.get(field)) + check(not bad, bad or "") for _rid, _ids in (detail.get("ambiguous_candidates") or {}).items(): - if isinstance(_ids, list): - rep = sorted({i for i in _ids if _ids.count(i) > 1}) - check(not rep, - f"{where}: ambiguous_candidates[{_rid!r}] repeats {rep!r}; one " - "candidate written twice is not two candidates") + bad = repeats_failure(f"{where}: ambiguous_candidates[{_rid!r}]", _ids) + check(not bad, bad or "") cand = detail.get("ambiguous_candidates") blunted_ids = detail.get("rules_without_a_unique_candidate") or [] From 4ab40b32281235538ed529fa3a2576a58c86c9a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:36:21 +0000 Subject: [PATCH 45/84] test(identity): the last list the predicate did not reach Coverage half of the predicate census: for each shared predicate, is it called everywhere its question is asked? `repeats_failure` reached every declared list but one - `case_obligations.obligations[case]`, where a duty written twice is checked twice and means once. The verdict is unaffected, which is precisely why it survived the sweep that introduced the predicate one commit earlier: I looked for lists whose duplicates would change an answer, and stopped at the boundary of harm rather than the boundary of the claim. The claim is that a declared list has one spelling. Four other candidates were probed and were already bound: dominance `loses` entries, a catalog `matches` emptied, a `cardinality` emptied, a `reason_mapping` entry emptied. One probe bites. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 5d9be92..7bacda4 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -1862,6 +1862,11 @@ def shown(ids_): check(duties is not None, f"{name}: no entry in `case_obligations`; nothing says what this case " "must exhibit beyond being green") + # The last declared list `repeats_failure` did not reach. A duty written + # twice is checked twice and means once; the verdict is unaffected, which + # is exactly why it sat here after the sweep that added the predicate. + bad = repeats_failure(f"{name}: case_obligations", duties) + check(not bad, bad or "") for duty in duties or []: check(duty in obligation_meanings, f"{name}: unknown obligation {duty!r}") met = False From a9827f9536f13ed2352b2b52055324e38bffa0c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:40:36 +0000 Subject: [PATCH 46/84] fix(identity): a suite that dies reports nothing; and the fifth floor site Two findings, both from review, both confirmed by mutation. 1. THE SUITE DIED INSTEAD OF REPORTING. `repeats_failure` built a set from raw list values, so a fixture holding `[{...}, {...}]` raised `unhashable type: dict` and `[1, 1, "R-X", "R-X"]` raised on comparing str with int. The helper runs before any field-specific validator, so the whole run ended in a traceback - reporting neither the malformed field nor the twenty other things that fixture might also get wrong. Fixing the reported site was not enough and the second run proved it: a dict element still reached a set comprehension in `mandated_reason`, an int element still reached `rules[rid]` as a key. Patching each crash as it surfaced would have been this branch's recurring mistake one more time. The assumption - every declared list holds strings - is now stated once, at the entry to each expectation, and a fixture that breaks it gets a reported failure and is skipped rather than killing the run. This file already carried a comment recording that a suite dying on the evidence it came to read "has happened in this repo before". It happened again, in a helper added two commits ago. 2. THE FLOOR, AT A FIFTH SITE - and this one in the CONTRACT. The senior exception for `sufficient_alone` was taught to four checker call sites and to none of the places the contract STATES the rule: `reason_mapping` still said the floor is "counted over DISTINCT surviving kinds" and its `why` still said a defeat leaving zero or one kind is below it. A mapper following the contract would classify a promoted kind `below_the_floor` while the suite classified it `at_or_above`. The census that was supposed to have closed this enumerated predicates in the CHECKER and never looked at the contract's own prose. The floor is now machine-readable - `senior_field`, `counted_over`, `sufficient_alone_clears` - and `floor_spec_failures` binds it to the senior rule, so denying the exception, naming a bogus senior field, or reverting to prose all fail. Five probes bite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 32 +++++-- identity/tests/test_lineage_decision.py | 97 +++++++++++++++++++++- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 6b30232..473485c 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -515,7 +515,27 @@ "reason_by_surviving_kinds": { "below_the_floor": "lineage-id-unavailable:insufficient-evidence-kind", "at_or_above_the_floor": "lineage-id-unavailable:insufficient-evidence-combination", - "floor": "finding-lineage/v1 minimum_evidence_kinds_for_continued, counted over DISTINCT surviving kinds" + "floor": { + "senior_field": "minimum_evidence_kinds_for_continued", + "counted_over": "DISTINCT surviving kinds", + "sufficient_alone_clears": true, + "why": [ + "MACHINE-READABLE, and read by the suite rather than restated in it.", + "", + "This was the sentence `minimum_evidence_kinds_for_continued, counted over", + "DISTINCT surviving kinds`, and the `why` below said a defeat leaving ZERO or", + "ONE kind is below the floor. Both stopped being true the moment the senior", + "contract's own exception was honoured: a surviving kind with", + "`sufficient_alone: true` clears the floor and the count does not apply to it.", + "A mapper reading this section would have classified such a defeat", + "`below_the_floor` while the integrity suite classified it `at_or_above`.", + "", + "The exception was taught to four checker call sites and to none of the places", + "the CONTRACT states it - which is the same claim at a fifth site, found by a", + "reviewer after a census that enumerated predicates in the checker and never", + "looked here." + ] + } }, "detail": "`signals_defeated` (top level): signal name -> the record that removed it", "why": [ @@ -524,10 +544,12 @@ "observed - it was removed because a record explained it away - and saying `no", "mapping evidence` would be false in the plain meaning of the words.", "CONDITIONAL, not a single value. An earlier revision mapped every defeat to", - "`insufficient-evidence-combination` unconditionally, which is right only while", - "enough kinds survive. A formatter or rename defeat that leaves ZERO or ONE kind", - "is below the senior floor, and there the shortage really is of kinds - which is", - "the distinction the senior contract was amended to carry, contradicted here by", + "A formatter or rename defeat that leaves too few kinds is below the senior", + "floor, and there the shortage really is of kinds - which is the distinction the", + "senior contract was amended to carry. `too few` is `floor` above, exception", + "included: a single surviving kind is below it unless that kind is", + "`sufficient_alone`, in which case the senior contract says the count stops", + "applying and the shortage is of combinations, not kinds.", "the very entry that asked for it.", "In `defeated-signal-drops-below-the-floor` three kinds survive against a floor", "of two: ample kinds, and no declared combination accepts them." diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 7bacda4..00805b1 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -336,6 +336,56 @@ def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: return field, entries, pairs +def malformed_list_failures(where: str, exp: dict, unavailable_field: str) -> list: + """Every declared list on an expectation holds STRINGS - checked before + anything reads them. + + Review reported one crash site: a set built from raw list values. Fixing that + one left the suite still dying, because the assumption is everywhere - a + `{...}` element reached a set comprehension in `mandated_reason`, an integer + element reached `rules[rid]` as a key. Patching each site as it surfaced would + have been the mistake this branch keeps making; the assumption is stated once, + here, and the caller skips an expectation that fails it. + + A suite that dies on the evidence it came to read reports NOTHING - not the + malformed field, and not the twenty other things that fixture might also get + wrong. That is strictly worse than a suite that says what is wrong and + continues, which is why this is a guard rather than a repair.""" + out = [] + fields = ["frm", "to", "applicable_rules", "licensed_by", "evidence_surviving", + unavailable_field] + detail = exp.get("decision_detail") + for field in fields: + value = exp.get(field) + if value is None or isinstance(value, str): + continue + if not isinstance(value, list): + out.append(f"{where}: `{field}` is {value!r}, not a list") + continue + bad = [v for v in value if not isinstance(v, str)] + if bad: + out.append(f"{where}: `{field}` holds non-string entries {bad!r}. Every " + "consumer treats these as ids or kind names, and several put " + "them in a set or a dict key - so the suite would die here " + "rather than tell you which field is malformed.") + if isinstance(detail, dict): + for field in ("conflicting_rules", "rules_without_a_unique_candidate", + "rules_excluded_by_cardinality"): + value = detail.get(field) + if isinstance(value, list): + bad = [v for v in value if not isinstance(v, str)] + if bad: + out.append(f"{where}: decision_detail.{field} holds non-string " + f"entries {bad!r}") + for rid_, ids_ in (detail.get("ambiguous_candidates") or {}).items(): + if isinstance(ids_, list): + bad = [v for v in ids_ if not isinstance(v, str)] + if bad: + out.append(f"{where}: ambiguous_candidates[{rid_!r}] holds " + f"non-string entries {bad!r}") + return out + + def repeats_failure(where: str, seq) -> str: """"Does this declared list repeat an entry?" - asked once. @@ -351,7 +401,18 @@ def repeats_failure(where: str, seq) -> str: different question, asked where that field is read.""" if not isinstance(seq, list): return "" - rep = sorted({i for i in seq if seq.count(i) > 1}) + # BY EQUALITY, never through a set or a sort. This helper runs BEFORE the + # field-specific validators that check element types, so a fixture holding + # `[{...}, {...}]` raised `unhashable type: dict` and `[1, 1, "R-X", "R-X"]` + # raised on comparing str with int - the suite dying on the evidence it came + # to read, which this file already records as a failure it has had before and + # which the last commit reintroduced by building a set from raw input. + rep = [] + for i, item in enumerate(seq): + if any(item == other for other in seq[:i]): + continue + if sum(1 for other in seq if other == item) > 1 and item not in rep: + rep.append(item) if rep: return (f"{where} repeats {rep!r}. Every consumer normalises this through " "`set()`, so the repeat changes no verdict and still leaves two raw " @@ -360,6 +421,32 @@ def repeats_failure(where: str, seq) -> str: return "" +def floor_spec_failures(policy: dict, senior: dict) -> list: + """The contract's own statement of the floor, bound to the senior rule. + + `clears_floor` implements the exception; this checks the contract SAYS it. + Prose restating a rule goes stale exactly as often as it is restated - the + fifth site of this one was a sentence in `reason_mapping`, still describing a + bare count after four checker sites had been corrected.""" + out = [] + spec = ((policy.get("reason_mapping") or {}) + .get("no_rule_applied_after_a_defeat") or {} + ).get("reason_by_surviving_kinds", {}).get("floor") + if not isinstance(spec, dict): + return [f"`reason_mapping.no_rule_applied_after_a_defeat` states its floor as " + f"{spec!r}. A prose restatement cannot be checked against the senior " + "rule, and this one had already drifted from it."] + if spec.get("senior_field") not in senior: + out.append(f"the floor names senior field {spec.get('senior_field')!r}, which " + "`finding-lineage/v1` does not carry") + if spec.get("sufficient_alone_clears") is not True: + out.append("the floor declares `sufficient_alone_clears` " + f"{spec.get('sufficient_alone_clears')!r}. `evidence_rule` in the " + "senior contract says such a kind alone satisfies the floor and the " + "count stops applying; a junior contract cannot say otherwise.") + return out + + def clears_floor(kinds, senior: dict, floor: int) -> bool: """Does this evidence clear `minimum_evidence_kinds_for_continued`? @@ -631,6 +718,8 @@ def main() -> int: ids = sorted(rules) outcomes = {r: rules[r]["outcome"] for r in ids} raw_edges = raw_dominance_edges(policy) + for msg in floor_spec_failures(policy, senior): + check(False, msg) for msg in refusal_shape_failures(policy): check(False, msg) raw_refusals = raw_refusal_pairs(policy) @@ -1327,6 +1416,12 @@ def arbitration_leaves(node, trail): for i, exp in enumerate(case.get("expect", [])): where = f"{name}[{i}]" + # FAIL AND SKIP, never crash. Everything below assumes strings. + malformed = malformed_list_failures(where, exp, UNAVAILABLE_FIELD) + for msg in malformed: + check(False, msg) + if malformed: + continue outcome = exp.get("outcome") check(outcome in senior_outcomes, f"{where}: outcome {outcome!r} is not in the frozen vocabulary") From f3adcd8935defb29a49c4c528d7670842d855379 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:43:04 +0000 Subject: [PATCH 47/84] fix(identity): candidates are rivals, not endpoints `ambiguous_candidates` accepted a list mixing occurrence ids from both revisions, because membership was checked as `in occ_a or in occ_b` - per id, never across the list. That is not an ambiguity at all: candidates are the alternatives a rule could not choose BETWEEN, and two occurrences from different producer runs are the endpoints of a mapping rather than rivals for one end of it. A consumer reading such a record cannot tell whether the rule failed to pick predecessors or successors, so the provenance says less than its shape suggests. Each candidate list must now draw from one revision. Deliberately not stronger: which side the ambiguity is on varies with the refusal - side a records successors it could not choose between, side b records competing B occurrences - so requiring a particular revision would be the checker asserting something the contract has not said. One revision per list is what the declaration itself implies. Two probes bite, one of them the previous round's duplicate check re-run through the same list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 00805b1..a3177cb 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -1904,6 +1904,20 @@ def shown(ids_): check(isinstance(ids_, list) and len(set(ids_)) >= 2, f"{where}: {rid} is recorded as unable to choose between " f"{ids_!r}; fewer than two candidates is not an ambiguity") + # ONE REVISION PER LIST. `in occ_a or in occ_b` accepted a list + # mixing both, which is not an ambiguity at all: candidates are + # the alternatives a rule could not choose BETWEEN, and two + # occurrences from different runs are endpoints of a mapping + # rather than rivals for one end of it. A consumer reading such a + # record cannot tell whether the rule failed to pick predecessors + # or successors - the provenance says less than it appears to. + sides = {("A" if oid in occ_a else "B") for oid in ids_ or [] + if oid in occ_a or oid in occ_b} + check(len(sides) <= 1, + f"{where}: ambiguous_candidates[{rid!r}] = {ids_!r} draws from " + "BOTH revisions. A rule that could not choose was choosing among " + "candidates on one side; a list spanning both records no " + "answerable question.") for oid in ids_ or []: check(oid in occ_a or oid in occ_b, f"{where}: candidate {oid!r} is in neither revision") From 29c1235097e54977eb20f27a573e24f8d3b79baa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:45:36 +0000 Subject: [PATCH 48/84] fix(identity): membership standing in for the correct member, in my own binding `floor_spec_failures` - added one commit ago to stop the contract and the checker disagreeing about the floor - checked `senior_field in senior`. That accepts any real senior key, so the contract could declare its refusal threshold derives from `limitations` or `outcomes` and nothing objected. `counted_over` was not checked at all: `all signals` was green. This is exactly the shape I have been hunting and naming for four rounds - a value accepted for being IN a vocabulary with nothing checking it is the CORRECT member - occurring in the check written to close a different instance of it. The binding bound that a field was named, not which. - `FLOOR_FIELD` names the senior key ONCE and is used both to READ the floor and to bind the contract's claim about it, so the two cannot agree by coincidence. - `counted_over` is a token now, `distinct_kinds`, checked against the only semantics `clears_floor` implements. The old value was a sentence, and a sentence is what let `all signals` through. Eight probes bite: three senior_field shapes, three counted_over shapes, and two regressions from the previous commit's binding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 10 ++++++-- identity/tests/test_lineage_decision.py | 30 +++++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 473485c..eac0435 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -517,7 +517,7 @@ "at_or_above_the_floor": "lineage-id-unavailable:insufficient-evidence-combination", "floor": { "senior_field": "minimum_evidence_kinds_for_continued", - "counted_over": "DISTINCT surviving kinds", + "counted_over": "distinct_kinds", "sufficient_alone_clears": true, "why": [ "MACHINE-READABLE, and read by the suite rather than restated in it.", @@ -533,7 +533,13 @@ "The exception was taught to four checker call sites and to none of the places", "the CONTRACT states it - which is the same claim at a fifth site, found by a", "reviewer after a census that enumerated predicates in the checker and never", - "looked here." + "looked here.", + "", + "`counted_over` is a TOKEN, not a sentence. It read `DISTINCT surviving kinds`,", + "and the check that was supposed to bind this section accepted `all signals` in", + "its place - and accepted `senior_field: limitations`, a real senior key with", + "nothing to do with the floor. Membership standing in for the correct member,", + "inside the check written to stop this section drifting from the senior rule." ] } }, diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index a3177cb..90819b3 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -421,6 +421,15 @@ def repeats_failure(where: str, seq) -> str: return "" +# The senior field the floor comes from, named ONCE. `floor_spec_failures` binds +# the contract's declaration to this same constant, so the check cannot pass +# because it happens to agree with a different reading of the same key. +FLOOR_FIELD = "minimum_evidence_kinds_for_continued" +# The only counting semantics `clears_floor` implements. A contract declaring +# another one would be describing a policy this suite does not check. +FLOOR_COUNTED_OVER = "distinct_kinds" + + def floor_spec_failures(policy: dict, senior: dict) -> list: """The contract's own statement of the floor, bound to the senior rule. @@ -436,9 +445,22 @@ def floor_spec_failures(policy: dict, senior: dict) -> list: return [f"`reason_mapping.no_rule_applied_after_a_defeat` states its floor as " f"{spec!r}. A prose restatement cannot be checked against the senior " "rule, and this one had already drifted from it."] - if spec.get("senior_field") not in senior: - out.append(f"the floor names senior field {spec.get('senior_field')!r}, which " - "`finding-lineage/v1` does not carry") + # EXACTLY the field, not merely a field that exists. `in senior` accepted + # `limitations` and `outcomes` - real senior keys, unrelated to the floor - + # so the contract could claim its refusal threshold derives from the + # limitation vocabulary and nothing objected. Membership standing in for the + # correct member, in the check written to stop the contract and the checker + # disagreeing about this very number. + if spec.get("senior_field") != FLOOR_FIELD: + out.append(f"the floor names senior field {spec.get('senior_field')!r}; the " + f"suite reads the floor from {FLOOR_FIELD!r} and the contract has to " + "name the same one, or the two describe different thresholds while " + "agreeing that a threshold exists") + if spec.get("counted_over") != FLOOR_COUNTED_OVER: + out.append(f"the floor is counted over {spec.get('counted_over')!r}; " + f"`clears_floor` counts {FLOOR_COUNTED_OVER!r} and implements no " + "other semantics, so any other value describes a policy nothing " + "here checks") if spec.get("sufficient_alone_clears") is not True: out.append("the floor declares `sufficient_alone_clears` " f"{spec.get('sufficient_alone_clears')!r}. `evidence_rule` in the " @@ -890,7 +912,7 @@ def arbitration_leaves(node, trail): "authority, or a mapper reads whichever it happened to open.") senior_outcomes = set(senior["outcomes"]) - floor = senior["minimum_evidence_kinds_for_continued"] + floor = senior[FLOOR_FIELD] for rid in ids: check(outcomes[rid] in senior_outcomes, f"{rid} names outcome {outcomes[rid]!r}, which is not one of the six") From 7fa0d1284d1148b026d730d572b7bf5af0e5e420 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:49:22 +0000 Subject: [PATCH 49/84] fix(identity): the guard has to survive what it was added to doubt Three findings, all confirmed by mutation. Two are in the guard written last round to stop the suite dying on malformed fixtures. 1. `malformed_list_failures` called `.items()` on `ambiguous_candidates` before checking it was a mapping, so a list there raised `AttributeError` INSIDE the helper whose whole purpose is to report malformed input rather than die on it. A guard that assumes the shape it was added to doubt is not a guard. 2. A truthy non-object `decision_detail` - a string, an int - reached the obligations pass and died there. The guard was sitting inside the expectation loop and skipping ONE expectation, which protected that loop and nothing else; the obligations pass is a separate walk over the same expectations. Guarding one consumer of malformed input is the same error as fixing one site of a claim, so the fixture is now refused once, before any reader sees it, and the whole case is skipped. Patching the crash site the traceback named would have been that error a third time in one commit. 3. `entry_shape` was declared for every structural signal and enforced nowhere. `record_names` accepts a scalar by equality and a list by membership - correct, because a fold's `from` IS a list and a copy's is not - and that tolerance had become the only reading: turning `copies[].from` into a one-element list left the suite green against a catalog declaring a scalar. A literal parser and a lenient one would then disagree about the same frozen corpus, which is what `entry_shape` exists to prevent. Both directions now fail. Eleven probes bite: seven malformed container shapes, three entry-shape mutations, one non-string element as a regression. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 76 +++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 90819b3..6f92963 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -297,6 +297,37 @@ def signal_bindings(spec: dict) -> list: return out +def entry_shape_failures(where: str, cat_spec: dict, entries) -> list: + """The catalog's `entry_shape` says whether a field is a scalar or a list. + + Nothing enforced it. `record_names` accepts a scalar by equality and a list by + membership - which is right, because a fold's `from` IS a list and a copy's is + not - and that tolerance quietly became the only reading: turning + `copies[].from` from a path into `["path"]` left the suite green while + `entry_shape` declared a scalar. A mapper parsing the contract literally and + a mapper parsing it leniently would then disagree about the same frozen + corpus, which is what `entry_shape` exists to prevent.""" + out = [] + shape = cat_spec.get("entry_shape") + if not isinstance(shape, dict) or not isinstance(entries, list): + return out + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + out.append(f"{where}[{index}] is {entry!r}, not an object") + continue + for key, declared in shape.items(): + if key not in entry: + continue + wants_list = isinstance(declared, str) and declared.startswith("list of") + if wants_list != isinstance(entry[key], list): + out.append( + f"{where}[{index}].{key} is {entry[key]!r}, and `entry_shape` " + f"declares it {declared!r}. A scalar written as a one-element list " + "reads the same to a lenient parser and differently to a literal " + "one, so the corpus would sanction a record the contract does not.") + return out + + def record_names(entry: dict, key: str, wanted) -> bool: """One record entry naming one occurrence, on one key. A fold's `from` is a LIST of symbols and a copy's is a single path; `entry_shape` says which, so @@ -368,7 +399,22 @@ def malformed_list_failures(where: str, exp: dict, unavailable_field: str) -> li "consumer treats these as ids or kind names, and several put " "them in a set or a dict key - so the suite would die here " "rather than tell you which field is malformed.") + # THE CONTAINERS, BEFORE THEIR CONTENTS. This entered the dict branch and + # otherwise said nothing, so a `decision_detail` that was a string sailed + # past and died later on `.get`; and `ambiguous_candidates` was iterated with + # `.items()` before anything checked it was a mapping, so a list died HERE - + # inside the guard written to stop the suite dying. A guard that assumes the + # shape it was added to doubt is not a guard. + if detail is not None and not isinstance(detail, dict): + return [f"{where}: `decision_detail` is {detail!r}, not an object. Everything " + "that reads it expects a mapping, so nothing below can report what is " + "wrong with this fixture."] if isinstance(detail, dict): + cand_ = detail.get("ambiguous_candidates") + if cand_ is not None and not isinstance(cand_, dict): + return [f"{where}: `decision_detail.ambiguous_candidates` is {cand_!r}, not " + "an object. It maps a rule id to the candidates it could not choose " + "between; a bare list names candidates for no rule."] for field in ("conflicting_rules", "rules_without_a_unique_candidate", "rules_excluded_by_cardinality"): value = detail.get(field) @@ -1399,6 +1445,14 @@ def arbitration_leaves(node, trail): check(bool(case.get("expect")), f"{name}: nothing is preregistered") rev_a, rev_b = case.get("revision_a", {}), case.get("revision_b", {}) + # The catalog declares each record's shape; the fixtures have to hold it. + for _sig, _spec in sorted(catalog.items()): + _field = str(_spec.get("observable_from", "")).split("[].")[0] + if not _field.startswith("revision_b."): + continue + for msg in entry_shape_failures(f"{name}: {_field}", _spec, + rev_b.get(_field.partition(".")[2])): + check(False, msg) # BEFORE THE SETS. `{o["occurrence_id"] for o in ...}` silently collapses # a repeated id, and so does `by_id`; the raw LIST length then still # satisfied a 1:N minimum, so `to: ["occ-b1", "occ-b1"]` froze a branch @@ -1436,14 +1490,24 @@ def arbitration_leaves(node, trail): claim_b: dict = {} claimed_a, claimed_b = set(), set() + # FAIL AND SKIP THE WHOLE FIXTURE, never crash. This guard used to sit + # inside the expectation loop and `continue` past one expectation, which + # protected that loop and nothing else - the OBLIGATIONS pass further down + # is a separate walk over the same expectations, and it died on a + # `decision_detail` that was a string. Guarding one consumer of malformed + # input is the same mistake as fixing one site of a claim: the input is + # malformed for every reader, so it is refused once, here, before any + # reader sees it. + malformed = [m for i, exp in enumerate(case.get("expect", [])) + for m in malformed_list_failures(f"{name}[{i}]", exp, + UNAVAILABLE_FIELD)] + for msg in malformed: + check(False, msg) + if malformed: + continue + for i, exp in enumerate(case.get("expect", [])): where = f"{name}[{i}]" - # FAIL AND SKIP, never crash. Everything below assumes strings. - malformed = malformed_list_failures(where, exp, UNAVAILABLE_FIELD) - for msg in malformed: - check(False, msg) - if malformed: - continue outcome = exp.get("outcome") check(outcome in senior_outcomes, f"{where}: outcome {outcome!r} is not in the frozen vocabulary") From 1eee85838dd6124a263dd15a0878a8310852aa47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:52:25 +0000 Subject: [PATCH 50/84] test(identity): prose that restates a declared number The contract-prose audit, deferred for three rounds and finally run. Sweeping every `why` / `note` / `rule` string in both contracts for numbers turns up 119 mentions, almost all ordinary English - "two answers", "three questions". The class that matters is prose restating a value the contract also declares as data, and two such values are restated across both files: the senior evidence floor and the count of outcomes. Neither is stale today. Both are latent in exactly the way this branch has already been bitten three times - the case count twice, and the floor sentence in `reason_mapping` once, that last one load-bearing, because a mapper reading it would have classified evidence the suite classified the other way. `preregistration_rule` even records the remedy for its own count - "THE LIST ABOVE IS THE COUNT, and this paragraph does not repeat it" - and nothing applied it to the floor or the outcomes. `prose_number_failures` binds the restatements: promoting the floor to 3, or adding a seventh outcome, now fails on the prose that still says two and six. WHAT IT DOES NOT DO, stated in the helper because a gate claiming more than it checks is the defect most often found here: it catches a restatement written as `floor of N` or `N outcomes` and disagreeing with the data. Prose restating the same number some other way is not caught, and no check can promise otherwise. The honest remedy for that residue is not to restate the number. Two probes bite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 6f92963..a51b05a 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -476,6 +476,61 @@ def repeats_failure(where: str, seq) -> str: FLOOR_COUNTED_OVER = "distinct_kinds" +WORD_NUMBERS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, + "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, + "twelve": 12} + + +def prose_number_failures(policy: dict, senior: dict) -> list: + """Prose that restates a number this contract also declares as data. + + This branch has watched a restated count go stale three times: the case count + twice, and the floor sentence in `reason_mapping` once - the last of which was + load-bearing, since a mapper reading it classified evidence the suite + classified the other way. `preregistration_rule` already records the remedy + for its own count: "THE LIST ABOVE IS THE COUNT, and this paragraph does not + repeat it." Nothing applied that to the floor or the outcome count, which are + restated across both contracts. + + WHAT THIS DOES NOT DO, since a gate claiming more than it checks is this + branch's most-found defect: it catches a restatement written in one of the + forms below and disagreeing with the data. Prose that restates the same number + some other way is not caught, and no check here can promise otherwise - the + honest fix for that is not to restate the number.""" + out = [] + floor = senior[FLOOR_FIELD] + outcomes_n = len(senior["outcomes"]) + def numbers(text): + for raw in re.findall(r"floor of ([a-z]+|\d+)", text, re.I): + yield "floor", raw + for raw in re.findall(r"([a-z]+|\d+) outcomes", text, re.I): + yield "outcomes", raw + def walk(node, trail, source): + if isinstance(node, dict): + for k, v in node.items(): + walk(v, trail + [k], source) + elif isinstance(node, list): + for i, v in enumerate(node): + walk(v, trail + [f"[{i}]"], source) + elif isinstance(node, str): + for kind, raw in numbers(node): + value = WORD_NUMBERS.get(raw.lower()) + if value is None and raw.isdigit(): + value = int(raw) + if value is None: + continue + want = floor if kind == "floor" else outcomes_n + if value != want: + out.append( + f"{source} :: {'.'.join(trail)} says {raw!r} where the " + f"{kind} is {want}. A number restated in prose goes stale " + "exactly as often as it is restated, and one such sentence " + "here already contradicted the senior rule it described.") + walk(policy, [], "finding-lineage-decision/v1") + walk(senior, [], "finding-lineage/v1") + return out + + def floor_spec_failures(policy: dict, senior: dict) -> list: """The contract's own statement of the floor, bound to the senior rule. @@ -786,6 +841,8 @@ def main() -> int: ids = sorted(rules) outcomes = {r: rules[r]["outcome"] for r in ids} raw_edges = raw_dominance_edges(policy) + for msg in prose_number_failures(policy, senior): + check(False, msg) for msg in floor_spec_failures(policy, senior): check(False, msg) for msg in refusal_shape_failures(policy): From 4056e51a55694f8bd69c0cfe2a7c881adcfdc9c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:55:16 +0000 Subject: [PATCH 51/84] fix(identity): checking part of a claim, twice over Two findings, both against checks added in the last two commits, both the same shape: the check covered part of what it claimed. 1. `malformed_list_failures` validated the ELEMENTS of the three decision-detail rule-id fields only when the value already was a list, and never rejected a non-list. An object went unremarked - and `set(...)` over a mapping is a set of its KEYS, so `{"R-BRANCH-COPY": "not-a-list", "R-MERGE-FOLD": "not-a-list"}` satisfied every comparison downstream. The gate that exists to refuse malformed containers checked the contents of its claim and not the claim. 2. `entry_shape_failures` compared list-versus-scalar and nothing else: it skipped a key the catalog declares but the record omits, and never checked the declared primitive type. Deleting `copies[0].similarity`, or writing it as `"100"`, left a record violating the shape the catalog states for it. `entry_shape` makes three claims - the key is there, it is scalar or list, and it holds a given type - and the check tested one. Both now cover the whole declaration. `bool` is excluded from `int` explicitly, since `isinstance(True, int)` is true in Python and a similarity of `true` is not a similarity. Ten probes bite. One first reported the container gap as still open; the mutation had silently not applied, because the fixture I aimed it at does not carry that field. Re-aimed at one that does, it fails correctly - the same confounded-probe error as the round where a mutation tripped an unrelated check, and the reason every mutation script here now asserts that it changed something. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index a51b05a..2832f0e 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -316,9 +316,28 @@ def entry_shape_failures(where: str, cat_spec: dict, entries) -> list: out.append(f"{where}[{index}] is {entry!r}, not an object") continue for key, declared in shape.items(): + # EVERY DECLARED KEY, and its TYPE. This skipped a missing key and + # compared only list-versus-scalar, so deleting `similarity` or + # writing it as `"100"` left a record violating the shape the catalog + # declares for it. `entry_shape` says what a record IS; checking one + # of its three claims is the shape this branch keeps repeating. if key not in entry: + out.append(f"{where}[{index}] omits {key!r}, which `entry_shape` " + f"declares as {declared!r}. A mapper reading the catalog " + "expects the field to be there.") continue wants_list = isinstance(declared, str) and declared.startswith("list of") + if wants_list == isinstance(entry[key], list): + want_type = int if declared == "int" else str + members = entry[key] if wants_list else [entry[key]] + wrong = [m for m in members if not isinstance(m, want_type) + or isinstance(m, bool)] + if wrong: + out.append( + f"{where}[{index}].{key} holds {wrong!r}, and `entry_shape` " + f"declares {declared!r}. A record whose types differ from the " + "catalog is one a mapper parses differently than the contract " + "describes.") if wants_list != isinstance(entry[key], list): out.append( f"{where}[{index}].{key} is {entry[key]!r}, and `entry_shape` " @@ -418,6 +437,16 @@ def malformed_list_failures(where: str, exp: dict, unavailable_field: str) -> li for field in ("conflicting_rules", "rules_without_a_unique_candidate", "rules_excluded_by_cardinality"): value = detail.get(field) + # THE CONTAINER FIRST. This validated elements only when the value + # already WAS a list, so an object went unremarked - and `set(...)` + # over a dict iterates its keys, so a mapping whose keys happen to be + # the right rule ids satisfied every comparison downstream. The check + # covered the contents of its claim and not the claim. + if value is not None and not isinstance(value, list): + out.append(f"{where}: decision_detail.{field} is {value!r}, not a list " + "of rule ids. An object here passes every set comparison " + "below, because a set of a mapping is a set of its keys.") + continue if isinstance(value, list): bad = [v for v in value if not isinstance(v, str)] if bad: From a2f435f1197f44ed976cbcbede8d8b0a6a488f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:58:19 +0000 Subject: [PATCH 52/84] docs(contract): a claim about the suite that the suite does not keep `record_binding_vocabulary` asserted that the two group quantifiers are "load-bearing, not incidental - swapping them makes both rules wrong in opposite directions, and the suite fails on both". Review checked it. The suite fails on NEITHER, and the reasons differ: - On the copy rule, `excluding` narrows the successor pool to exactly the partner the record explains - one occurrence - and `every` over one element is `at_least_one` over one element. The two declarations are indistinguishable on this corpus BY CONSTRUCTION, not by oversight. - On the fold, `every` is strictly stronger and every fixture satisfies it, so weakening it cannot fail. The fixture that would detect the weakening is a fold record naming only some of its predecessors - an invalid mapping, which the matrix cannot preregister as a valid case. I had probed this myself, four commits back, and recorded "the quantifier value decides" on the strength of a discriminating result. That result was real then and is stale now: the `excluding` clause and the per-entry rewrite since have collapsed the copy case to one candidate. A probe result is evidence about the code that existed when it ran. The claim is corrected in place rather than deleted, because why it was false is the useful part. These two values are DECLARED and not PINNED: the suite checks vocabulary membership and role agreement, and cannot check that the right member was chosen. Closing that would need either a fixture the matrix does not admit or the checker asserting a policy value - which is the owner's call, and is recorded as such rather than settled here. No behaviour changes. The contract now says what is true of the suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 25 ++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index eac0435..a7d4049 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -963,8 +963,29 @@ "whose roles disagree with the catalog.", "", "The quantifiers differ between the two group rules and that difference is", - "load-bearing, not incidental - see each rule's `why`. Swapping them makes both", - "rules wrong in opposite directions, and the suite fails on both." + "The quantifiers differ between the two group rules and the difference is a", + "POLICY CHOICE - see each rule's `why`. An earlier revision of this paragraph", + "claimed that swapping them 'makes both rules wrong in opposite directions, and", + "the suite fails on both'. That was false in both directions and is corrected", + "here rather than quietly deleted, because the reason it was false is worth", + "keeping:", + "", + " - On the copy rule, `excluding` narrows the successor pool to exactly the", + " partner the record explains - one occurrence - and `every` over one element", + " is `at_least_one` over one element. The two declarations are", + " indistinguishable on this corpus by construction.", + " - On the fold, `every` is strictly stronger than `at_least_one`, and every", + " fixture satisfies the stronger one, so weakening it cannot fail. A fixture", + " that WOULD detect the weakening is a fold record naming only some of its", + " predecessors - which is an invalid mapping, and so cannot be preregistered", + " as a valid case.", + "", + "So these two values are declared and not pinned. The suite checks that each is", + "a member of the vocabulary and that the roles match the catalog; it does not,", + "and on this corpus cannot, check that the RIGHT member was chosen. Whether that", + "should be closed - and if so, by a fixture the matrix does not currently admit,", + "or by the checker asserting the value - is an open question for the owner, not", + "something to settle here." ], "quantifier": { "every": "the record must name EVERY occurrence in that role for this mapping", From d5b2ceeadc565ee7fa0c35c138994860ff307a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:25:49 +0000 Subject: [PATCH 53/84] test(identity): a container checked only through its contents Two reviewers reported one instance each, in the same round: a structural record source written as an object rather than a list, and an `ambiguous_candidates` value written as a bare integer. Both were real. Fixing the two named sites would have been the mistake this branch has now made seven times, so the fixtures were censused instead - every field of every fixture replaced, one at a time, with a scalar, an empty object and an empty list. Of 2637 mutations, 633 crashed the suite and 625 left it green: 1258 of them were not refused. A crash is the worse half, because it reports nothing at all - not the violation that caused it and not the other findings that run was going to make. The two reported sites were two of 1258. So a fixture is now refused once, before any reader sees it, against a table that declares what every field IS - in both directions, since a list written as a scalar and a scalar written as a list are one defect and answering only the direction a reviewer sent is how the last several rounds each left half a fix behind. An undeclared key is refused too: a field added to a fixture and not to the table would reopen exactly this hole, silently. The first draft of the gate crashed on `occurrences` one line after reporting it - the seventh time on this branch that a guard has died on the input it exists to describe. It was caught by re-running the census against the fix rather than against the two reported cases. Two things the gate has to guarantee because its readers already assume them: every occurrence attribute the policy reads is present, since an absent `path` makes `same_path` compare None to a string and come out false - absence of a record read as a semantic outcome, the one thing this contract exists to forbid - and `boundary_defeated` is declared non-empty through the predicate that already answers that question, because a case claiming a boundary was defeated and naming no boundary says less than a case that stays silent. The same census now crashes 0 and leaves 38 green. Those 38 are four known classes, recorded in the docstring: `start_column` legitimately accepting an integer, the elements of a senior-contract source no decision-layer signal reads, and one obligation-scope question that belongs to `case_obligations` rather than to shape. Contracts unchanged. No mapper. 20 suites green, normal and -O. --- identity/tests/test_lineage_decision.py | 239 ++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 2832f0e..2d7f05a 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -386,6 +386,233 @@ def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: return field, entries, pairs +KIND_TESTS = { + "string": lambda v: isinstance(v, str), + "list": lambda v: isinstance(v, list), + "object": lambda v: isinstance(v, dict), + # `bool` is an `int` in Python and nowhere else. A fixture writing `true` + # where a line number belongs is malformed input, not the number one. + "integer": lambda v: isinstance(v, int) and not isinstance(v, bool), + "integer-or-null": lambda v: v is None or KIND_TESTS["integer"](v), + "string-or-list-or-null": lambda v: v is None or isinstance(v, (str, list)), +} + + +def declared_kind_failure(where: str, value, kind: str, why: str = "") -> str: + """"Is this value the KIND its declaration says?" - asked once. + + It was asked in a dozen hand-written places and never asked at all in most + of them, and the two spellings that existed disagreed: one branch tested + `isinstance(value, list)` and reported, another tested it and merely + returned. Callers pass their own `why`, because the reason a wrong container + is dangerous differs by field and the argument is worth keeping; the QUESTION + does not differ and is no longer written twice.""" + if KIND_TESTS[kind](value): + return "" + return f"{where} is {value!r}, not {kind}." + (f" {why}" if why else "") + + +def record_sources(*contracts) -> set: + """Every `revision_b.` either contract declares a signal observable + from. + + Hand-listing the record sources would have made a third place that must be + updated when a signal is added - and of the two that already exist, one was + missing `rename_record` for a whole round. The contracts say where a signal + is read FROM; this reads that declaration rather than restating it.""" + found = set() + + def walk(node): + if isinstance(node, dict): + for key, value in node.items(): + if key == "observable_from" and isinstance(value, str): + head, _, rest = value.split("[].")[0].partition(".") + if head == "revision_b" and rest: + found.add(rest) + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + for contract in contracts: + walk(contract) + return found + + +# The whole vocabulary of a decision fixture, and the KIND each field holds. +# An undeclared key is a failure: a field added to a fixture and not to these +# tables would reopen exactly the hole they close, and reopen it in silence. +FIXTURE_KINDS = { + "case": "string", "title": "string", "contract": "string", + "status": "string", "why": "string", + "revision_a": "object", "revision_b": "object", + "expect": "list", "forbid": "list", +} +REVISION_KINDS = {"revision": "string", "occurrences": "list"} +OCCURRENCE_KINDS = { + "occurrence_id": "string", "path": "string", "enclosing_symbol": "string", + "anchored_content": "string", "pattern_id": "string", + "start_line": "integer", "start_column": "integer-or-null", +} +# EVERY attribute the policy reads off an occurrence, because a MISSING one is +# read as a value. `by_id` indexes `occurrence_id` directly and an occurrence +# without it killed the run; the other five are read with `.get`, which is worse +# - an absent `path` makes `same_path` compare None against a string and come out +# false, so the fixture would witness "the paths differ" by not saying what they +# are. That is absence of a record read as a semantic outcome, which is the one +# thing this whole contract exists to forbid. `start_column` is the exception on +# purpose: nothing reads it. +OCCURRENCE_REQUIRED = frozenset(OCCURRENCE_KINDS) - {"start_column"} +EXPECTATION_KINDS = { + "outcome": "string", "side": "string", "reason": "string", "note": "string", + # A predecessor is one occurrence or, for a fold, several. `to` is a list at + # every outcome, including the empty one. + "frm": "string-or-list-or-null", "to": "list", + "applicable_rules": "list", "licensed_by": "list", "evidence_surviving": "list", + UNAVAILABLE_FIELD: "list", + "not_applicable": "object", "signals_defeated": "object", + "boundary_defeated": "object", "decision_detail": "object", +} +DETAIL_KINDS = { + "conflicting_rules": "list", "rules_without_a_unique_candidate": "list", + "rules_excluded_by_cardinality": "list", "ambiguous_candidates": "object", +} +# signal or rule id -> a sentence. The KEYS are checked against their +# vocabularies elsewhere; this table is only about the VALUES. +NAMED_REASON_FIELDS = ("not_applicable", "signals_defeated", "boundary_defeated") + + +def kind_table_failures(where: str, obj, kinds: dict, required=frozenset()) -> list: + """One object against one table: declared keys only, each of its kind, and + the ones a reader indexes rather than `.get`s actually there.""" + bad = declared_kind_failure( + where, obj, "object", + "Nothing below can report what is wrong with a fixture whose sections " + "are not sections.") + if bad: + return [bad] + out = [] + for key in sorted(obj): + if key not in kinds: + out.append(f"{where}.{key} is not a field this fixture schema declares. " + "Either the field is a typo the suite would read as absent, or " + "the schema has drifted behind the fixtures - and an undeclared " + "field is an unchecked one.") + continue + msg = declared_kind_failure(f"{where}.{key}", obj[key], kinds[key]) + if msg: + out.append(msg) + for key in sorted(required - set(obj)): + out.append(f"{where} omits {key!r}. Every reader below either indexes it " + "directly or reads it with `.get` and compares the result - so " + "leaving it out does not raise a question, it answers one.") + return out + + +def fixture_shape_failures(name: str, case, senior: dict, policy: dict) -> list: + """Every container in a decision fixture is the KIND its schema declares - + asked once, before any reader sees the fixture. + + Two reviewers each reported one instance of this in the same round: a + structural record source written as an object rather than a list, and an + `ambiguous_candidates` value written as a bare integer. Both were real, and + fixing the two named sites would have been the mistake this branch has now + made seven times. A census replaced every field of every fixture, one at a + time, with a scalar, an empty object and an empty list: of 2637 mutations, + 633 crashed the suite and 625 left it green. A crash reports NOTHING - not + this violation and not the twenty others that run would have found - so the + two reported sites were two of 1258. + + With this gate the same census crashes 0 and leaves 38 green, and those 38 + are four known classes, not a residue: `start_column`, which is declared + `integer-or-null` and legitimately accepts an integer; the ELEMENTS of + `removed_symbols`, a senior-contract source no decision-layer signal reads; + and an `evidence_surviving` or `inputs_unavailable` emptied on ONE + expectation while a sibling expectation still carries it, which asks whether + an obligation binds the case or each expectation - a question about + `case_obligations`, not about shape. + + KIND, in BOTH directions. A list written as a scalar and a scalar written as + a list are one defect, and checking the direction a reviewer happened to send + is how the last several rounds each left half a fix behind. + + KIND ONLY. Whether a field must be PRESENT, and whether its contents are well + formed, are different questions, asked by `entry_shape_failures`, + `malformed_list_failures` and the passes in `main`. This one answers what a + field IS, so that those may assume it.""" + kinds = dict(FIXTURE_KINDS) + revision_kinds = dict(REVISION_KINDS, + **{src: "list" for src in record_sources(senior, policy)}) + out = kind_table_failures(name, case, kinds) + if out: + return out + for side in ("revision_a", "revision_b"): + if side not in case: + continue + # THE TABLE, THEN WHAT IT LICENSES. `... or []` walked whatever + # `occurrences` happened to be, so a scalar `occurrences` - the very + # thing the table above reports - was iterated one line after being + # reported, and the TypeError killed the run before the report reached + # anyone. That is the seventh time on this branch that a guard has died + # on the input it exists to describe. Nothing here reads a section the + # table has not first agreed is that kind of section. + rev_bad = kind_table_failures(f"{name}.{side}", case[side], revision_kinds) + out += rev_bad + if rev_bad: + continue + for index, occ in enumerate(case[side].get("occurrences") or []): + out += kind_table_failures(f"{name}.{side}.occurrences[{index}]", + occ, OCCURRENCE_KINDS, OCCURRENCE_REQUIRED) + for index, forbidden in enumerate(case.get("forbid") or []): + msg = declared_kind_failure(f"{name}.forbid[{index}]", forbidden, "string", + "`forbid` states in words what the case must NOT " + "conclude; a container states nothing.") + if msg: + out.append(msg) + for index, exp in enumerate(case.get("expect") or []): + where = f"{name}[{index}]" + entry = kind_table_failures(where, exp, EXPECTATION_KINDS) + out += entry + if entry: + continue + for field in NAMED_REASON_FIELDS + ("decision_detail",): + # DECLARED, AND NOT EMPTY - the question `populated_object_failure` + # was written for, asked here rather than answered a second way. + # Three of these four happened to be caught by a later pass that + # wanted their contents; `boundary_defeated: {}` was caught by + # nothing, and a case declaring that a boundary was defeated and + # naming no boundary says less than a case that stays silent. + if field in exp: + bad = populated_object_failure(f"{where}.{field}", exp[field]) + if bad: + out.append(bad) + for field in NAMED_REASON_FIELDS: + for key in sorted(exp.get(field) or {}): + msg = declared_kind_failure( + f"{where}.{field}[{key!r}]", exp[field][key], "string", + "The value is the sentence saying why, and it is the only " + "place that reason is written down.") + if msg: + out.append(msg) + detail = exp.get("decision_detail") + if detail is None: + continue + detail_bad = kind_table_failures(f"{where}.decision_detail", detail, DETAIL_KINDS) + out += detail_bad + if detail_bad: + continue + for rid in sorted(detail.get("ambiguous_candidates") or {}): + msg = declared_kind_failure( + f"{where}.ambiguous_candidates[{rid!r}]", + detail["ambiguous_candidates"][rid], "list", + "It holds the candidates a rule could not choose BETWEEN, so " + "fewer than two of anything is not an ambiguity - and a scalar " + "here is iterated further down and kills the run.") + if msg: + out.append(msg) + return out + + def malformed_list_failures(where: str, exp: dict, unavailable_field: str) -> list: """Every declared list on an expectation holds STRINGS - checked before anything reads them. @@ -1522,6 +1749,18 @@ def arbitration_leaves(node, trail): for name in sorted(set(on_disk) & set(preregistered)): with open(os.path.join(FIXDIR, f"{name}.json"), encoding="utf-8") as fh: case = json.load(fh) + # BEFORE EVERY READER, and the FIRST of them. The malformed-input guard + # below this loop already learned that refusing per-reader protects one + # reader; it was hoisted to cover the expectations, and the passes ABOVE + # where it landed - the identity checks, the entry-shape walk, the + # occurrence census, `by_id` - still read a revision no one had looked + # at. A fixture whose `occurrences` is a scalar killed the entire run at + # `by_id`, and a killed run reports nothing at all. + shape = fixture_shape_failures(name, case, senior, policy) + for msg in shape: + check(False, msg) + if shape: + continue check(case.get("case") == name, f"{name}: case field is {case.get('case')!r}") check(case.get("contract") == "finding-lineage-decision/v1", f"{name}: wrong contract {case.get('contract')!r}") From 20c97611f5764f049ec25c5f869611fedbefcb74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:32:49 +0000 Subject: [PATCH 54/84] test(identity): a senior claim restated instead of read Hunting siblings of the container defect turned the same census on the two contracts: replace each container with a scalar, run both contract suites. 54 of 175 left both suites green. Fifty-one of those are prose - `notes`, `*_rule`, `why` - which carry no machine claim and are green for the right reason. Three were not. `outcomes.continued`, `outcomes.branched` and `outcomes.merged` are structured senior declarations, and the junior checker restated them: check(shape == "1:1", f"... continued is 1:1 in the senior contract") The literal is the defect. Editing `outcomes.continued.cardinality` to "1:N" in `finding-lineage/v1` left both suites green, with this check still enforcing 1:1 and its own message citing the file it had stopped agreeing with. A junior contract may not narrow senior from below; a junior CHECKER restating senior is the same move one level down, and it keeps its answer when senior changes its mind. The shape is read from the senior contract now, and the minima are keyed on the shape rather than on the outcome name, because the shape is what says which side is plural. Two is still the junior contract's own reading - senior says "several", which is prose - and the comment now says so instead of claiming senior for it. The first version of that read was `(senior["outcomes"].get(o) or {})`, which handles `null` and `{}` and dies on `42`: the eighth time on this branch that a guard has been written at `bool` strength where `isinstance` was meant, and it died on the senior contract, the one input this check exists to read. Caught by mutating that input rather than by reading the line. The probe harness produced three false "not caught" verdicts first, by mangling quoted JSON so the mutation never applied while the run still reported a verdict. That is a recorded probe failure mode, so the harness now exits non-zero when the file is unchanged and the caller aborts rather than judging. The same census now leaves 51 green: the 50 prose sections, and `parent_lineage_reference_forms`, which is senior-only and belongs to the senior suite. Recorded in the PR, not fixed here. No mapper. 20 suites green, normal and -O. --- identity/tests/test_lineage_decision.py | 42 +++++++++++++++++++------ 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 2d7f05a..07c4cc2 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -1297,18 +1297,40 @@ def arbitration_leaves(node, trail): "the rule is 1:1, and guessing is how a lone successor gets `branched`") if isinstance(card, dict): shape = card.get("shape") - if outcomes[rid] == "continued": - check(shape == "1:1", f"{rid} licenses continued at cardinality {shape!r}; " - "continued is 1:1 in the senior contract") - elif outcomes[rid] == "branched": - check(shape == "1:N", f"{rid} licenses branched at {shape!r}") + # READ OFF THE SENIOR CONTRACT, not restated next to a sentence + # claiming the senior contract says so. The three shapes stood here + # as the literals "1:1", "1:N" and "N:1", so `outcomes.continued` + # could be edited upstairs to 1:N and this check went on enforcing + # 1:1 while its own message cited the file it had stopped agreeing + # with. A junior contract may not narrow senior from below; a junior + # CHECKER restating senior is the same move one level down, and it + # keeps its answer when senior changes its mind. Found by mutating + # the senior contract: both suites stayed green. + # `isinstance`, not `or {}`. The truthy spelling handles `null` and + # `{}` and dies on `42` - the same half-guard this branch has now + # written eight times, and it died on the senior contract, which is + # the input this check exists to read. + senior_spec = senior["outcomes"].get(outcomes[rid]) + senior_card = (senior_spec.get("cardinality") + if isinstance(senior_spec, dict) else None) + check(isinstance(senior_card, str), + f"the senior contract gives outcome {outcomes[rid]!r} no `cardinality` " + f"string, so there is nothing for {rid} to be checked against") + if isinstance(senior_card, str): + check(shape == senior_card, + f"{rid} licenses {outcomes[rid]} at cardinality {shape!r}; the " + f"senior contract makes {outcomes[rid]} {senior_card!r}") + # The MINIMUM on the plural side, keyed on the shape rather than on + # the outcome name - the shape is what says which side is plural. + # Two is not read from senior: senior says "several", which is prose, + # and `min_successors` is the junior contract's own reading of it. + if shape == "1:N": check(card.get("min_successors", 0) >= 2, - f"{rid} licenses branched without requiring two successors; " - "a branch with one is a continued, and the senior contract says so") - elif outcomes[rid] == "merged": - check(shape == "N:1", f"{rid} licenses merged at {shape!r}") + f"{rid} licenses {outcomes[rid]} without requiring two successors; " + "a group of one is a 1:1 under another name") + elif shape == "N:1": check(card.get("min_predecessors", 0) >= 2, - f"{rid} licenses merged without requiring two predecessors") + f"{rid} licenses {outcomes[rid]} without requiring two predecessors") # IF AND ONLY IF, for the minima too. Each was required where the # shape needs it and forbidden nowhere, so a 1:1 rule could carry From cc11aeeaa55681edae16a3205e89904b964c0551 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:47:21 +0000 Subject: [PATCH 55/84] test(identity): a record that binds a mapping, holding one end of it Codex: deleting `structural_signals.rename_record.matches.to` leaves the suite green. Verified, with a control - a rename record whose `from` matched while its `to` pointed at an unrelated file licensed R-CONT-RENAME, and the same fixture mutation fails when `matches.to` is declared. With the successor end unnamed, `related_by` counts every successor as reached. Group rules were already covered: their declared `record_binding` roles must equal the catalog's. But `record_binding` belongs to group rules and only them, so a 1:1 rule's binding is SYNTHESISED with both roles further down, and nothing asked whether the catalog names both. `rename_record` is cited only by a 1:1 rule, which is why it was the one that showed. The question is asked of the records rules REST ON, computed from the rules rather than listed here. The two defeaters name one role on purpose - `renamed_symbol_record` matches on `from` alone, because a record saying something ARRIVED at a name is not evidence about the predecessor that left it, and pooling the two roles there was a defect fixed in an earlier round. The contract already draws that line; this reads it. Hunting the sibling - `matches` declares a role AND an attribute, and the attribute half was checked - found that a misspelt role passed into `related_by` as a dict key and raised KeyError. The role was ALREADY checked, one block above. What was missing was not the question but the consequence: `check` accumulates, so the run carried on into the reader and died, throwing away that very report along with everything else it had left to say. `signal_bindings` now declines to hand an unknown role downstream, so the report survives to be printed. My first attempt at this added a second role check rather than looking for the existing one - the same duplicate-question defect this branch keeps fixing - and it is removed; `SUBJECT_ROLES` is now the one spelling, at nine sites that each had their own. CodeRabbit: `record_binding_vocabulary.why` carried a stale duplicated lead-in, an unfinished fragment of the claim that replaced it. Removed. Swept both contracts for other duplicated lead-ins by common prefix; this was the only one. Also recorded: the probe harness reverted this contract fix by `git checkout` during a later probe. Caught by diffing before commit rather than by trusting the edit had stayed. Codex's third finding - a truthy non-object `not_applicable` crashing on `.items()` - was already closed by the fixture-shape gate in d5b2cee, which is the remedy it asked for. Verified rather than assumed. Fixture census unchanged at 0 crashes and 38 explainable greens. No mapper. 20 suites green, normal and -O. --- contracts/finding-lineage-decision-v1.json | 1 - identity/tests/test_lineage_decision.py | 66 ++++++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index a7d4049..2205cc1 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -962,7 +962,6 @@ "the roles the signal's own `matches` names, and the suite rejects a binding", "whose roles disagree with the catalog.", "", - "The quantifiers differ between the two group rules and that difference is", "The quantifiers differ between the two group rules and the difference is a", "POLICY CHOICE - see each rule's `why`. An earlier revision of this paragraph", "claimed that swapping them 'makes both rules wrong in opposite directions, and", diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 07c4cc2..da785eb 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -276,9 +276,24 @@ def collect(rev: dict, dotted: str) -> list: return out +# The two ends of a mapping. Written once: `record_binding` role keys, the +# catalog's `matches` values and the binding pools all name the same two, and +# three literal spellings of one vocabulary is how this contract has gone wrong +# before. +SUBJECT_ROLES = ("predecessor", "successor") + + def signal_bindings(spec: dict) -> list: """(read path, subject role, subject attribute) for each key the catalog's - `matches` names. + `matches` names, for the roles a mapping actually has. + + A role outside `SUBJECT_ROLES` is DROPPED here rather than handed on. It is + reported by name where the catalog is validated; what this guarantees is that + no downstream reader can be given one. `related_by` indexes `subject[role]` + directly, so writing `succesor.path` in the catalog killed the whole run on a + KeyError - the contract-integrity check above it had already reported the + fault, and the crash then threw that report away along with everything else + the run had left to say. `matches` is the NORMATIVE half and `entry_shape` only says how the entries are shaped. An earlier version read every key of `entry_shape`, which is a @@ -292,6 +307,8 @@ def signal_bindings(spec: dict) -> list: out = [] for key, subject in (spec.get("matches") or {}).items(): role, _, attr = str(subject).partition(".") + if role not in SUBJECT_ROLES: + continue read = f"{field}[].{key}" if isinstance(shape, dict) else field out.append((read, role, attr)) return out @@ -1581,7 +1598,13 @@ def arbitration_leaves(node, trail): "the record is about and any record in revision B would do") for key, subject in (matches or {}).items(): role, dot, attr = str(subject).partition(".") - check(role in ("predecessor", "successor"), + # Already asked here. What was missing is not the question but the + # consequence: `check` accumulates, so the run continued into + # `related_by`, which indexes `subject[role]` and died on a KeyError - + # throwing away this very report along with everything else the run + # had left to say. `signal_bindings` now declines to pass an unknown + # role on, so the report survives to be printed. + check(role in SUBJECT_ROLES, f"{where_s}.matches[{key!r}] names role {role!r}; a record relates " "predecessors to successors and nothing else") check(bool(dot) and attr in occurrence_attrs, @@ -1699,6 +1722,37 @@ def arbitration_leaves(node, trail): # ...and the group quantifier, which used to live in `requires_all_scope` as # prose. A 1:1 rule could claim GROUP scope and a group rule could drop the # declaration entirely, both silently. + # A RECORD THAT BINDS A MAPPING HAS TO CONSTRAIN BOTH ENDS OF IT. The loop + # below checks that a GROUP rule's declared roles equal the catalog's, which + # covers `copy_record` and `merge_record` - but only transitively, through + # those rules' own declarations. A 1:1 rule declares no `record_binding` at + # all, since the field belongs to group rules and only them, so its binding + # is SYNTHESISED further down with both roles and nothing asked whether the + # catalog names both. Deleting `rename_record.matches.to` left the suite + # green, and a rename record whose `from` matched while its `to` pointed at + # an unrelated file then licensed R-CONT-RENAME: with the successor end + # unnamed, `related_by` counts every successor as reached. + # + # Asked of the records rules REST ON, not of every signal. The two defeaters + # name one role deliberately - `renamed_symbol_record` matches on `from` + # alone, because a record saying something ARRIVED at a name is not evidence + # about the predecessor that left it, and pooling the two roles there was a + # defect fixed in an earlier round. The contract draws that line itself; this + # reads it rather than restating it. + rested_on = set() + for rule_ in rules.values(): + for kind_ in rule_needs(rule_): + sig_ = kind_ if kind_ in catalog else kind_records.get(kind_) + if sig_ in catalog: + rested_on.add(sig_) + for sig_ in sorted(rested_on): + roles_ = {r for _, r, _ in signal_bindings(catalog[sig_])} + check(roles_ == set(SUBJECT_ROLES), + f"{sig_} is a record that rules rest on, and its `matches` names " + f"{sorted(roles_)}. A record explaining a mapping has to constrain BOTH " + "ends of it: with one end unnamed, every occurrence on that side counts " + "as reached and the record licenses a mapping it does not describe.") + vocab = policy["record_binding_vocabulary"] for rid in sorted(rules): rule_ = rules[rid] @@ -1718,12 +1772,12 @@ def arbitration_leaves(node, trail): "the binding is about exactly one record") for sig in structural: roles = {r for _, r, _ in signal_bindings(policy["structural_signals"][sig])} - declared = {k for k in binding if k in ("predecessor", "successor")} + declared = {k for k in binding if k in SUBJECT_ROLES} check(declared == roles, f"{rid}.record_binding names roles {sorted(declared)} but {sig} " f"matches on {sorted(roles)}. The roles are the catalog's, not a " "second opinion about which side the group is on.") - for role in ("predecessor", "successor"): + for role in SUBJECT_ROLES: spec_r = binding.get(role) if role in binding: bad = populated_object_failure(f"{rid}.record_binding.{role}", spec_r) @@ -2072,7 +2126,7 @@ def arbitration_leaves(node, trail): "nothing. A record cited by a licensing rule and absent from " "revision B is a record asserted in a note.") pools = {} - for role in ("predecessor", "successor"): + for role in SUBJECT_ROLES: spec_r = binding.get(role) or {} pool = list(frm if role == "predecessor" else to) if spec_r.get("excluding") == "partners_where_same_path_holds": @@ -2089,7 +2143,7 @@ def arbitration_leaves(node, trail): def carries(entry: dict) -> bool: rel = related_by(entry, pairs, pools["predecessor"], pools["successor"], by_id) - for role in ("predecessor", "successor"): + for role in SUBJECT_ROLES: quant = (binding.get(role) or {}).get("quantifier") reached = {p[0 if role == "predecessor" else 1] for p in rel} if quant == "every" and any(o not in reached From 55ff9d5b59bb79e529fe6a087107df2b9b0c02c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:57:11 +0000 Subject: [PATCH 56/84] test(identity): the adjacent suite, still holding the literal CodeRabbit: `test_lineage_contract.py` enforces the outcome shapes with the literals `continued is 1:1`, `branched is 1:N`, `merged is N:1`, and reads `outcomes.*.cardinality` nowhere. Verified, and wider than reported - all SIX shapes were literals, spread over three blocks, and the senior contract's own cardinality declarations were read zero times in the file. Editing `outcomes.continued.cardinality` to `1:N`, `N:1` or `0:1` left the senior suite green and still enforcing `1:1`. This is the same defect I fixed in the junior checker one commit earlier, at the site next door, and I did not go and look. The commit that fixed it carried a message about a checker restating a senior claim while the senior contract's own suite was restating the same claim one file over. Naming the shape in a commit message is not the same as asking the adjacent site. The shape is now read off each outcome's declaration. `N` means at least two, which is what these checks already meant - "branched with fewer than two successors is a continued" - so binding the literal changes nothing about what is enforced. An unparseable or missing shape is reported rather than skipped. `unresolved` keeps its per-side rule: its declared shape is "unknown", and the side constraint comes from `outcomes.unresolved.sides`, a different declaration the suite already reads. CodeRabbit also caught the revision history contradicting itself - two amendments both called "third", and the one after them "FOURTH". Corrected to FOURTH and FIFTH, and bound: a revision history that counts itself is a number nothing was checking, which is the prose-restates-data defect one level up. The duplicate quantifier sentence CodeRabbit reported was already removed in cc11aee; verified rather than assumed. Recorded, not fixed: Codex is right that `R-CONT-DRIFT` requires nothing that discriminates the DEFECT, only the site - `same_path`, `structural_context`, `line_drift` all hold of a different-pattern occurrence at a shifted line in the same symbol. Adding `same_pattern_id` leaves the corpus green, so neither policy is pinned. Unlike the quantifier question, the discriminating fixture here is a VALID mapping and could be preregistered - which makes it answerable, and makes choosing the answer the owner's rather than mine. Raised as an open question. Probe harness, twice burned the same way: `git checkout -- contracts/` discarded the uncommitted ordinal fix, so a whole probe round ran against the stale file and reported two different mutations with one message. Caught by noticing the duplicate message. Probes restore from a snapshot now, never from git. No mapper. 20 suites green, normal and -O. --- contracts/finding-lineage-v1.json | 4 +- identity/tests/test_lineage_contract.py | 105 ++++++++++++++++++++---- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index 9188447..0257d61 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -45,7 +45,7 @@ "Still frozen-unimplemented: no mapper exists, so nothing is built on the old", "shape and nothing has to be migrated.", "", - "Amended a THIRD time by slice 2 step 1, which added", + "Amended a FOURTH time by slice 2 step 1, which added", "'mutually_exclusive_evidence_kinds'. Step 1's per-rule coverage gate proved that", "a rule reached by a fixture is still not a rule whose REQUIREMENTS are pinned:", "swapping a continued rule's `line_drift` for `path_rename` left every check", @@ -55,7 +55,7 @@ "second mapper. It does not close all of it - see the open question recorded in", "the step 1 pull request.", "", - "Amended a FOURTH time by slice 2 step 1, which added", + "Amended a FIFTH time by slice 2 step 1, which added", "'evidence_kind_observation'. Step 1's kind-to-record map needed a value for", "kinds that no record witnesses, and any such escape hatch can be pointed at a", "kind that a record DOES witness. Which kinds those are is a fact about this", diff --git a/identity/tests/test_lineage_contract.py b/identity/tests/test_lineage_contract.py index 59293dc..71cf63d 100644 --- a/identity/tests/test_lineage_contract.py +++ b/identity/tests/test_lineage_contract.py @@ -91,6 +91,82 @@ def collect(revision: dict, spec: str) -> list: return out +SIDE_COUNTS = {"0": (0, 0), "1": (1, 1), "N": (2, None)} + + +def cardinality_shape_failures(where: str, outcome: str, shape, frm, to) -> list: + """The `:` shape an outcome declares, enforced FROM + that declaration. + + Every one of the six shapes was enforced here as a literal - `continued is + 1:1`, `branched is 1:N`, `merged is N:1`, and the ended/new pair as separate + length checks - spread over three blocks, and `outcomes.*.cardinality` was + read nowhere in this file. Editing the senior contract to say `continued` is + `1:N` left every one of them still enforcing `1:1` while citing the contract + they had stopped agreeing with. + + The junior suite had exactly this defect and it was fixed one commit earlier; + the senior contract's own suite was the adjacent site nobody asked. That is + the shape this branch keeps repeating, and this time it was committed + alongside a message describing it. + + `N` means at least two, which is what these checks already meant - "branched + with fewer than two successors is a continued". Reading it off the shape + binds the literal without changing what is enforced. An unparseable side is + reported rather than skipped: a shape nobody can read is not a shape that + constrains anything.""" + out = [] + sides = str(shape).split(":") + if len(sides) != 2 or any(s not in SIDE_COUNTS for s in sides): + return [f"{where}: outcome {outcome!r} declares cardinality {shape!r}, which " + f"is not `:` over {sorted(SIDE_COUNTS)}. " + "Nothing can be enforced from a shape that cannot be read."] + for token, label, got in zip(sides, ("predecessor", "successor"), (frm, to)): + low, high = SIDE_COUNTS[token] + if len(got) < low or (high is not None and len(got) > high): + want = f"at least {low}" if high is None else str(low) + out.append(f"{where}: {outcome} is {shape} in the senior contract, so it " + f"names {want} {label}(s), got {len(got)}. A group of one is the " + "1:1 outcome under another name, and a group of none is not an " + "outcome at all.") + return out + + +AMENDMENT_ORDINALS = {"again": 2, "second": 2, "third": 3, "fourth": 4, "fifth": 5, + "sixth": 6, "seventh": 7, "eighth": 8} + + +def amendment_ordinal_failures(note) -> list: + """The revision history counts itself, so the count is checked. + + Two entries both said "third" - one lower-case, one shouting - and the entry + after them said "FOURTH", because each amendment was written by reading the + previous one rather than by counting. A revision history whose ordinals + disagree is the same defect as prose restating a declared number, one level + up: the number is a fact about the document, and nothing was checking it. + + The pre-merge correction is revision one and is not an amendment, so the + n-th amendment is revision n + 1.""" + out = [] + seen = [] + for line in note if isinstance(note, list) else []: + if not isinstance(line, str) or not line.startswith("Amended"): + continue + words = [w.strip(",.").lower() for w in line.split()[:4]] + found = [AMENDMENT_ORDINALS[w] for w in words if w in AMENDMENT_ORDINALS] + if not found: + out.append(f"revision_note: {line!r} amends the contract without saying " + "which amendment it is") + continue + seen.append((line, found[0])) + for index, (line, ordinal) in enumerate(seen): + want = index + 2 + if ordinal != want: + out.append(f"revision_note: amendment {index + 1} calls itself revision " + f"{ordinal}, but it is revision {want} - {line!r}") + return out + + def main() -> int: with open(CONTRACT, encoding="utf-8") as fh: contract = json.load(fh) @@ -110,6 +186,8 @@ def main() -> int: check(contract["status"] == "frozen-unimplemented", "the contract must declare itself unimplemented until a mapper exists") check(outcomes == set(OUTCOMES), f"the six outcomes changed: {sorted(outcomes)}") + for msg in amendment_ordinal_failures(contract.get("revision_note")): + check(False, msg) # The load-bearing rule, asserted rather than only written down: absent # evidence yields 'unresolved'. If this ever reads 'new', every "introduced @@ -269,12 +347,12 @@ def main() -> int: "absence of a match is unresolved, not a boundary") kinds = exp.get("boundary_evidence") or [] check(bool(kinds), f"{where}: {outcome} must be earned by boundary evidence") - if outcome == "ended": - check(len(frm) == 1, f"{where}: ended must name the predecessor it is about") - check(not to, f"{where}: ended must not name a successor") - else: - check(len(to) == 1, f"{where}: new must name the occurrence it is about") - check(not frm, f"{where}: new must have no predecessor") + # `ended` is 1:0 and `new` is 0:1 in the senior contract, and + # both halves of each come off that declaration now. + for msg in cardinality_shape_failures( + where, outcome, + contract["outcomes"][outcome].get("cardinality"), frm, to): + check(False, msg) for kind in kinds: spec = boundary_values.get(kind) check(spec is not None, @@ -335,8 +413,6 @@ def main() -> int: f"{required}, and none is sufficient alone; the frozen floor is " f"{floor}. Name the evidence that really fired, or promote a kind.") if outcome == "continued": - check(len(frm) == 1 and len(to) == 1, - f"{where}: continued is 1:1, got {len(frm)}:{len(to)}") # The inherit rule: a predecessor that already carries a # lineage passes THAT id on. A re-mint would read as a new # defect appearing where an old one was proven to persist. @@ -344,14 +420,11 @@ def main() -> int: check(exp.get("lineage_id") == established[frm[0]], f"{where}: predecessor carries {established[frm[0]]!r}; " f"the successor must inherit it, not {exp.get('lineage_id')!r}") - if outcome == "branched": - check(len(frm) == 1, f"{where}: branched is 1:N, got {len(frm)} predecessors") - check(len(to) >= 2, - f"{where}: branched with fewer than two successors is a continued") - if outcome == "merged": - check(len(frm) >= 2, - f"{where}: merged with fewer than two predecessors is a continued") - check(len(to) == 1, f"{where}: merged is N:1, got {len(to)} successors") + if outcome in ("continued", "branched", "merged"): + for msg in cardinality_shape_failures( + where, outcome, + contract["outcomes"][outcome].get("cardinality"), frm, to): + check(False, msg) if outcome in ("branched", "merged"): # A child with no recorded parent is a birth wearing another # word, and a merge that records one parent has quietly From 66cb15cd328f6f4c4c9df1e2b514ffc5ed8667af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:12:36 +0000 Subject: [PATCH 57/84] test(identity): records required in code and declared nowhere Codex, two findings, both verified and both live: Removing `inputs_unavailable` from EITHER expectation of `unavailable-diff-is-not-a-deletion` left the suite green, because the loop validated the field only when present and the per-CASE obligation was satisfied by the mirror expectation. `unavailable_inputs` means a signal that "could not be EVALUATED at all ... for the revision" and is observed from `revision_b.unavailable_signals` - a revision-level fact - and `record_additions` says the field is "Present whenever any were". So every decision about the pair records them, and records the same set. Deleting `boundary_defeated` from `copy-at-one-to-one-is-not-a-branch` also left the suite green: the validation ran only when the optional object was present, so the case quietly stopped being adversarial. `deleted_paths` earns `ended` for that predecessor unless the copy record defeats it, and with the record gone it was an ordinary copy-continuation wearing a harder case's name. The converse is checked now, derived rather than listed: every boundary kind declares `match`, `observable_from` and what it `proves`, so a boundary that applied to an occurrence an expectation names must be either honoured or defeated in writing. That check immediately found the sibling Codex did not name. `merge-record-dominates-the-single-match` carries `removed_symbols` naming the folded-away symbol, concludes `merged`, and had never declared the defeat at all. Fixed in the fixture, with the defeater the senior contract specifies. Making the two directions agree exposed a third defect of the usual kind: "did this boundary apply?" was asked twice at different strengths. The declared-defeat validation demanded it applied to EVERY occurrence on its side; the converse requires a defeat when it applied to ANY. A fold falsifies the stronger one - it names two predecessors and only one symbol is removed, the other being what the fold merged INTO - so the stronger reading rejects the shape the fixture exists to preregister. One predicate now, `boundary_hits`, called from both. The guard still says what it was added to say: defeating a boundary that never applied proves nothing. Then, self-found, chasing the last census residue rather than reusing the label I had given it: `evidence_surviving` and `boundary_defeated` appear in NEITHER contract. Both are required by this suite, both validated in detail, both written by any conforming mapper. That is `requires_all_scope` inverted - prose nothing read became code nothing declared - and a mapper following the contract would have omitted two fields whose absence the suite treats as a violation. Declared, and the section bound in both directions so the next one cannot slip in. `not_applicable` and `note` are deliberately left unasserted: whether a mapper writes them is a contract question nobody has answered, and answering it here would be the checker deciding what belongs in a record. CodeRabbit's cardinality finding against cc11aee was already fixed in 55ff9d5; verified rather than assumed. Two probes fired on an unrelated check first and were re-run so the intended guard is what bites. Fixture census: 38 green to 32, still 0 crashes, and the remainder is `start_column` accepting an integer as declared plus one case whose `evidence_surviving` can be emptied without changing its mandated reason. No mapper. 20 suites green, normal and -O. --- contracts/finding-lineage-decision-v1.json | 4 +- ...rge-record-dominates-the-single-match.json | 5 +- identity/tests/test_lineage_decision.py | 121 ++++++++++++++++-- 3 files changed, 114 insertions(+), 16 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 2205cc1..64b1de3 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -392,7 +392,9 @@ "licensed_by": "the SURVIVING rules the outcome rests on - the applicable rules that no other applicable rule dominates, per `arbitration.licensed_by_rule`. Non-empty for continued, branched and merged; EMPTY on a refused conflict.", "decision_detail": "decision-layer precision that the senior contract's `reason` vocabulary does not carry: `conflicting_rules`, `rules_without_a_unique_candidate` (matched, singled nobody out), `rules_excluded_by_cardinality` (matched, wrong shape - no ambiguity involved) and `ambiguous_candidates` (rule id -> the occurrence ids it could not choose between). The rejecting stages are not cosmetic: without them a rule that lost before arbitration leaves no trace, and `applicable_rules` would either have to omit it (losing the recall set) or include it (claiming it supported the answer). It did neither. `ambiguous_candidates` is not cosmetic either: `reason_mapping.several_candidates` promised `the candidate ids` as its detail while the record shape defined nowhere to put them, so a mapper could not implement the promise and a consumer could not see which partners caused the refusal. NOT `inputs_unavailable` and NOT `signals_defeated`: both are top level.", "signals_defeated": "signal name -> the record that defeated it. TOP LEVEL, and spelled this way everywhere: an earlier revision also called it `defeated_signals` inside `decision_detail`, so two conforming implementations could have produced different JSON while each followed part of this contract. Present whenever step 2 removed anything.", - "inputs_unavailable": "signal names that could not be evaluated. TOP LEVEL, like `signals_defeated`, and declared in exactly one place. Present whenever any were." + "inputs_unavailable": "signal names that could not be evaluated. TOP LEVEL, like `signals_defeated`, and declared in exactly one place. Present whenever any were.", + "evidence_surviving": "the evidence kinds still standing after step 2 removed the defeated ones. TOP LEVEL, like `signals_defeated`, and present whenever a defeat occurred: which kinds survive is what decides whether the refusal is `insufficient-evidence-kind` or `insufficient-evidence-combination`, and a record that omits it leaves a consumer unable to tell an honest limitation from the other one. It may be EMPTY - a defeat can remove everything - so emptiness is a fact, not an absence.", + "boundary_defeated": "boundary kind -> the revision B record that defeated it, for each boundary whose evidence this revision pair carries and whose outcome the mapping did not reach. The senior contract says which record defeats which boundary; this field is where a mapper writes down that it applied one. Present whenever a carried boundary was not honoured: `deleted_paths` proves `ended` unless something says otherwise, and a mapping that concludes `continued` in silence is the absence-of-record defect one layer up." }, "preregistered_cases": [ "copy-record-dominates-the-single-match", diff --git a/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json b/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json index 9d0e302..59c593f 100644 --- a/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json +++ b/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json @@ -74,7 +74,10 @@ "R-CONT-COPY": "no copy record", "R-BRANCH-COPY": "no copy record, and the shape is N:1" }, - "note": "the partner profile is what makes R-MERGE-FOLD reach occ-a2 at all: same_pattern_id and anchored_content hold across the fold, while structural_context does not and must not be faked into holding. occ-a2 is a predecessor of the merge, NOT an `ended` - removed_symbols says its site is gone and merged_symbols says where it went, and the boundary contract already ranks the second above the first." + "note": "the partner profile is what makes R-MERGE-FOLD reach occ-a2 at all: same_pattern_id and anchored_content hold across the fold, while structural_context does not and must not be faked into holding. occ-a2 is a predecessor of the merge, NOT an `ended` - removed_symbols says its site is gone and merged_symbols says where it went, and the boundary contract already ranks the second above the first.", + "boundary_defeated": { + "boundary:enclosing-site-removed": "revision_b.merged_symbols[].from" + } } ], "forbid": [ diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index da785eb..2b5ae7a 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -314,6 +314,31 @@ def signal_bindings(spec: dict) -> list: return out +def boundary_hits(spec: dict, frm, to, by_id: dict, rev_b: dict) -> tuple: + """(role, the occurrences this boundary actually applied to) - asked once. + + It was asked twice at different strengths, which is this branch's signature + defect arriving in the boundary block. The declared-defeat validation + required the boundary to have applied to EVERY occurrence on its side; the + converse check requires a defeat when it applied to ANY. A fold falsifies the + stronger one: `merged` names two predecessors, only one of whose enclosing + symbols is in `removed_symbols`, and the other is the symbol the fold merged + INTO. Demanding it applied to both rejects the one shape the fixture exists + to preregister. + + `at least one` is what the guard was for - "defeating a boundary that never + applied to the side it names proves nothing" - and it still says that. What + it stops claiming is that a boundary reaching one member of a group must + reach all of them, which the senior contract nowhere says: the boundary is a + fact about an occurrence's site, not about the group it was decided with.""" + role, _, attr = str(spec.get("match", "")).partition(".") + if role not in SUBJECT_ROLES or not attr: + return role, [] + subjects = frm if role == "predecessor" else to + observed = collect(rev_b, str(spec.get("observable_from", ""))) + return role, [oid for oid in subjects if by_id.get(oid, {}).get(attr) in observed] + + def entry_shape_failures(where: str, cat_spec: dict, entries) -> list: """The catalog's `entry_shape` says whether a field is a scalar or a list. @@ -1798,6 +1823,33 @@ def arbitration_leaves(node, trail): check(unavailable_from.startswith("revision_b."), "`unavailable_inputs.observable_from` must name a revision B field, got " f"{unavailable_from!r}") + # EVERY RECORD FIELD THIS SUITE VALIDATES IS DECLARED, and every declaration + # is a field a fixture may carry. Only `inputs_unavailable` was ever checked + # for presence here, so `evidence_surviving` and `boundary_defeated` - both + # required by this suite, both validated in detail, both written by any + # conforming mapper - appeared in NEITHER contract. That is + # `requires_all_scope` inverted: prose nothing read became code nothing + # declared, and a mapper following the contract would have omitted two fields + # whose absence the suite treats as a violation. + # + # `not_applicable` and `note` are deliberately not asserted either way. + # Whether a mapper writes them, or whether they are annotations the fixtures + # carry for the reader, is a question the contract has not answered, and + # answering it here would be the checker deciding what belongs in a record. + validated_record_fields = {"applicable_rules", "licensed_by", "decision_detail", + "signals_defeated", UNAVAILABLE_FIELD, + "evidence_surviving", "boundary_defeated"} + missing_declarations = sorted(validated_record_fields - set(policy["record_additions"])) + check(not missing_declarations, + f"`record_additions` does not declare {missing_declarations}, which this " + "suite requires of an expectation and validates. A field enforced here and " + "described nowhere is a field a conforming mapper would not write.") + undeclared_fields = sorted(set(policy["record_additions"]) - set(EXPECTATION_KINDS)) + check(not undeclared_fields, + f"`record_additions` declares {undeclared_fields}, which no expectation may " + "carry. A record field nothing can hold is a promise to a mapper that the " + "corpus cannot keep.") + check(UNAVAILABLE_FIELD in policy["record_additions"], f"`unavailable_inputs.recorded_as` names {UNAVAILABLE_FIELD!r}, which is not a " "field `record_additions` declares. The suite reads the record through this " @@ -2416,6 +2468,54 @@ def shown(ids_): "excluded by cardinality. A rule lost at one stage, and the two " "answer different questions when the rule is later changed.") + # THE CONVERSE, which nothing asked. The loop below validates a + # DECLARED defeat; nothing ever required one. A boundary whose + # evidence revision B carries, and which applies to an occurrence + # this expectation names, has to be either HONOURED - concluded as + # the outcome it `proves` - or DEFEATED in writing. Deleting + # `boundary_defeated` from `copy-at-one-to-one-is-not-a-branch` left + # the suite green and the case stopped being adversarial: + # `deleted_paths` earns `ended` for that predecessor unless the copy + # record defeats it, and with the record gone it was an ordinary + # copy-continuation wearing the name of a harder case. Review found + # that one; the census here found a second, where the fold's + # `removed_symbols` boundary had never been declared defeated at all. + # + # Derived, not listed: every boundary kind declares `match`, + # `observable_from` and what it `proves`, so which boundaries applied + # to which occurrence is read off the senior contract and the records + # the fixture carries. No applicability is computed. + declared_defeats = set(exp.get("boundary_defeated") or {}) + for bspec in senior["boundary_evidence_kinds"].values(): + _, hit = boundary_hits(bspec, frm, to, by_id, rev_b) + if not hit or outcome == bspec.get("proves"): + continue + check(bspec["value"] in declared_defeats, + f"{where}: {bspec['observable_from']} names " + f"{hit!r}, so {bspec['value']!r} " + f"applies to {hit!r} and proves {bspec.get('proves')!r} - but " + f"this expectation concludes {outcome!r} and records no defeat. " + "Boundary evidence carried and not honoured has to be defeated " + "in writing; silence is the absence-of-record defect this " + "contract exists to forbid.") + + # REVISION-LEVEL, so every decision about the pair records it. + # `unavailable_inputs` means a signal that "could not be EVALUATED at + # all ... for the revision" and is observed from + # `revision_b.unavailable_signals`; `record_additions` says the field + # is "Present whenever any were". The only check was the per-CASE + # obligation, satisfied by whichever expectation happened to carry + # it, so the mirror expectation could drop the record and stay green - + # one stored refusal missing the machine-readable reason its twin + # gives for the same revision pair. + revision_unavailable = set(collect(rev_b, unavailable_from)) + if revision_unavailable: + check(set(exp.get(UNAVAILABLE_FIELD) or []) == revision_unavailable, + f"{where}: {unavailable_from} declares " + f"{sorted(revision_unavailable)} unevaluable for the REVISION, " + f"so every decision about it records them; this one records " + f"{sorted(set(exp.get(UNAVAILABLE_FIELD) or []))}.") + for kind, defeater in (exp.get("boundary_defeated") or {}).items(): spec = next((s for s in senior["boundary_evidence_kinds"].values() if s["value"] == kind), None) @@ -2433,20 +2533,13 @@ def shown(ids_): # fixture could defeat a boundary that never applied to the side it # names. Every occurrence on the declared side is checked, not the # first one. - role, attr = spec["match"].split(".") - subjects = frm if role == "predecessor" else to - check(bool(subjects), - f"{where}: {kind!r} matches on {spec['match']}, and this " - f"expectation names no {role}. There is nothing for the boundary " - "to have applied to, so defeating it proves nothing.") - observed = collect(rev_b, spec["observable_from"]) - for oid in subjects: - wanted = by_id.get(oid, {}).get(attr) - check(wanted in observed, - f"{where}: {kind!r} would not have applied to {oid} anyway - " - f"{spec['observable_from']} holds {sorted(observed)!r}, which " - f"does not name its {attr} {wanted!r}, so defeating it proves " - "nothing") + role, hit = boundary_hits(spec, frm, to, by_id, rev_b) + check(bool(hit), + f"{where}: {kind!r} matches on {spec['match']} and " + f"{spec['observable_from']} holds " + f"{sorted(collect(rev_b, spec['observable_from']))!r}, which names " + f"no {role} this expectation declares. The boundary never applied " + "here, so defeating it proves nothing.") # ---- the case's PREREGISTERED OBLIGATION, not just its answer. ------- # A case can reach the right outcome for the wrong reason: move the losing From c0812ca68e2d23c7d11d20af0526ae1885d69b59 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:26:44 +0000 Subject: [PATCH 58/84] test(identity): three spellings of one guard, and a gate instead of a fourth Codex, two findings on 55ff9d5, both verified live on HEAD. A rule whose `cardinality` is edited into the string "1:1" killed the whole run. The integrity check that reports exactly that fault had already recorded it, and the traceback threw its own diagnosis away along with every other violation the run had left to find. The site Codex named was not the defect. `(x or {})` was the spelling at seventeen reader positions, ten of them reading the CONTRACT, which - unlike the fixtures - has no shape gate. Sweeping that spelling did not fix the reported case: it was written `.get("cardinality", {})`, a second spelling. Fixing that did not fix it either; the next reader was bare `card.get("shape")`, a third. Each sweep left the next spelling behind, which is the argument for a gate rather than a fourth sweep. So the contracts get what the fixtures have had since the container census: a refusal, once, before any reader, with nothing running afterwards on a contract that failed it. `check` accumulates rather than stopping, so reporting a malformed section and carrying on is precisely how the report gets discarded by the traceback it predicted. The gate covers the sections this suite indexes into, their entries, and the entry fields read attribute by attribute - that last level being the one review actually crashed on, since a string `cardinality` sits inside a well-formed rule in a well-formed section. Measured, on the same census both ways: contract mutations went from 115 crashes to 65, and from 6 clean failures to 59. SIXTY-FIVE CRASHES REMAIN, in `case_obligations`, `outcomes`, `evidence_kinds`, `dominance`, `arbitration` and five other sections - structures nested deeper than the gate reaches. That is a measured residue, not a closed class, and it is recorded rather than rounded off. The reader predicates stay: one definition of "is this a mapping", not a truthiness test that dies on the input it is defending against. Second finding: `mutually_exclusive_evidence_kinds` was validated by nothing. Its consumer tests `set(entry["between"]) <= rule_requirements`, so a misspelt member simply fails that subset test for every rule - renaming `path_rename` to `not_a_kind` left BOTH suites green while the frozen exclusion silently stopped excluding. A declaration that cannot be satisfied is indistinguishable, to its consumer, from one that is never violated. Validated in the SENIOR suite, because that is where it is declared; the junior suite restating a senior claim was the defect two commits ago. My first draft of that check read `if k not in outcomes and k not in evidence_kinds` - accepting an OUTCOME name as a member of an evidence exclusion. A value admitted for being in a vocabulary rather than in the right one, written into the check whose whole purpose is to catch that. Corrected before commit, and the probe for it is one of the seven. CodeRabbit returned clean on 55ff9d5 and agrees R-CONT-DRIFT is a policy question the suite should not settle, which is how it is recorded. Fixture census unchanged: 0 crashes, 32 green. No mapper. 20 suites green, normal and -O. --- identity/tests/test_lineage_contract.py | 53 ++++++- identity/tests/test_lineage_decision.py | 190 +++++++++++++++++++----- 2 files changed, 205 insertions(+), 38 deletions(-) diff --git a/identity/tests/test_lineage_contract.py b/identity/tests/test_lineage_contract.py index 71cf63d..2819bf3 100644 --- a/identity/tests/test_lineage_contract.py +++ b/identity/tests/test_lineage_contract.py @@ -189,6 +189,52 @@ def main() -> int: for msg in amendment_ordinal_failures(contract.get("revision_note")): check(False, msg) + # THE EXCLUSION ITSELF, which nothing validated. `mutually_exclusive_evidence_kinds` + # is consumed by the decision suite as `set(entry["between"]) <= rule_needs`, + # and a set built from a misspelt name simply fails that subset test for every + # rule - so renaming a member to `not_a_kind` left BOTH suites green while the + # frozen exclusion silently stopped preventing the combination it was added to + # catch. A declaration that cannot be satisfied is indistinguishable, to its + # consumer, from one that is never violated. + # + # Validated here because this is where it is DECLARED. The junior suite + # restating a senior claim is the defect the previous commit was about. + exclusions = contract.get("mutually_exclusive_evidence_kinds") + check(isinstance(exclusions, list) and bool(exclusions), + f"`mutually_exclusive_evidence_kinds` is {exclusions!r}, not a non-empty list") + seen_pairs = [] + for index, entry in enumerate(exclusions if isinstance(exclusions, list) else []): + where_x = f"mutually_exclusive_evidence_kinds[{index}]" + if not isinstance(entry, dict): + check(False, f"{where_x} is {entry!r}, not an object") + continue + between = entry.get("between") + if not isinstance(between, list): + check(False, f"{where_x}.between is {between!r}, not a list of two kinds") + continue + # `evidence_kinds` ALONE. The first draft of this line read + # `k not in outcomes and k not in evidence_kinds`, which accepts an + # OUTCOME name as a member of an evidence exclusion - a value admitted + # for being in a vocabulary rather than in the right one, which is the + # defect this whole check exists to catch, written into the check. + unknown = [k for k in between if k not in evidence_kinds] + check(not unknown, + f"{where_x}.between names {unknown!r}, which `evidence_kinds` does not " + "declare. The consumer tests `set(between) <= rule_requirements`, and a " + "name no rule can require makes that test vacuous - the exclusion stops " + "excluding and nothing says so.") + check(len(set(between)) == 2, + f"{where_x}.between is {between!r}; an exclusion holds between exactly " + "two DISTINCT kinds. One name repeated excludes a rule from requiring " + "one kind, which is not what this section means.") + check(bool(entry.get("why")), + f"{where_x} declares no `why`; a frozen exclusion with no argument is a " + "rule nobody can check against the vocabulary it constrains.") + seen_pairs.append(frozenset(between)) + check(len(seen_pairs) == len(set(seen_pairs)), + "`mutually_exclusive_evidence_kinds` declares the same pair twice; two " + "entries for one fact is how this contract has drifted before.") + # The load-bearing rule, asserted rather than only written down: absent # evidence yields 'unresolved'. If this ever reads 'new', every "introduced # this revision" metric silently starts measuring the mapper. @@ -406,8 +452,11 @@ def main() -> int: # accept a mapper that does the same. check(bool(required), f"{where}: continued must name the evidence that carries it") - alone_ok = any(contract["evidence_kinds"].get(k, {}).get("sufficient_alone") - for k in required) + alone_ok = any( + (contract["evidence_kinds"].get(k) + if isinstance(contract["evidence_kinds"].get(k), dict) + else {}).get("sufficient_alone") + for k in required) check(alone_ok or len(required) >= floor, f"{where}: continued names {len(required)} evidence kind(s) " f"{required}, and none is sufficient alone; the frozen floor is " diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 2b5ae7a..f01d321 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -190,9 +190,9 @@ def rule_needs(rule) -> set: where a defeatable signal reaches a group rule's profile it also sits in some applicable 1:1 rule's `requires_all` and is caught there first. Same code path, same fix, and only one half has a witness.""" - needs = set(rule.get("requires_all") or []) - profile = rule.get("partner_profile") or {} - return needs | set(profile.get("requires_all") or []) + needs = set(list_or_empty(rule.get("requires_all"))) + profile = mapping_or_empty(rule.get("partner_profile")) + return needs | set(list_or_empty(profile.get("requires_all"))) def mandated_reason(exp, mapping, floor, senior): @@ -212,10 +212,10 @@ def mandated_reason(exp, mapping, floor, senior): on it rather than guessing: a shape nobody has ranked must not be settled by whichever condition was typed first.""" def of(key, surviving_kinds=None): - spec = mapping.get(key) or {} + spec = mapping_or_empty(mapping.get(key)) if "reason" in spec: return spec["reason"] - by = spec.get("reason_by_surviving_kinds") or {} + by = mapping_or_empty(spec.get("reason_by_surviving_kinds")) if surviving_kinds is None: return None return by.get("at_or_above_the_floor" @@ -283,6 +283,27 @@ def collect(rev: dict, dotted: str) -> list: SUBJECT_ROLES = ("predecessor", "successor") +def mapping_or_empty(value) -> dict: + """The value if it IS a mapping, an empty one otherwise - never a crash. + + `(x or {})` was the spelling at seventeen reader sites. It handles `null` and + `{}` and dies on `"1:1"`, which is how a rule whose `cardinality` was edited + into a string killed the whole run: the integrity check that reports exactly + that fault had already recorded it, and the traceback threw the report away + along with every other violation the run had left to find. + + The fixture reader is gated - a malformed fixture is refused before any + consumer sees it - and the CONTRACT reader is not, so ten of those sites were + reading an ungated file. This does not report anything; the checks that own + each field already do. What it guarantees is that they get to finish.""" + return value if isinstance(value, dict) else {} + + +def list_or_empty(value) -> list: + """The list half of the same question, asked the same way.""" + return value if isinstance(value, list) else [] + + def signal_bindings(spec: dict) -> list: """(read path, subject role, subject attribute) for each key the catalog's `matches` names, for the roles a mapping actually has. @@ -305,7 +326,7 @@ def signal_bindings(spec: dict) -> list: field = str(spec.get("observable_from", "")) shape = spec.get("entry_shape") out = [] - for key, subject in (spec.get("matches") or {}).items(): + for key, subject in mapping_or_empty(spec.get("matches")).items(): role, _, attr = str(subject).partition(".") if role not in SUBJECT_ROLES: continue @@ -721,7 +742,7 @@ def malformed_list_failures(where: str, exp: dict, unavailable_field: str) -> li if bad: out.append(f"{where}: decision_detail.{field} holds non-string " f"entries {bad!r}") - for rid_, ids_ in (detail.get("ambiguous_candidates") or {}).items(): + for rid_, ids_ in mapping_or_empty(detail.get("ambiguous_candidates")).items(): if isinstance(ids_, list): bad = [v for v in ids_ if not isinstance(v, str)] if bad: @@ -837,9 +858,9 @@ def floor_spec_failures(policy: dict, senior: dict) -> list: fifth site of this one was a sentence in `reason_mapping`, still describing a bare count after four checker sites had been corrected.""" out = [] - spec = ((policy.get("reason_mapping") or {}) - .get("no_rule_applied_after_a_defeat") or {} - ).get("reason_by_surviving_kinds", {}).get("floor") + spec = mapping_or_empty(mapping_or_empty(mapping_or_empty( + policy.get("reason_mapping")).get("no_rule_applied_after_a_defeat") + ).get("reason_by_surviving_kinds")).get("floor") if not isinstance(spec, dict): return [f"`reason_mapping.no_rule_applied_after_a_defeat` states its floor as " f"{spec!r}. A prose restatement cannot be checked against the senior " @@ -885,7 +906,8 @@ def clears_floor(kinds, senior: dict, floor: int) -> bool: and not asked at the next, in the commit that was itself correcting a contradiction with this same sentence.""" kinds = set(kinds or ()) - if any(senior["evidence_kinds"].get(k, {}).get("sufficient_alone") for k in kinds): + if any(mapping_or_empty(senior["evidence_kinds"].get(k)).get("sufficient_alone") + for k in kinds): return True return len(kinds) >= floor @@ -931,7 +953,8 @@ def evidence_list_failures(where: str, kinds, senior: dict, catalog: dict, out.append(f"{where} names {kind!r}, which is neither a frozen evidence " "kind nor a structural signal") if (forbid_sufficient_alone - and senior["evidence_kinds"].get(kind, {}).get("sufficient_alone")): + and mapping_or_empty(senior["evidence_kinds"].get(kind)) + .get("sufficient_alone")): out.append(f"{where} rests on {kind!r}, which `finding-lineage/v1` calls " "sufficient alone; a profile of one strong signal is not a " "profile") @@ -982,7 +1005,7 @@ def group_only_field_failures(rid: str, rule: dict, field: str) -> list: to be empty, and a mapper reading the contract sees a second way of spelling "no binding".""" out = [] - shape = (rule.get("cardinality") or {}).get("shape") + shape = mapping_or_empty(rule.get("cardinality")).get("shape") is_group = shape != "1:1" declared = field in rule if declared != is_group: @@ -1131,9 +1154,102 @@ def arbitrate(subset, outcomes, edges, refusals): return None +# The contract sections this suite indexes into, and the kind each must be. +# Derived from what is READ, not from a restatement of the contract: every entry +# here is a section some check below walks or subscripts. +POLICY_SECTIONS = { + "rules": "objects", "structural_signals": "objects", "reason_mapping": "objects", + "signal_defeaters": "objects", "arbitration": "object", "dominance": "object", + "record_additions": "object", "record_binding_vocabulary": "object", + "case_obligations": "object", "unavailable_inputs": "object", + "evidence_kind_records": "object", + "deliberately_unresolved_conflicts": "list", "preregistered_cases": "list", +} +# Fields INSIDE an entry that the suite reads attribute by attribute. One level +# deeper than the sections, and the level review actually crashed the suite on: +# `rules.R-CONT-SAME-SITE.cardinality` as the string "1:1" is a well-formed rule +# in a well-formed section, and `card.get("shape")` still dies on it. +ENTRY_MAPPING_FIELDS = { + "rules": ("cardinality", "record_binding", "partner_profile"), + "structural_signals": ("matches",), + "reason_mapping": ("reason_by_surviving_kinds",), + "signal_defeaters": (), +} +SENIOR_SECTIONS = { + "outcomes": "objects", "evidence_kinds": "objects", + "boundary_evidence_kinds": "objects", "limitations": "object", + "mutually_exclusive_evidence_kinds": "list", +} + + +def contract_shape_failures(policy: dict, senior: dict) -> list: + """Every contract section this suite reads is the KIND it is read as - asked + once, before any reader sees it. + + The fixtures have had this gate since the container census; the contracts + never did, and it is the contracts that three consecutive rounds of review + kept crashing the suite on. Editing one rule's `cardinality` into the string + `"1:1"` killed the whole run at `card.get("shape")` - and the integrity check + that reports exactly that fault had already recorded it, so the traceback + threw away its own diagnosis along with every other violation the run had + left to find. + + Three spellings of one guard had to be swept before this was written - + `(x or {})`, `.get(k, {})` and bare indexing - and each sweep left the next + spelling behind, which is the argument for a gate rather than a fourth sweep. + `objects` means a mapping whose VALUES are mappings: `rules` is walked as + `rules[rid]["outcome"]`, so a rule that is a string is the same defect one + level down. + + KIND ONLY, and only for sections that are read. What each section must + CONTAIN is the business of the checks that own it; this exists so that they + run at all.""" + out = [] + for contract, sections, label in ((policy, POLICY_SECTIONS, "finding-lineage-decision/v1"), + (senior, SENIOR_SECTIONS, "finding-lineage/v1")): + for name, kind in sorted(sections.items()): + value = contract.get(name) + if kind == "list": + if not isinstance(value, list): + out.append(f"{label}: `{name}` is {value!r}, not a list. Every " + "reader below walks it.") + continue + if not isinstance(value, dict): + out.append(f"{label}: `{name}` is {value!r}, not an object. Every " + "reader below subscripts it.") + continue + if kind != "objects": + continue + for key in sorted(value): + if not isinstance(value[key], dict): + out.append(f"{label}: `{name}.{key}` is {value[key]!r}, not an " + "object. It is read attribute by attribute, so a " + "reader reaches it before any check can report it.") + continue + for field in ENTRY_MAPPING_FIELDS.get(name, ()): + if field in value[key] and not isinstance(value[key][field], dict): + out.append( + f"{label}: `{name}.{key}.{field}` is " + f"{value[key][field]!r}, not an object. The check that " + "reports this field records the fault and the run then " + "dies reading it, which throws the report away.") + return out + + def main() -> int: senior = load(SENIOR) policy = load(POLICY) + # BEFORE EVERY READER, and nothing runs on a contract that failed it. `check` + # accumulates rather than stopping, so reporting a malformed section and + # carrying on is how the report gets discarded by the traceback it predicted. + shape = contract_shape_failures(policy, senior) + for msg in shape: + check(False, msg) + if shape: + print(f"identity/lineage-decision: FAIL - {len(fails)} check(s) failed") + for msg in fails: + print(f"FAIL: {msg}") + return 1 rules = policy["rules"] ids = sorted(rules) @@ -1215,7 +1331,7 @@ def main() -> int: "declared, not guessed - a checker that knows which branch means which " "entry has an opinion nobody can review.") if key in policy["reason_mapping"]: - want_b = (policy["reason_mapping"][key] or {}).get("reason") + want_b = mapping_or_empty(policy["reason_mapping"][key]).get("reason") check(node.get("reason") == want_b, f"{'.'.join(where_b)} says {node.get('reason')!r} but implements " f"{key!r}, which selects {want_b!r}. One value, stated three times, " @@ -1300,7 +1416,7 @@ def arbitration_leaves(node, trail): "SURVIVING rules, which is what `licensed_by_rule` and `arbitrate` both " "implement.") - conflict_reason = (policy["reason_mapping"].get("conflicting_rules") or {}).get("reason") + conflict_reason = mapping_or_empty(policy["reason_mapping"].get("conflicting_rules")).get("reason") for entry in policy["deliberately_unresolved_conflicts"]: pair = sorted(entry.get("between") or []) check(entry.get("outcome") == "unresolved", @@ -1419,10 +1535,10 @@ def arbitration_leaves(node, trail): # opinion about, unlike the quantifier, which is why this one is # derived where that one is declared. repeated = {"1:N": "successor", "N:1": "predecessor"}.get( - (rules[rid].get("cardinality") or {}).get("shape")) + mapping_or_empty(rules[rid].get("cardinality")).get("shape")) if repeated: check(prof.get("per") == repeated, - f"{rid} is {(rules[rid].get('cardinality') or {}).get('shape')!r}, so " + f"{rid} is {mapping_or_empty(rules[rid].get('cardinality')).get('shape')!r}, so " f"the group is its {repeated}s, but `partner_profile.per` is " f"{prof.get('per')!r} - the side there is exactly one of. A profile " "checked against the singleton says nothing about the partners the " @@ -1443,7 +1559,7 @@ def arbitration_leaves(node, trail): # suite cannot do without becoming a second mapper - see the note on # `rule_coverage_rule`. needs = rule_needs(rules[rid]) - for excl in senior.get("mutually_exclusive_evidence_kinds") or []: + for excl in list_or_empty(senior.get("mutually_exclusive_evidence_kinds")): pair = set(excl.get("between") or []) check(not pair <= needs, f"{rid} requires {sorted(pair)} together, which " @@ -1470,7 +1586,7 @@ def arbitration_leaves(node, trail): by_requirements: dict = {} for rid in ids: key = (outcomes[rid], frozenset(rule_needs(rules[rid])), - rules[rid].get("cardinality", {}).get("shape")) + mapping_or_empty(rules[rid].get("cardinality")).get("shape")) by_requirements.setdefault(key, []).append(rid) for key, group in sorted(by_requirements.items(), key=lambda kv: sorted(kv[1])): check(len(group) == 1, @@ -1561,7 +1677,7 @@ def arbitration_leaves(node, trail): # leaving the rule licensed and its record unchecked. The mappings now live # under `map` and the prose does not, so there is nothing to classify and # every entry is validated. - kind_records = (policy.get("evidence_kind_records") or {}).get("map") or {} + kind_records = mapping_or_empty(policy.get("evidence_kind_records")).get("map") or {} for name, spec in catalog.items(): for field in ("observable_from", "matches", "why"): check(field in spec, f"structural signal {name!r} must state {field!r}") @@ -1621,7 +1737,7 @@ def arbitration_leaves(node, trail): check(isinstance(matches, dict) and matches, f"{where_s} declares no `matches`, so nothing says which occurrence " "the record is about and any record in revision B would do") - for key, subject in (matches or {}).items(): + for key, subject in mapping_or_empty(matches).items(): role, dot, attr = str(subject).partition(".") # Already asked here. What was missing is not the question but the # consequence: `check` accumulates, so the run continued into @@ -1664,7 +1780,7 @@ def arbitration_leaves(node, trail): # section added to fix it. NO_RECORD = "no_structural_record" observation = {k: v for k, v in - (senior.get("evidence_kind_observation") or {}).items() + mapping_or_empty(senior.get("evidence_kind_observation")).items() if isinstance(v, str)} check(set(observation) == set(senior["evidence_kinds"]), "`finding-lineage/v1.evidence_kind_observation` must classify EVERY evidence " @@ -1790,7 +1906,7 @@ def arbitration_leaves(node, trail): check(False, msg) if not isinstance(binding, dict) or not binding: continue - structural = [k for k in rule_.get("requires_all") or [] + structural = [k for k in list_or_empty(rule_.get("requires_all")) if k in policy["structural_signals"]] check(len(structural) == 1, f"{rid} is a group rule requiring {structural!r} structural signals; " @@ -2076,7 +2192,7 @@ def arbitration_leaves(node, trail): "must be accounted for - applicable, not applicable with a reason, or " "unable to choose.") - for rid, reason in (exp.get("not_applicable") or {}).items(): + for rid, reason in mapping_or_empty(exp.get("not_applicable")).items(): check(rid in rules, f"{where}: not_applicable names unknown rule {rid!r}") check(rid not in app, f"{where}: {rid} is in applicable_rules AND not_applicable") @@ -2158,7 +2274,7 @@ def arbitration_leaves(node, trail): for rid in lic: rule_r = rules[rid] binding = rule_r.get("record_binding") - if binding is None and (rule_r.get("cardinality") or {}).get("shape") == "1:1": + if binding is None and mapping_or_empty(rule_r.get("cardinality")).get("shape") == "1:1": # At 1:1 there is exactly one occurrence in each role, so # `every` and `at_least_one` are the SAME condition and the # checker chooses no policy by applying it - see @@ -2167,7 +2283,7 @@ def arbitration_leaves(node, trail): "successor": {"quantifier": "every"}} if not binding: continue - for kind in (rule_r.get("requires_all") or []): + for kind in list_or_empty(rule_r.get("requires_all")): sig = kind if kind in catalog else kind_records.get(kind) cat_s = catalog.get(sig) if not cat_s: @@ -2179,7 +2295,7 @@ def arbitration_leaves(node, trail): "revision B is a record asserted in a note.") pools = {} for role in SUBJECT_ROLES: - spec_r = binding.get(role) or {} + spec_r = mapping_or_empty(binding.get(role)) pool = list(frm if role == "predecessor" else to) if spec_r.get("excluding") == "partners_where_same_path_holds": others = to if role == "predecessor" else frm @@ -2196,7 +2312,7 @@ def carries(entry: dict) -> bool: rel = related_by(entry, pairs, pools["predecessor"], pools["successor"], by_id) for role in SUBJECT_ROLES: - quant = (binding.get(role) or {}).get("quantifier") + quant = mapping_or_empty(binding.get(role)).get("quantifier") reached = {p[0 if role == "predecessor" else 1] for p in rel} if quant == "every" and any(o not in reached for o in pools[role]): @@ -2234,7 +2350,7 @@ def shown(ids_): # copy target, correctly, because they are the other half of the # conflict. for rid in sorted(set(app) - set(lic)): - for kind in (rules[rid].get("requires_all") or []): + for kind in list_or_empty(rules[rid].get("requires_all")): sig = kind if kind in catalog else kind_records.get(kind) cat_s = catalog.get(sig) if not cat_s: @@ -2256,14 +2372,15 @@ def shown(ids_): "any other.") # A defeat must be CARRIED by revision B, exactly as a boundary is. - for signal, source in (exp.get("signals_defeated") or {}).items(): + for signal, source in mapping_or_empty(exp.get("signals_defeated")).items(): spec = policy["signal_defeaters"].get(signal) check(spec is not None, f"{where}: {signal!r} is not a defeatable signal in the policy") if spec is None: continue check(spec["defeated_by_signal"] == source or - catalog.get(spec["defeated_by_signal"], {}).get("observable_from", "") + mapping_or_empty(catalog.get(spec["defeated_by_signal"])) + .get("observable_from", "") .endswith("." + str(source)), f"{where}: {signal!r} is defeated by {spec['defeated_by_signal']!r}, " f"not by {source!r}") @@ -2377,7 +2494,7 @@ def shown(ids_): # - a 1:1 rule listed against a 1:1 shape passed. The arithmetic # lived only in `case_obligations`, which reaches one fixture. if field == "rules_excluded_by_cardinality" and rid in rules: - card = rules[rid].get("cardinality") or {} + card = mapping_or_empty(rules[rid].get("cardinality")) nf, nt = len(frm), len(to) blocked = (card.get("min_successors", 0) > nt or card.get("min_predecessors", 0) > nf) @@ -2426,7 +2543,7 @@ def shown(ids_): bad = repeats_failure(f"{where}: decision_detail.{field}", detail.get(field)) check(not bad, bad or "") - for _rid, _ids in (detail.get("ambiguous_candidates") or {}).items(): + for _rid, _ids in mapping_or_empty(detail.get("ambiguous_candidates")).items(): bad = repeats_failure(f"{where}: ambiguous_candidates[{_rid!r}]", _ids) check(not bad, bad or "") @@ -2438,7 +2555,7 @@ def shown(ids_): "candidates for exactly the rules recorded in " f"rules_without_a_unique_candidate {sorted(blunted_ids)}, got " f"{sorted(cand or {})}") - for rid, ids_ in (cand or {}).items(): + for rid, ids_ in mapping_or_empty(cand).items(): check(rid in rules, f"{where}: ambiguous_candidates names unknown rule {rid!r}") check(isinstance(ids_, list) and len(set(ids_)) >= 2, f"{where}: {rid} is recorded as unable to choose between " @@ -2516,7 +2633,7 @@ def shown(ids_): f"so every decision about it records them; this one records " f"{sorted(set(exp.get(UNAVAILABLE_FIELD) or []))}.") - for kind, defeater in (exp.get("boundary_defeated") or {}).items(): + for kind, defeater in mapping_or_empty(exp.get("boundary_defeated")).items(): spec = next((s for s in senior["boundary_evidence_kinds"].values() if s["value"] == kind), None) check(spec is not None, f"{where}: unknown boundary kind {kind!r}") @@ -2598,7 +2715,8 @@ def shown(ids_): check(rid not in app_s, f"{name}: {rid} is excluded by cardinality and also " "applicable") - card = rules.get(rid, {}).get("cardinality", {}) + card = mapping_or_empty(mapping_or_empty(rules.get(rid)) + .get("cardinality")) nf, nt = len(as_list(exp.get("frm"))), len(as_list(exp.get("to"))) blocked = (card.get("min_successors", 0) > nt or card.get("min_predecessors", 0) > nf) From 212a37fad28b34cf647d732baab06b652d150c59 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:42:16 +0000 Subject: [PATCH 59/84] test(identity): a record cited as a defeat, about somebody else Both bots found the same defect independently, and CodeRabbit re-confirmed it against the current head: a declared `boundary_defeated` entry was checked for being an ALLOWED defeater path and for being NON-EMPTY, and never for being about the occurrence it claims to defeat. A copy of an unrelated file, or a rename of one, satisfied every test here while explaining nothing about the predecessor whose file was deleted - the boundary standing undisturbed while the fixture said otherwise. This is the discipline the rule bindings and the signal defeats already keep - "no SINGLE entry relates this mapping", "a defeat must be carried AND name these occurrences" - and the boundary path was the one place it was never asked. A claim applied where it was named and not asked of the site next door, which is the shape this branch has now repeated more times than any other. The first version of that check read `set(collect(rev_b, defeater))` and died with `TypeError: unhashable` on a record whose value is an object - the ninth time a guard has crashed on the malformed input it exists to examine, written in the same commit that counts the other eight. Caught by re-running the fixture census, which went from 0 crashes to 8; not by reading the line. A non-string record value is a real violation and `entry_shape_failures` reports it by name, so this check now looks at the values that are paths and symbols. Codex, second finding: `arbitration.licensed_by_rule` was a paragraph checked for EXISTENCE. Replacing it with the opposite claim - that `licensed_by` carries every applicable rule, dominated ones included - left the whole suite green, so a mapper reading the contract and this suite reading `arbitrate` could disagree about the provenance every record carries, with nothing to object. Given the same treatment as the floor: machine-readable tokens, swept against the arbitration procedure over every subset rather than asserted once, because the two selectors agree on most subsets and differ precisely where dominance does its work. `all_applicable` is in the vocabulary so that the declared token is load-bearing - naming it makes the sweep fail. Dominance within one outcome is already refused by property 2, so `arbitrate` returning the whole subset on the all-agree branch IS the undominated set there, not a third selector hiding in the code. Fixture census back to 0 crashes and 32 green. Contract census unchanged at 65 crashes - still the measured residue reported last round, still not closed. No mapper. 20 suites green, normal and -O. --- contracts/finding-lineage-decision-v1.json | 28 ++++-- identity/tests/test_lineage_decision.py | 107 +++++++++++++++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 64b1de3..8f69f3d 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -316,13 +316,27 @@ "different thing from arriving at the same answer two sections later." ] }, - "licensed_by_rule": [ - "ONE definition, and the branches above are readings of it: `licensed_by` is", - "the set of SURVIVING rules - the applicable rules that no other applicable", - "rule dominates - and it is empty exactly when the outcome is a refusal.", - "Dominated rules never appear in it. They stay in `applicable_rules`, where", - "the disagreement remains readable without being credited with the answer." - ], + "licensed_by_rule": { + "selects": "undominated_applicable", + "empty_exactly_when": "refusal", + "dominated_rules_remain_in": "applicable_rules", + "why": [ + "ONE definition, and the branches above are readings of it: `licensed_by` is", + "the set of SURVIVING rules - the applicable rules that no other applicable", + "rule dominates - and it is empty exactly when the outcome is a refusal.", + "Dominated rules never appear in it. They stay in `applicable_rules`, where", + "the disagreement remains readable without being credited with the answer.", + "", + "MACHINE-READABLE, because the prose alone was checked for EXISTENCE and", + "nothing else: replacing these lines with the opposite claim - that", + "`licensed_by` carries every applicable rule, dominated ones included -", + "left the whole suite green. A mapper reading the contract and a suite", + "reading the code could then disagree about the provenance a record", + "carries, which is the one thing `licensed_by` exists to make legible.", + "The tokens above are swept against the arbitration procedure over every", + "subset of the rule set, so changing one changes the verdict." + ] + }, "reason_declarations_rule": [ "EVERY branch here that names a `reason` also names the `reason_mapping` entry", "it IMPLEMENTS, and the integrity suite requires the two to agree.", diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index f01d321..6c1c65d 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -1133,6 +1133,77 @@ def dominance_sanity_failures(ids, outcomes, raw_edges) -> list: return out +# What `licensed_by` may be declared to select. Two entries, and the second is +# not decoration: `all_applicable` differs from `undominated_applicable` on +# exactly the subsets where dominance does any work, so the token the contract +# names changes the verdict of the sweep below. +LICENSED_BY_SELECTORS = { + "undominated_applicable": + lambda subset, edges: {r for r in subset + if not any((w, r) in edges for w in subset)}, + "all_applicable": lambda subset, edges: set(subset), +} +LICENSED_BY_EMPTINESS = ("refusal", "never") + + +def licensed_by_rule_failures(policy: dict, results: dict, edges, refusals) -> list: + """The contract's account of `licensed_by`, checked against the procedure. + + It was a paragraph, and the paragraph was checked for EXISTENCE. Replacing it + with the opposite claim - that `licensed_by` carries every applicable rule, + dominated ones included - left the whole suite green, so a mapper reading the + contract and this suite reading `arbitrate` could disagree about the + provenance every record carries, with nothing to object. + + Same shape as the floor: a sentence that states what some code decides, and + binds to it only if it is machine-readable. Swept over every subset rather + than asserted once, because the two selectors agree on most subsets and + differ precisely where dominance does its work. + + Dominance between rules of ONE outcome is already refused by property 2, so + the all-agree branch of `arbitrate` returning the whole subset is the + undominated set on that branch - not a third selector hiding in the code.""" + out = [] + rule = mapping_or_empty(mapping_or_empty(policy.get("arbitration")) + .get("licensed_by_rule")) + selects = rule.get("selects") + if selects not in LICENSED_BY_SELECTORS: + return [f"`arbitration.licensed_by_rule.selects` is {selects!r}; the vocabulary " + f"is {sorted(LICENSED_BY_SELECTORS)}. Prose here was checked for " + "existence and said nothing, which is why it is a token now."] + empty_when = rule.get("empty_exactly_when") + if empty_when not in LICENSED_BY_EMPTINESS: + return [f"`arbitration.licensed_by_rule.empty_exactly_when` is {empty_when!r}; " + f"the vocabulary is {list(LICENSED_BY_EMPTINESS)}."] + if rule.get("dominated_rules_remain_in") != "applicable_rules": + out.append("`arbitration.licensed_by_rule.dominated_rules_remain_in` must be " + "`applicable_rules`; a dominated rule that leaves the record " + "entirely takes the disagreement with it, and the recall set is " + "the union that has to keep it.") + select = LICENSED_BY_SELECTORS[selects] + for subset, result in sorted(results.items(), key=lambda kv: sorted(kv[0])): + if result is None: + continue + outcome_, licensed = result + refused = any(frozenset(pair) <= subset for pair in refusals) + if refused: + if empty_when != "refusal" or licensed: + out.append(f"{sorted(subset)}: the subset exercises a declared refusal " + f"and arbitration licenses {sorted(licensed)!r}. The contract " + f"says `licensed_by` is empty exactly when {empty_when!r}.") + continue + want = select(subset, edges) + if set(licensed) != want: + out.append(f"{sorted(subset)}: arbitration licenses {sorted(licensed)!r}, " + f"but `licensed_by_rule.selects` is {selects!r}, which names " + f"{sorted(want)!r}. The contract and the procedure disagree " + "about which rules the outcome rests on.") + if empty_when == "never" and not licensed: + out.append(f"{sorted(subset)}: licenses nothing while the contract says " + "`licensed_by` is never empty.") + return out[:6] + + def arbitrate(subset, outcomes, edges, refusals): """The contract's procedure, read structurally. Returns (outcome, licensed_by) where licensed_by is the SURVIVOR set - empty on a refusal - or None for 'no @@ -1622,6 +1693,8 @@ def arbitration_leaves(node, trail): results[frozenset(sub)] = arbitrate(sub, outcomes, edges, refusals) check(len(results) == expected, f"swept {len(results)} subsets, expected {expected} for {len(ids)} rules") + for msg in licensed_by_rule_failures(policy, results, edges, refusals): + check(False, msg) undefined = sorted((sorted(s) for s, r in results.items() if r is None), key=len) check(not undefined, f"subsets with no defined result: {undefined[:4]}. The theorem says this " @@ -2657,6 +2730,40 @@ def shown(ids_): f"{sorted(collect(rev_b, spec['observable_from']))!r}, which names " f"no {role} this expectation declares. The boundary never applied " "here, so defeating it proves nothing.") + # AND THE RECORD MUST NAME THE OCCURRENCE IT DEFEATS. Both bots + # found this independently, and they were right: "the record is + # carried" and "the boundary applied" were both checked, and + # nothing ever asked whether the record was about the SAME + # occurrence. A copy of an unrelated file, or a rename of one, is + # an allowed defeater path and a non-empty collection, so it + # satisfied every test here while explaining nothing about the + # predecessor whose file was deleted. + # + # This is the discipline the rule bindings and the signal defeats + # already keep - "no SINGLE entry relates this mapping", "a defeat + # must be carried AND name these occurrences" - and the boundary + # path was the one place it was never asked. A record cited where + # it was named and never asked of the site next to it. + # STRINGS ONLY, and not a `set` of whatever the records hold. + # `collect` returns raw values, so a record whose `from` is an + # object made `set(...)` raise TypeError: unhashable - the ninth + # time on this branch that a guard has died on the malformed + # input it exists to examine, and this one was written in the + # same commit that quotes the other eight. A non-string record + # value is a real violation, and it belongs to + # `entry_shape_failures`, which reports it by name; this check + # compares paths and symbols, so it looks at the values that are + # paths and symbols. + defeating = {v for v in collect(rev_b, defeater) if isinstance(v, str)} + attr = str(spec["match"]).partition(".")[2] + relieved = sorted(oid for oid in hit + if by_id.get(oid, {}).get(attr) in defeating) + check(bool(relieved) or not hit, + f"{where}: {defeater} holds {sorted(defeating)!r}, none of which " + f"names the {attr} of any occurrence {kind!r} applied to " + f"({sorted(by_id.get(o, {}).get(attr) for o in hit)!r}). An allowed " + "record about somebody else is not a defeat; it is the boundary " + "standing undisturbed while the fixture says otherwise.") # ---- the case's PREREGISTERED OBLIGATION, not just its answer. ------- # A case can reach the right outcome for the wrong reason: move the losing From 848515e234f88ae39509516b0084c307fc4a563c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:51:57 +0000 Subject: [PATCH 60/84] test(identity): what runs before the gate, and a contract that never says what it is Codex, two findings on c0812ca, both verified live on HEAD. `UNAVAILABLE_FIELD` is derived at MODULE level, so it runs on import - before `main()`, and therefore before the contract gate added last commit to refuse a malformed contract once and let the diagnosis be printed. Deleting `unavailable_inputs.recorded_as` raised KeyError three hundred lines above the gate and took every other finding of that run with it. A gate cannot protect what runs before it, which is a thing worth knowing about gates, and the gate was one commit old when review found the hole in it. The read is non-crashing now and the gate refuses the contract by name, so nothing downstream ever sees an empty value. It is still the CONTRACT'S name and not a default: an unreadable one comes back empty and the run stops. There is still one source. Verified by importing both suites with each contract replaced by `{}`, `[]`, `42` and `null` - eight cases, no import-time crash - and by running with `{}`, which now reports 14 and 5 failures instead of dying. Second: the policy's own `contract` identifier was checked by nothing. Every one of the twelve FIXTURES is checked for naming this contract, and the contract itself was never asked, so the frozen policy could be renamed or lose its identifier entirely with both suites green while twelve fixtures went on declaring conformance to a document that no longer says what it is. The senior suite has checked this of the senior contract since it was written; the junior one is the adjacent site nobody asked. That pairing has now produced three findings - the cardinality literals, the exclusion validation, and this - which is enough of a pattern to state plainly rather than notice again next round. The decision suite also checks the senior contract it LOADS declares itself `finding-lineage/v1`. `builds_on` names a document; that is the check that the document read IS that one. Eight probes. Fixture census unchanged at 0 crashes and 32 green; contract census 65 crashes to 64 - the residue is untouched by this and still stands. No mapper. 20 suites green, normal and -O. --- identity/tests/test_lineage_decision.py | 53 ++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 6c1c65d..f6efbf3 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -91,8 +91,29 @@ # and this name was a literal at three call sites, so the two could drift apart # with nothing to object - the contract could point a mapper at `signals_defeated` # and every check stayed green. Now there is one source and it is the contract's. -UNAVAILABLE_FIELD = (json.load(open(POLICY, encoding="utf-8")) - ["unavailable_inputs"]["recorded_as"]) +def _recorded_as() -> str: + """The field name the contract reserves for unevaluable signals, read without + a crash. + + This is MODULE level: it runs on import, before `main()` and therefore before + the contract gate that exists to refuse a malformed contract once and let the + diagnosis be printed. Deleting `recorded_as` raised KeyError here, three + hundred lines above the gate, and took every other finding of that run with + it - a gate cannot protect what runs before it, which is a thing worth + knowing about gates. + + It stays the CONTRACT'S name and not a default: a value this cannot read + comes back empty, the gate refuses the contract by name, and the run stops + before anything reads a fixture through it. There is still one source.""" + try: + with open(POLICY, encoding="utf-8") as fh: + value = json.load(fh).get("unavailable_inputs", {}).get("recorded_as") + except (OSError, ValueError, AttributeError): + return "" + return value if isinstance(value, str) else "" + + +UNAVAILABLE_FIELD = _recorded_as() FIXDIR = os.path.join(ROOT, "identity", "fixtures", "lineage-decision") DOC = os.path.join(ROOT, "docs", "finding-lineage-decision.md") @@ -1276,6 +1297,18 @@ def contract_shape_failures(policy: dict, senior: dict) -> list: CONTAIN is the business of the checks that own it; this exists so that they run at all.""" out = [] + recorded_as = mapping_or_empty(policy.get("unavailable_inputs")).get("recorded_as") + if not isinstance(recorded_as, str) or not recorded_as: + out.append( + "finding-lineage-decision/v1: `unavailable_inputs.recorded_as` is " + f"{recorded_as!r}. It names the record field for unevaluable signals, and " + "this suite derives the fixture schema from it AT IMPORT - so a missing " + "one is read before any check can run and takes the whole run with it.") + elif recorded_as != UNAVAILABLE_FIELD: + out.append( + f"finding-lineage-decision/v1: `unavailable_inputs.recorded_as` is " + f"{recorded_as!r}, but the schema was built from {UNAVAILABLE_FIELD!r} at " + "import. The contract changed under the run.") for contract, sections, label in ((policy, POLICY_SECTIONS, "finding-lineage-decision/v1"), (senior, SENIOR_SECTIONS, "finding-lineage/v1")): for name, kind in sorted(sections.items()): @@ -1337,6 +1370,22 @@ def main() -> int: refusals = {frozenset(r) for r in raw_refusals} # ---- 0. The policy is subordinate, enforced from ABOVE. ----------------- + # ITS OWN NAME, not only whose it builds on. Every FIXTURE is checked for + # naming this contract, and the contract itself was never asked - so the + # frozen policy could be renamed or lose its identifier entirely with both + # suites green, and twelve fixtures would go on declaring conformance to a + # document that no longer says what it is. The senior suite has checked this + # of the senior contract from the start; the junior one is the adjacent site + # nobody asked, which is now the third time that pairing has produced a + # finding. + check(policy.get("contract") == "finding-lineage-decision/v1", + f"the policy declares itself {policy.get('contract')!r}; the fixtures and " + "this suite both name `finding-lineage-decision/v1`, and a document that " + "does not say which schema it is cannot be conformed to.") + check(senior.get("contract") == "finding-lineage/v1", + f"the senior contract this policy is read against declares itself " + f"{senior.get('contract')!r}. `builds_on` names a document; this is the " + "check that the document loaded IS that one.") check(policy["builds_on"] == "finding-lineage/v1", f"the policy must declare what it is subordinate to, got {policy.get('builds_on')!r}") check(policy["status"] == "frozen-unimplemented", From 1b09ffc6dd17053a4b104544209098691d0e250d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 04:02:46 +0000 Subject: [PATCH 61/84] contract(lineage): a kind that survives a move, and a stage before the relation The owner decided F and G. This is the semantic correction they authorised, and nothing beyond it. F. `same_rule_message` enters the SENIOR vocabulary: predecessor.rule equals successor.rule and predecessor.message equals successor.message, `pair_property`, `sufficient_alone: false`, path-independent, no normalisation. ONE kind rather than `same_rule` beside `same_message`, because two halves of one comparison would reach the floor of two between them and the floor would quietly become the algorithm again. Both group partner profiles now require it instead of `same_pattern_id`. `finding-pattern/v1` hashes the PATH into that id, so R-BRANCH-COPY - whose own `excluding` clause aims the profile at exactly the successors NOT at the predecessor's path - and R-MERGE-FOLD - whose `merge_record` matches on enclosing symbols and imposes no same-path restriction - both demanded of a partner the one thing the transformation guarantees it cannot have. `pattern_id` is unchanged and `same_pattern_id` stays in the vocabulary: it is the stronger signal where the path did not move, and has simply stopped being asked to prove something it cannot. The prose that caused it is corrected rather than deleted. `partner_profile_rule` said `same_pattern_id and anchored_content: ... which is what survives a transformation that moved the file` - the defect itself, written down. Every occurrence in the corpus now carries a real `rule` and `message`, and its `pattern_id` is recomputed by `finding-pattern/v1` and CHECKED. The twelve fixtures wrote an opaque `pat-A` at two different paths - a value this repository's own identity function cannot produce - and withholding two of the three inputs left the one contract that could falsify them with nothing to read. Three probes: `pat-A` restored, an id teleported across files, a message changed under a kept id. Two witnesses the old corpus could not hold, and the second is written rather than inferred: `a-copy-into-two-new-files-is-a-branch` (three distinct pattern ids, neither successor at the predecessor's path) and `a-fold-across-files-is-still-a-merge` (a predecessor folded in from another file). Assuming the fold was symmetric with the copy is the error I made in round 44 and had to correct publicly in round 45; it is not repeated here. Both license their group rule ALONE, which pins that rule's own requirements. G. The procedure opens at stage 0, ELIGIBILITY. A finding whose `occurrence_id` is null is `unresolved` / `missing-occurrence-id`, settled before any signal is collected, any candidate constructed, any rule matched or anything arbitrated. `occurrence.py` returns None when provenance is absent or the anchor ambiguous and `normalize.py` writes it into the record: ordinary output, not malformed input, frozen in the senior contract since step 0 and reachable by no junior path until now. It is a stage and not a seventh rule because there is no subject to relate - the identity a relation would name is what is missing - and it ABSORBS, which keeps an identity limitation on `unresolved` rather than letting it drift to `ended` or `new`. The refusal is INPUT-LOCAL: it annotates the finding, which is already the subject, and emits no edge. No synthetic id, no ordinal, no anchor hash, no `frm: null` pseudo-reference - each is listed under `eligibility.forbids`. Its cases live in their own corpus, `identity/fixtures/lineage-eligibility/`, both sides, shaped like the input they refuse. The relation schema still requires a string occurrence id and is NOT relaxed: that requirement is what stops an unanswerable edge being preregistered. Its expectation schema declares no `frm` and no `to`, and an undeclared key fails, so `record_shape: input_local` is encoded mechanically rather than asserted. `record_shape`, `precedence`, `is_not_a_rule` and `forbids` are bound to the things they describe - three of them were tokens no check read, which is the defect this branch has found a dozen times, appearing inside the section written to fix another one. Verification: 25 probes, each reproducing the defect it prevents. Two fired on an unrelated check first and were re-run until `rule_licensed_alone` was what bit. Fixture census 3280 mutations, 0 crashes, 44 green - all four known classes. Contract census 67 crashes against 64: the three new ones are two more `case_obligations` entries and one more `evidence_kinds` entry, the two largest buckets of the residue already reported, not a new class; the `eligibility` section itself contributes none. 20 suites green, normal and -O. No mapper. KNOWN AND NOT CLOSED: reverting the profiles to `same_pattern_id` still leaves the suite green. The witnesses make the honest case expressible; they do not mechanically refuse the dishonest one, because binding a rule's requirements to evidence is open question C and remains the owner's. --- contracts/finding-lineage-decision-v1.json | 87 +++++++- contracts/finding-lineage-v1.json | 21 +- docs/finding-lineage-decision.md | 41 +++- ...a-copy-into-two-new-files-is-a-branch.json | 99 +++++++++ .../a-defeat-can-leave-too-few-kinds.json | 16 +- .../a-fold-across-files-is-still-a-merge.json | 99 +++++++++ ...-recorded-rename-is-not-an-unresolved.json | 8 +- ...it-elsewhere-is-still-the-same-defect.json | 8 +- .../blunt-rule-loses-to-uniqueness.json | 12 +- .../copy-at-one-to-one-is-not-a-branch.json | 8 +- ...opy-record-dominates-the-single-match.json | 12 +- ...py-source-that-is-also-a-fold-refuses.json | 16 +- ...defeated-signal-drops-below-the-floor.json | 8 +- ...ery-candidate-blunted-is-an-ambiguity.json | 12 +- ...rge-record-dominates-the-single-match.json | 14 +- ...med-symbol-defeats-structural-context.json | 8 +- .../unavailable-diff-is-not-a-deletion.json | 8 +- ...d-finding-in-revision-a-is-unresolved.json | 36 ++++ ...d-finding-in-revision-b-is-unresolved.json | 36 ++++ identity/tests/test_lineage_decision.py | 191 +++++++++++++++++- 20 files changed, 693 insertions(+), 47 deletions(-) create mode 100644 identity/fixtures/lineage-decision/a-copy-into-two-new-files-is-a-branch.json create mode 100644 identity/fixtures/lineage-decision/a-fold-across-files-is-still-a-merge.json create mode 100644 identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json create mode 100644 identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 8f69f3d..0345c71 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -36,6 +36,14 @@ "never instead of them." ], "decision_procedure": [ + "0. ELIGIBILITY. A finding whose `occurrence_id` is null is not a subject a", + " relation can be stated about, and the answer is settled before any evidence", + " is read:", + " outcome = unresolved", + " reason = lineage-id-unavailable:missing-occurrence-id", + " No signals are collected, no candidates are constructed, no rule is matched", + " and nothing is arbitrated. See `eligibility`.", + "", "1. COLLECT the signals that were actually evaluated. A signal that could not", " be evaluated is not absent - see `unavailable_inputs`.", "2. DEFEAT: remove every signal an explaining record accounts for. A defeated", @@ -61,6 +69,54 @@ "7. RECORD every stage's output, the evidence, `decision_detail`, and the", " provenance." ], + "eligibility": { + "observable_from": "the finding's own `occurrence_id`", + "condition": "occurrence_id is null", + "outcome": "unresolved", + "reason": "lineage-id-unavailable:missing-occurrence-id", + "record_shape": "input_local", + "precedence": "before_every_other_stage", + "is_not_a_rule": true, + "record_shape_rule": "The missing-occurrence-id refusal belongs to the INPUT FINDING ITSELF and is not a relation record. Lineage enrichment annotates the normalized finding, which is already the subject; it does not emit an edge. The exact output field name is frozen in a later schema step - the SEMANTICS are frozen here.", + "forbids": [ + "a synthetic occurrence id minted to make the record referenceable", + "an ordinal or index standing in for identity", + "a hash of the physical anchor standing in for identity", + "`frm: null` or `to: null` used as a pseudo-reference to the subject" + ], + "why": [ + "A NULL OCCURRENCE ID IS NORMAL OUTPUT, NOT MALFORMED INPUT. `identity/occurrence.py`", + "returns `(occurrence_id | None, limitations)` and deliberately returns None when", + "provenance is absent or the physical anchor is ambiguous; `aggregate/normalize.py`", + "writes that None straight into the `normalized-findings/v2` record. A run without", + "producer provenance produces them routinely.", + "", + "`finding-lineage/v1` has frozen the refusal for exactly this since step 0, and", + "this policy had no path to it: the procedure began at COLLECT, no reason_mapping", + "branch emitted it, and no case in either corpus reached it. A mapper receiving", + "such a finding had no defined result at all - the one situation the whole", + "absence-of-record doctrine exists to make impossible.", + "", + "WHY A STAGE AND NOT A SEVENTH RULE. Rules relate a predecessor to a successor.", + "Here there is no subject to relate: the identity the relation would have to name", + "is the thing that is missing. This is a precondition of the relation problem", + "existing, so it is answered before the problem is posed - and it ABSORBS. No", + "later combination of same_path, anchored_content, a rename record or boundary", + "evidence rescues the decision, which is what keeps an identity limitation on", + "`unresolved` where the senior contract put it, rather than letting it drift to", + "`ended` or `new`.", + "", + "WHY THE REFUSAL IS INPUT-LOCAL. A relation record has to reference its subject,", + "and the reference it would use is precisely the id that is absent. Upstream", + "refused to invent an identity; this layer may not restore one with sticky tape.", + "Everything under `forbids` is a way of doing that, and each would silently turn", + "an honest 'we do not know which occurrence this is' into a confident edge." + ], + "preregistered_cases": [ + "an-unidentified-finding-in-revision-a-is-unresolved", + "an-unidentified-finding-in-revision-b-is-unresolved" + ] + }, "order_independence": [ "Nothing in the procedure depends on the order anything was written in.", "Steps 3 and 4 evaluate SETS - of rules, and of the candidate bindings each", @@ -138,10 +194,10 @@ "partner_profile": { "per": "successor", "requires_all": [ - "same_pattern_id", + "same_rule_message", "anchored_content" ], - "why": "what every successor must show ON ITS OWN, across a transformation that changed the path and may have changed the enclosing symbol too" + "why": "what every successor must show ON ITS OWN, across a transformation that changed the path and may have changed the enclosing symbol too - and it may NOT be `same_pattern_id`: `finding-pattern/v1` hashes the path into that id, so the partner this profile is aimed at can never share one with its counterpart. The rule was unsatisfiable on real data until this said `same_rule_message` instead - the analyser saying the same thing about both occurrences, which is what 'the same defect' meant here all along. `record_binding.successor.excluding` makes the point sharpest: it aims this profile at exactly the successors NOT at the predecessor's path." }, "means": "one predecessor, several successors, and a RECORD explaining why there are several", "note": "the copy record is what separates a branch from an ambiguity. Without it, several equally-supported candidates are `unresolved` - see `arbitration.multiplicity`.", @@ -170,10 +226,10 @@ "partner_profile": { "per": "predecessor", "requires_all": [ - "same_pattern_id", + "same_rule_message", "anchored_content" ], - "why": "the merge record relates the old structural sites to the new one; what each predecessor must show on its own is that it is the same defect, not that it is in a symbol the fold has just renamed out of existence" + "why": "the merge record relates the old structural sites to the new one; what each predecessor must show on its own is that it is the same defect, not that it is in a symbol the fold has just renamed out of existence - and it may NOT be `same_pattern_id`: `finding-pattern/v1` hashes the path into that id, so the partner this profile is aimed at can never share one with its counterpart. The rule was unsatisfiable on real data until this said `same_rule_message` instead - the analyser saying the same thing about both occurrences, which is what 'the same defect' meant here all along. A fold is not confined to one file: `merge_record` matches on enclosing symbols and imposes no same-path restriction, so a predecessor folded in from another file is an ordinary case this rule has to license." }, "means": "several predecessors, one successor, and a RECORD explaining the fold", "record_binding": { @@ -422,7 +478,9 @@ "every-candidate-blunted-is-an-ambiguity", "a-defeat-can-leave-too-few-kinds", "a-recorded-rename-is-not-an-unresolved", - "an-edit-elsewhere-is-still-the-same-defect" + "an-edit-elsewhere-is-still-the-same-defect", + "a-copy-into-two-new-files-is-a-branch", + "a-fold-across-files-is-still-a-merge" ], "preregistration_rule": [ "Fixed before the mapper, same as slice 2 step 0. These are about the POLICY", @@ -791,9 +849,16 @@ "cardinal number would be handing the job back.", "", "So each structural rule declares its partner profile EXPLICITLY, by naming the", - "kinds. `same_pattern_id` and `anchored_content`: the partner must be an", - "occurrence of the same pattern saying the same thing, which is what survives a", - "transformation that moved the file, the symbol, or both. The integrity suite", + "kinds. `same_rule_message` and `anchored_content`: the partner must be an", + "occurrence the analyser said the same thing about, showing the same content.", + "", + "It read `same_pattern_id` here until the sixth senior amendment, and the", + "sentence that followed it - `which is what survives a transformation that moved", + "the file` - was the defect itself, written down. It does not survive one:", + "`finding-pattern/v1` hashes the PATH into that id, so a partner reached across", + "a move never shares it. Both group rules were therefore unsatisfiable on real", + "data, and the corpus hid it by writing one opaque id at two paths. The kinds", + "named here now are the ones that actually survive the move. The integrity suite", "checks the profile clears the senior floor and that no kind in it is", "`sufficient_alone` - it does NOT derive the profile, because a derived profile", "would be the cardinal number again with extra steps.", @@ -877,6 +942,12 @@ ], "an-edit-elsewhere-is-still-the-same-defect": [ "rule_licensed_alone" + ], + "a-copy-into-two-new-files-is-a-branch": [ + "rule_licensed_alone" + ], + "a-fold-across-files-is-still-a-merge": [ + "rule_licensed_alone" ] }, "meanings": { diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index 0257d61..2a45f72 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -60,7 +60,21 @@ "kinds that no record witnesses, and any such escape hatch can be pointed at a", "kind that a record DOES witness. Which kinds those are is a fact about this", "vocabulary rather than about any rule, so it is settled here and the junior map", - "is checked against it." + "is checked against it.", + "", + "Amended a SIXTH time by slice 2 step 1, which added 'same_rule_message'. Review", + "found that both group rules were unsatisfiable on real data: they require", + "`same_pattern_id` of every profiled partner, and `finding-pattern/v1` hashes the", + "PATH into that id, so a copy's successor and a cross-file fold's predecessor can", + "never share one. R-BRANCH-COPY's own `excluding` clause makes it worse rather", + "than better - it aims the profile precisely at the partners whose path differs.", + "Twelve fixtures hid the contradiction by writing an opaque `pat-A` at two paths,", + "a value this contract's own identity function cannot produce.", + "The repair is a kind that says what those rules meant - the analyser said the", + "same thing about both occurrences - rather than weakening `pattern_id`, which is", + "deliberately path-bearing and stays exactly as it was. `same_pattern_id` remains", + "in the vocabulary and remains the stronger signal where the path did not change;", + "it has simply stopped being asked to prove something it cannot." ], "outcomes": { "continued": { @@ -143,6 +157,10 @@ "line_drift": { "sufficient_alone": false, "why": "proximity is not identity; case 8 is proximity without anything else" + }, + "same_rule_message": { + "sufficient_alone": false, + "why": "the rule and the message are what the analyser SAID, and they survive a file moving. `same_pattern_id` cannot: `finding-pattern/v1` hashes the path together with the rule and the message, so two occurrences of one defect at two paths have different pattern ids BY CONSTRUCTION. Both group rules required `same_pattern_id` of a partner across exactly such a move, which made them unsatisfiable on real data - a copy changes the path, and a cross-file fold changes it for every predecessor that came from another file. This kind is what those rules meant. It is ONE kind and not `same_rule` beside `same_message`: two halves of one comparison would reach the floor of two between them, and the floor would silently become the algorithm again. Not identity, not a hash, no normalisation - equality of two strings the producer already emitted." } }, "minimum_evidence_kinds_for_continued": 2, @@ -383,6 +401,7 @@ "structural_context": "pair_property", "anchored_content": "pair_property", "line_drift": "pair_property", + "same_rule_message": "pair_property", "why": [ "HOW EACH KIND IS OBSERVED, which decides whether anything in revision B has", "to be produced to support a claim about it.", diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 33e97fa..4330d51 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -159,8 +159,8 @@ Six, deliberately small and deliberately not exhaustive. | `R-CONT-DRIFT` | `continued` | 1:1 | `same_path`, `structural_context`, `line_drift` | | `R-CONT-RENAME` | `continued` | 1:1 | `path_rename`, `structural_context`, `anchored_content` | | `R-CONT-COPY` | `continued` | 1:1 | `copy_record`, `structural_context`, `anchored_content` | -| `R-BRANCH-COPY` | `branched` | 1:N, N>=2 | `copy_record` (group) + per successor: `same_pattern_id`, `anchored_content` | -| `R-MERGE-FOLD` | `merged` | N:1, N>=2 | `merge_record` (group) + per predecessor: `same_pattern_id`, `anchored_content` | +| `R-BRANCH-COPY` | `branched` | 1:N, N>=2 | `copy_record` (group) + per successor: `same_rule_message`, `anchored_content` | +| `R-MERGE-FOLD` | `merged` | N:1, N>=2 | `merge_record` (group) + per predecessor: `same_rule_message`, `anchored_content` | **No rule licenses `ended` or `new`.** Those are earned by boundary evidence, which step 0 already governs; a rule for them here would create a second, @@ -238,6 +238,7 @@ remembered it unprompted. | `insufficient-evidence-combination` | a defeat left the floor cleared, and no declared combination survives | | `ambiguous-candidates` | several partners, and nothing prefers one — a choice with no grounds | | `conflicting-evidence` | one observed structure supporting incompatible conclusions — grounds that argue with each other | +| `missing-occurrence-id` | the finding has no occurrence identity, so there is no subject to relate — settled at stage 0, before any evidence is read | The last two are neighbours and opposites, and the distinction is worth keeping sharp. A draft of this file reported a rule conflict as `ambiguous-candidates`, @@ -292,6 +293,40 @@ third time on this branch that a count restated in prose has drifted from the list beside it. Each is a shape where two answers are available and the policy has to say which, or say neither. +### Stage 0: eligibility, and why it is not a rule + +`identity/occurrence.py` returns `None` for an occurrence id when the producer +run id is missing or the physical anchor is ambiguous, and `aggregate/normalize.py` +writes that `None` into the `normalized-findings/v2` record. It is ordinary +output of a run without producer provenance — not malformed input — and +`finding-lineage/v1` has frozen `missing-occurrence-id` for it since step 0. + +The decision policy had no path to it. The procedure began at COLLECT, no +`reason_mapping` branch emitted it, and the relation matrix could not express it +at all, because a relation record has to name its subject and the name is +exactly what is absent. So the procedure now opens at stage 0: + +> **0. ELIGIBILITY.** A finding whose `occurrence_id` is null is `unresolved` / +> `missing-occurrence-id`. No signals are collected, no candidates are +> constructed, no rule is matched, and nothing is arbitrated. + +Two things are deliberate. It **absorbs**: no later combination of `same_path`, +`anchored_content`, a rename record or boundary evidence rescues the decision, +which keeps an identity limitation on `unresolved` rather than letting it drift +to `ended` or `new`. And the refusal is **input-local** — it annotates the +normalized finding, which is already the subject, instead of emitting an edge. +No synthetic id, no ordinal, no hash of the physical anchor, and no `frm: null` +standing in for a reference. Upstream refused to invent an identity; this layer +does not restore one. + +Its cases live in `identity/fixtures/lineage-eligibility/`, apart from the +relation matrix, and cover both sides: +`an-unidentified-finding-in-revision-a-is-unresolved` (a predecessor that cannot +be named) and `an-unidentified-finding-in-revision-b-is-unresolved` (a +successor). The relation schema still requires a string occurrence id, and is +not relaxed — that requirement is what stops an unanswerable edge being +preregistered. + | # | case | expected | |---|---|---| | 1 | `copy-record-dominates-the-single-match` | `branched` — over a real, applicable 1:1 match | @@ -306,6 +341,8 @@ answers are available and the policy has to say which, or say neither. | 10 | `a-defeat-can-leave-too-few-kinds` | `unresolved` / `insufficient-evidence-kind` — one kind survives against a floor of two | | 11 | `a-recorded-rename-is-not-an-unresolved` | `continued` — the twin of case 6, with the record readable | | 12 | `an-edit-elsewhere-is-still-the-same-defect` | `continued` — the line moved and the text changed | +| 13 | `a-copy-into-two-new-files-is-a-branch` | `branched` — and no successor shares the predecessor's `pattern_id` | +| 14 | `a-fold-across-files-is-still-a-merge` | `merged` — the folded predecessor lives in another file | Cases 1 and 2 carry an extra burden, because a fixture can be right for the wrong reason: move the losing rule out of `applicable_rules` and the answer is diff --git a/identity/fixtures/lineage-decision/a-copy-into-two-new-files-is-a-branch.json b/identity/fixtures/lineage-decision/a-copy-into-two-new-files-is-a-branch.json new file mode 100644 index 0000000..348231d --- /dev/null +++ b/identity/fixtures/lineage-decision/a-copy-into-two-new-files-is-a-branch.json @@ -0,0 +1,99 @@ +{ + "case": "a-copy-into-two-new-files-is-a-branch", + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented", + "title": "A branch whose successors share no pattern id with their predecessor", + "why": "THE CASE THE OLD CORPUS COULD NOT HOLD. `R-BRANCH-COPY` profiles each successor, and its `record_binding.successor.excluding` aims that profile at exactly the successors NOT at the predecessor's path. Until the sixth senior amendment the profile required `same_pattern_id` - which `finding-pattern/v1` computes from the path - so the rule demanded of every partner the one thing a copy guarantees it cannot have. Twelve fixtures hid that by writing one opaque id at two paths. Here the ids are real and they DIFFER: occ-a1 is f-something at Broker/Doc.cs, the two successors are elsewhere and hash to something else. The rule fires anyway, because what actually survives a copy is that the analyser said the same thing about the same content. Neither successor sits at the predecessor's path, so `excluding` removes nobody and the profile is asked of both.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "deleted_paths": [ + "Broker/Doc.cs" + ], + "copies": [ + { + "from": "Broker/Doc.cs", + "to": "Broker/CopyA.cs", + "similarity": 100 + }, + { + "from": "Broker/Doc.cs", + "to": "Broker/CopyB.cs", + "similarity": 100 + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/CopyA.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "00cb4d12cb15cb18", + "enclosing_symbol": "CopyAView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-b2", + "path": "Broker/CopyB.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "7a3209859290f641", + "enclosing_symbol": "CopyBView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "branched", + "frm": "occ-a1", + "to": [ + "occ-b1", + "occ-b2" + ], + "applicable_rules": [ + "R-BRANCH-COPY" + ], + "licensed_by": [ + "R-BRANCH-COPY" + ], + "not_applicable": { + "R-CONT-SAME-SITE": "Broker/Doc.cs is gone and no successor is at it, so same_path never fired", + "R-CONT-DRIFT": "same_path never fired, and line_drift alone carries nothing", + "R-CONT-RENAME": "there is no rename record - the file was copied and then deleted, not moved", + "R-CONT-COPY": "the copy record is present, but structural_context fails: neither CopyAView.Wire nor CopyBView.Wire is DocView.Wire", + "R-MERGE-FOLD": "no merge record, and the shape is 1:N rather than N:1" + }, + "note": "the two successors hash to different pattern ids from occ-a1 and from each other, which is what `finding-pattern/v1` does with three different paths. That is no longer an obstacle: the profile asks for same_rule_message and anchored_content, both of which a copy preserves.", + "boundary_defeated": { + "boundary:containing-file-deleted": "revision_b.copies[].from" + } + } + ], + "forbid": [ + "unresolved because no successor shares the predecessor's pattern_id", + "continued to one successor with the other reported as new", + "ended for occ-a1 - the copy records say where its content went", + "R-CONT-COPY named in applicable_rules or licensed_by", + "a branch licensed by anything other than R-BRANCH-COPY" + ] +} diff --git a/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json b/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json index ea4c324..83c2658 100644 --- a/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json +++ b/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json @@ -1,16 +1,18 @@ { "case": "a-defeat-can-leave-too-few-kinds", "title": "A defeat below the floor, where the shortage really is of kinds", - "why": "The other half of the distinction the senior contract was amended to carry, and the half no case exercised. `defeated-signal-drops-below-the-floor` leaves THREE kinds standing and is therefore about the combination; here the reformat defeats anchored_content and only same_pattern_id survives - one kind against a floor of two. That is `insufficient-evidence-kind` in its literal sense, and without this case the below-floor branch of `reason_mapping` was selected by nothing, so changing which limitation it names left the suite green. A coverage gate over outcomes cannot see this: both cases are `unresolved`.", + "why": "The other half of the distinction the senior contract was amended to carry, and the half no case exercised. `defeated-signal-drops-below-the-floor` leaves THREE kinds standing and is therefore about the combination; here the reformat defeats anchored_content and only same_rule_message survives - one kind against a floor of two. That is `insufficient-evidence-kind` in its literal sense, and without this case the below-floor branch of `reason_mapping` was selected by nothing, so changing which limitation it names left the suite green. A coverage gate over outcomes cannot see this: both cases are `unresolved`. The surviving kind was `same_pattern_id` until the sixth senior amendment, which is exactly the claim review falsified: these two occurrences are at different paths, so `finding-pattern/v1` gives them different ids and the old fixture asserted a collision its own identity function cannot produce.", "revision_a": { "revision": "r1", "occurrences": [ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -25,9 +27,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/Other.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 10, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "0b34b0c7f1c60c08", "enclosing_symbol": "Helper.Init", "anchored_content": "_store.Changed += OnChanged;" } @@ -46,7 +50,7 @@ "anchored_content": "reformatted_paths" }, "evidence_surviving": [ - "same_pattern_id" + "same_rule_message" ], "not_applicable": { "R-CONT-SAME-SITE": "the path differs, and anchored_content was defeated anyway", @@ -71,13 +75,13 @@ "anchored_content": "reformatted_paths" }, "evidence_surviving": [ - "same_pattern_id" + "same_rule_message" ] } ], "forbid": [ "insufficient-evidence-combination as the reason - one surviving kind is below the floor, not a combination nobody declared", - "continued on same_pattern_id alone - one kind, and no kind is sufficient alone", + "continued on same_rule_message alone - one kind, and no kind is sufficient alone", "ended for occ-a1 or new for occ-b1 - neither carries boundary evidence", "anchored_content counted after the reformat defeated it" ], diff --git a/identity/fixtures/lineage-decision/a-fold-across-files-is-still-a-merge.json b/identity/fixtures/lineage-decision/a-fold-across-files-is-still-a-merge.json new file mode 100644 index 0000000..726a3fa --- /dev/null +++ b/identity/fixtures/lineage-decision/a-fold-across-files-is-still-a-merge.json @@ -0,0 +1,99 @@ +{ + "case": "a-fold-across-files-is-still-a-merge", + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented", + "title": "A fold that reaches into another file, and the predecessor that lives there", + "why": "THE MIRROR, AND NOT ASSUMED. The copy rule's defect was reported first and the fold's was denied once on this branch - `R-MERGE-FOLD is not affected, a fold changes the enclosing symbol rather than the path` - which was true of the fixture in front of me and false of the rule. `merge_record` matches on enclosing symbols and imposes NO same-path restriction, so a fold may gather a predecessor out of another file, and that predecessor's pattern id necessarily differs from the successor's. occ-a2 lives in Broker/Legacy.cs and is folded into DocView.Wire in Broker/Doc.cs. Nothing about this case is a copy, so it cannot be inferred from the branch witness; it is written separately because assuming the symmetry is the mistake this case exists to stop.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.WireA", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-a2", + "path": "Broker/Legacy.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 17, + "start_column": null, + "pattern_id": "e49d0c1af9ab08fd", + "enclosing_symbol": "LegacyView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "removed_symbols": [ + "DocView.WireA", + "LegacyView.Wire" + ], + "merged_symbols": [ + { + "from": [ + "DocView.WireA", + "LegacyView.Wire" + ], + "to": "DocView.Wire" + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "merged", + "frm": [ + "occ-a1", + "occ-a2" + ], + "to": [ + "occ-b1" + ], + "applicable_rules": [ + "R-MERGE-FOLD" + ], + "licensed_by": [ + "R-MERGE-FOLD" + ], + "not_applicable": { + "R-CONT-SAME-SITE": "structural_context fails for both: the fold replaced DocView.WireA and LegacyView.Wire with DocView.Wire", + "R-CONT-DRIFT": "structural_context fails for the same reason, and occ-a1 is at the successor's line anyway", + "R-CONT-RENAME": "there is no rename record; Broker/Legacy.cs still exists", + "R-CONT-COPY": "there is no copy record - a fold is not a copy", + "R-BRANCH-COPY": "no copy record, and the shape is N:1 rather than 1:N" + }, + "note": "occ-a2 is in a different FILE from the successor, not merely a different symbol, so its pattern id differs. same_rule_message and anchored_content hold across the fold and carry the profile; structural_context does not and must not be faked into holding. Licensed by R-MERGE-FOLD alone, which is what pins the rule's own requirements rather than letting it ride along with a continued rule.", + "boundary_defeated": { + "boundary:enclosing-site-removed": "revision_b.merged_symbols[].from" + } + } + ], + "forbid": [ + "unresolved because occ-a2 shares no pattern id with the successor", + "occ-a2 dropped from the merge because it lives in another file", + "ended for occ-a2 on the strength of removed_symbols - the fold record says where it went", + "merged from occ-a1 alone, with occ-a2 reported separately", + "a merge licensed by anything other than R-MERGE-FOLD" + ] +} diff --git a/identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json b/identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json index 2c27581..74504ce 100644 --- a/identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json +++ b/identity/fixtures/lineage-decision/a-recorded-rename-is-not-an-unresolved.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Old.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "a50f4f69ffb5c777", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -29,9 +31,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/New.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "6075f0659d0b467c", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json b/identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json index 4db811b..e1bd07b 100644 --- a/identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json +++ b/identity/fixtures/lineage-decision/an-edit-elsewhere-is-still-the-same-defect.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -22,9 +24,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 57, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "if (_store != null) _store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json index efcdbe6..120581b 100644 --- a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json +++ b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -22,18 +24,22 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 44, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" }, { "occurrence_id": "occ-b2", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 51, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnReset;" } diff --git a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json index 3753569..184626b 100644 --- a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json +++ b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -32,9 +34,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/Moved.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "c55059814baef619", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json b/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json index a68b92f..7703386 100644 --- a/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json +++ b/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -29,18 +31,22 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" }, { "occurrence_id": "occ-b2", "path": "Broker/DocCopy.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "2f333e4872fe309f", "enclosing_symbol": "DocCopyView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json b/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json index 50b9f01..9ee05cf 100644 --- a/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json +++ b/identity/fixtures/lineage-decision/copy-source-that-is-also-a-fold-refuses.json @@ -8,18 +8,22 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.WireA", "anchored_content": "_store.Changed += OnChanged;" }, { "occurrence_id": "occ-a2", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 88, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.WireB", "anchored_content": "_store.Changed += OnChanged;" } @@ -47,18 +51,22 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 40, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" }, { "occurrence_id": "occ-b2", "path": "Broker/DocCopy.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 40, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "2f333e4872fe309f", "enclosing_symbol": "DocCopyView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json index ce6006a..8f0c561 100644 --- a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json +++ b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -25,9 +27,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json b/identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json index ab5fee2..fd82bcd 100644 --- a/identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json +++ b/identity/fixtures/lineage-decision/every-candidate-blunted-is-an-ambiguity.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -22,18 +24,22 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 44, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" }, { "occurrence_id": "occ-b2", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 51, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json b/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json index 59c593f..8c899da 100644 --- a/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json +++ b/identity/fixtures/lineage-decision/merge-record-dominates-the-single-match.json @@ -8,18 +8,22 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.WireA", "anchored_content": "_store.Changed += OnChanged;" }, { "occurrence_id": "occ-a2", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 88, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.WireB", "anchored_content": "_store.Changed += OnChanged;" } @@ -43,9 +47,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.WireA", "anchored_content": "_store.Changed += OnChanged;" } @@ -74,7 +80,7 @@ "R-CONT-COPY": "no copy record", "R-BRANCH-COPY": "no copy record, and the shape is N:1" }, - "note": "the partner profile is what makes R-MERGE-FOLD reach occ-a2 at all: same_pattern_id and anchored_content hold across the fold, while structural_context does not and must not be faked into holding. occ-a2 is a predecessor of the merge, NOT an `ended` - removed_symbols says its site is gone and merged_symbols says where it went, and the boundary contract already ranks the second above the first.", + "note": "the partner profile is what makes R-MERGE-FOLD reach occ-a2 at all: same_rule_message and anchored_content hold across the fold, while structural_context does not and must not be faked into holding. occ-a2 is a predecessor of the merge, NOT an `ended` - removed_symbols says its site is gone and merged_symbols says where it went, and the boundary contract already ranks the second above the first.", "boundary_defeated": { "boundary:enclosing-site-removed": "revision_b.merged_symbols[].from" } diff --git a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json index 4a12a62..6d12138 100644 --- a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json +++ b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -28,9 +30,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 47, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json b/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json index 0e8c576..881c06f 100644 --- a/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json +++ b/identity/fixtures/lineage-decision/unavailable-diff-is-not-a-deletion.json @@ -8,9 +8,11 @@ { "occurrence_id": "occ-a1", "path": "Broker/Old.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "a50f4f69ffb5c777", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } @@ -25,9 +27,11 @@ { "occurrence_id": "occ-b1", "path": "Broker/New.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", "start_line": 42, "start_column": null, - "pattern_id": "pat-A", + "pattern_id": "6075f0659d0b467c", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" } diff --git a/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json new file mode 100644 index 0000000..1137b3f --- /dev/null +++ b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json @@ -0,0 +1,36 @@ +{ + "case": "an-unidentified-finding-in-revision-a-is-unresolved", + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented", + "title": "A revision A finding whose occurrence id was never earned", + "why": "`identity/occurrence.py` returns None for the occurrence id when the producer run id is missing, and `aggregate/normalize.py` writes that None into the record. This is ordinary output of a run without producer provenance, not malformed input. The decision layer had no path for it at all: the procedure began at COLLECT, no reason_mapping branch emitted the senior contract's frozen `missing-occurrence-id`, and no case in the relation corpus could express it - the relation schema requires a string id, correctly, because an edge has to name its subject. Stage 0 answers it before the relation problem is posed.", + "revision": "a", + "finding": { + "occurrence_id": null, + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;", + "identity_limitations": [ + "occurrence-id-unavailable:producer-run-id" + ] + }, + "expect": { + "outcome": "unresolved", + "reason": "lineage-id-unavailable:missing-occurrence-id", + "matched_rules": [], + "applicable_rules": [], + "licensed_by": [] + }, + "forbid": [ + "ended - absence of a decidable identity is not evidence the defect was fixed", + "a synthetic occurrence id minted so the finding can be referenced", + "an edge with `frm: null` standing in for the subject", + "any rule matched, applied or licensed before eligibility was settled", + "no-mapping-evidence - the evidence was never collected, not found wanting" + ] +} diff --git a/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json new file mode 100644 index 0000000..75667fb --- /dev/null +++ b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json @@ -0,0 +1,36 @@ +{ + "case": "an-unidentified-finding-in-revision-b-is-unresolved", + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented", + "title": "A revision B finding whose physical anchor is ambiguous", + "why": "The mirror, and written separately rather than inferred: the a-side case is about a predecessor that cannot be named and this one is about a successor, and on this branch assuming that a fix for one side covers the other has already been wrong once. Here the id is null because two findings share a physical anchor, so `occurrence.py` refuses to choose between them. The refusal is the same and it is reached the same way - before any evidence is read - which is the point: stage 0 does not care WHY the identity is missing, only that it is.", + "revision": "b", + "finding": { + "occurrence_id": null, + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;", + "identity_limitations": [ + "occurrence-id-unavailable:ambiguous-physical-anchor" + ] + }, + "expect": { + "outcome": "unresolved", + "reason": "lineage-id-unavailable:missing-occurrence-id", + "matched_rules": [], + "applicable_rules": [], + "licensed_by": [] + }, + "forbid": [ + "new - a finding that cannot be identified is not thereby a newly introduced one", + "an ordinal or an anchor hash standing in for the missing identity", + "an edge with `to: null` standing in for the subject", + "any rule matched, applied or licensed before eligibility was settled", + "ambiguous-candidates - no candidates were ever constructed" + ] +} diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index f6efbf3..8286581 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -83,6 +83,12 @@ ROOT = os.path.dirname(os.path.dirname(HERE)) sys.path.insert(0, ROOT) +# The ONE implementation of `finding-pattern/v1`, imported rather than restated. +# A second copy of `sha1(path + rule + message)` here would be a second identity +# function, and the whole reason this branch needed a sixth senior amendment is +# that nobody was checking fixture ids against the first one. +from identity import pattern as finding_pattern # noqa: E402 + SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") @@ -115,6 +121,12 @@ def _recorded_as() -> str: UNAVAILABLE_FIELD = _recorded_as() FIXDIR = os.path.join(ROOT, "identity", "fixtures", "lineage-decision") +# The eligibility corpus is SEPARATE on purpose. Its cases are not relations - +# they have no predecessor and no successor, because the identity an edge would +# have to name is the thing that is missing. Keeping them out of the relation +# corpus is what stops the relation schema being relaxed to admit a null id, +# which would let someone preregister an edge the policy cannot answer. +ELIGDIR = os.path.join(ROOT, "identity", "fixtures", "lineage-eligibility") DOC = os.path.join(ROOT, "docs", "finding-lineage-decision.md") # Reviewed cost ceiling for the theorem falsifier, in arbitrations. It bounds a @@ -536,6 +548,13 @@ def walk(node): OCCURRENCE_KINDS = { "occurrence_id": "string", "path": "string", "enclosing_symbol": "string", "anchored_content": "string", "pattern_id": "string", + # THE INPUTS TO `pattern_id`, without which it cannot be checked. Their + # absence is what let twelve fixtures write `pat-A` at two different paths - + # a value `finding-pattern/v1` cannot produce - and so hid the fact that both + # group rules were unsatisfiable on real data. `pattern_id` is a pure + # function of these three fields; withholding two of them left the one + # contract that could falsify the corpus with nothing to read. + "rule": "string", "message": "string", "start_line": "integer", "start_column": "integer-or-null", } # EVERY attribute the policy reads off an occurrence, because a MISSING one is @@ -566,6 +585,29 @@ def walk(node): NAMED_REASON_FIELDS = ("not_applicable", "signals_defeated", "boundary_defeated") +# The eligibility corpus's own vocabulary. Same discipline as the relation +# schema: declared keys only, each of its kind, and an undeclared key is a +# failure rather than a field nobody reads. +ELIGIBILITY_KINDS = { + "case": "string", "contract": "string", "status": "string", "title": "string", + "why": "string", "revision": "string", "finding": "object", "expect": "object", + "forbid": "list", +} +ELIGIBILITY_FINDING_KINDS = { + "occurrence_id": "string-or-list-or-null", "path": "string", "rule": "string", + "message": "string", "pattern_id": "string", "enclosing_symbol": "string", + "anchored_content": "string", "start_line": "integer", + "start_column": "integer-or-null", "identity_limitations": "list", +} +# NO `frm` AND NO `to`, and their absence is enforced by this table rather than +# assumed: an undeclared key fails, so a fixture cannot quietly grow the edge +# fields that `eligibility.forbids` rules out. +ELIGIBILITY_EXPECT_KINDS = { + "outcome": "string", "reason": "string", "matched_rules": "list", + "applicable_rules": "list", "licensed_by": "list", +} + + def kind_table_failures(where: str, obj, kinds: dict, required=frozenset()) -> list: """One object against one table: declared keys only, each of its kind, and the ones a reader indexes rather than `.get`s actually there.""" @@ -1254,7 +1296,7 @@ def arbitrate(subset, outcomes, edges, refusals): "signal_defeaters": "objects", "arbitration": "object", "dominance": "object", "record_additions": "object", "record_binding_vocabulary": "object", "case_obligations": "object", "unavailable_inputs": "object", - "evidence_kind_records": "object", + "evidence_kind_records": "object", "eligibility": "object", "deliberately_unresolved_conflicts": "list", "preregistered_cases": "list", } # Fields INSIDE an entry that the suite reads attribute by attribute. One level @@ -1422,6 +1464,12 @@ def main() -> int: emitted.add(policy["arbitration"]["conflict"]["reason"]) emitted.add(policy["arbitration"]["multiplicity"]["several_candidates_without_one"]["reason"]) emitted |= {c["reason"] for c in policy["deliberately_unresolved_conflicts"]} + # STAGE 0 EMITS A REASON TOO. It is not in `reason_mapping` because it is not + # reached by mapping evidence to a refusal - it is settled before any evidence + # is read - but a reason the policy can emit has to be pinned by a case like + # any other, and leaving it out of this set is how `missing-occurrence-id` sat + # frozen in the senior contract with no junior path for the whole of step 1. + emitted.add(mapping_or_empty(policy.get("eligibility")).get("reason")) for reason in sorted(emitted): check(reason in senior_limitations, f"the policy emits {reason!r}, which finding-lineage/v1 does not define. " @@ -2144,6 +2192,29 @@ def arbitration_leaves(node, trail): for msg in entry_shape_failures(f"{name}: {_field}", _spec, rev_b.get(_field.partition(".")[2])): check(False, msg) + # EVERY pattern_id RECOMPUTED, not trusted. `finding-pattern/v1` is + # `sha1(path + rule + message)`, so a fixture's id is checkable and was + # checked by nothing: twelve cases wrote the opaque `pat-A` at two + # different paths, and that single fiction concealed a contract defect + # both reviewers eventually found by reading `identity/pattern.py` + # instead - both group rules required `same_pattern_id` of a partner + # whose path had just changed, which no real corpus can satisfy. + # + # This is not applicability. It computes no evidence kind and decides no + # rule; it asks the corpus to be consistent with the identity function + # this repository already ships, which is the difference between reading + # a contract and having an opinion about one. + for side, rev in (("A", rev_a), ("B", rev_b)): + for occ in rev.get("occurrences", []): + want = finding_pattern.pattern_id(occ["path"], occ["rule"], + occ["message"]) + check(occ["pattern_id"] == want, + f"{name}: revision {side} {occ['occurrence_id']} declares " + f"pattern_id {occ['pattern_id']!r}, but `finding-pattern/v1` " + f"computes {want!r} from its path, rule and message. An id the " + "identity function cannot produce is a fixture asserting a hash " + "collision to make a rule look satisfiable.") + # BEFORE THE SETS. `{o["occurrence_id"] for o in ...}` silently collapses # a repeated id, and so does `by_id`; the raw LIST length then still # satisfied a 1:N minimum, so `to: ["occ-b1", "occ-b1"]` froze a branch @@ -2928,6 +2999,124 @@ def shown(ids_): "case. A conflict the contract deliberately refuses is a decision, and a " "decision with no case is frozen in name only.") + # ---- 3d. THE ELIGIBILITY CORPUS. ---------------------------------------- + # Stage 0 is answered before the relation problem is posed, so its cases live + # in their own corpus and are shaped like the INPUT they refuse rather than + # like an edge. `record_shape: input_local` is the whole point: a relation + # record must reference its subject, and the reference it would use is the id + # that is missing. + elig = mapping_or_empty(policy.get("eligibility")) + # THE STAGE'S OWN DECLARATIONS, BOUND. `record_shape`, `precedence` and + # `forbids` were three tokens no check read - the shape this branch has found + # a dozen times, written into the section that was being added to fix another + # instance of it. Each is bound to the thing it describes rather than merely + # spelled correctly. + check(elig.get("record_shape") == "input_local", + f"`eligibility.record_shape` is {elig.get('record_shape')!r}. The corpus " + "encodes `input_local` mechanically - its expectation schema declares no " + "`frm` and no `to`, and an undeclared key fails - so a different token here " + "would describe a corpus that does not exist.") + check(elig.get("is_not_a_rule") is True, + "`eligibility.is_not_a_rule` must stay true: no rule id names this stage, " + "and `rules` is what arbitration ranges over.") + check(elig.get("precedence") == "before_every_other_stage", + f"`eligibility.precedence` is {elig.get('precedence')!r}") + first = (list_or_empty(policy.get("decision_procedure")) or [""])[0] + check(first.strip().startswith("0. ELIGIBILITY"), + f"`decision_procedure` opens with {first.strip()[:40]!r}. " + "`eligibility.precedence` says this stage runs before every other, and the " + "procedure is where that is either true or prose.") + forbids = list_or_empty(elig.get("forbids")) + check(bool(forbids) and all(isinstance(x, str) and x.strip() for x in forbids), + f"`eligibility.forbids` is {elig.get('forbids')!r}. It is the list of ways a " + "mapper could invent the identity upstream refused to invent; an empty or " + "malformed one permits all of them by saying nothing.") + + elig_cases = list_or_empty(elig.get("preregistered_cases")) + elig_on_disk = sorted(f[:-5] for f in os.listdir(ELIGDIR) if f.endswith(".json")) + check(sorted(elig_cases) == elig_on_disk, + f"`eligibility.preregistered_cases` is {sorted(elig_cases)} and the corpus on " + f"disk is {elig_on_disk}. Same gate the relation matrix uses: a case nobody " + "declared is a case nobody reviewed.") + elig_sides = set() + for cname in sorted(set(elig_cases) & set(elig_on_disk)): + with open(os.path.join(ELIGDIR, f"{cname}.json"), encoding="utf-8") as fh: + ecase = json.load(fh) + where = f"eligibility/{cname}" + bad = kind_table_failures(where, ecase, ELIGIBILITY_KINDS, + frozenset(ELIGIBILITY_KINDS) - {"title"}) + for msg in bad: + check(False, msg) + if bad: + continue + check(ecase["case"] == cname, f"{where}: case field is {ecase['case']!r}") + check(ecase["contract"] == "finding-lineage-decision/v1", + f"{where}: wrong contract {ecase['contract']!r}") + check(ecase["status"] == "preregistered-unimplemented", + f"{where}: stays preregistered until a mapper exists") + check(len(ecase["why"]) > 40, f"{where}: `why` must state what the case defends") + check(ecase["revision"] in ("a", "b"), + f"{where}: revision is {ecase['revision']!r}, not 'a' or 'b'") + elig_sides.add(ecase["revision"]) + + finding = ecase["finding"] + fbad = kind_table_failures(f"{where}.finding", finding, ELIGIBILITY_FINDING_KINDS, + frozenset(ELIGIBILITY_FINDING_KINDS)) + for msg in fbad: + check(False, msg) + if not fbad: + check(finding["occurrence_id"] is None, + f"{where}: the finding declares occurrence_id " + f"{finding['occurrence_id']!r}. This corpus exists for the null case; " + "a case with an identity belongs in the relation matrix.") + want = finding_pattern.pattern_id(finding["path"], finding["rule"], + finding["message"]) + check(finding["pattern_id"] == want, + f"{where}: pattern_id is {finding['pattern_id']!r}, and " + f"`finding-pattern/v1` computes {want!r}. A finding with no " + "occurrence identity still has a PATTERN identity - that is the " + "distinction the two contracts exist to keep.") + check(bool(finding["identity_limitations"]), + f"{where}: the finding records no `identity_limitations`. " + "`occurrence.py` never returns a null id without saying why, and a " + "fixture that omits the reason is not the record the mapper receives.") + + exp = ecase["expect"] + ebad = kind_table_failures(f"{where}.expect", exp, ELIGIBILITY_EXPECT_KINDS, + frozenset(ELIGIBILITY_EXPECT_KINDS)) + for msg in ebad: + check(False, msg) + if ebad: + continue + check(exp["outcome"] == elig.get("outcome"), + f"{where}: concludes {exp['outcome']!r}; `eligibility.outcome` is " + f"{elig.get('outcome')!r}. The stage declares one answer and the corpus " + "does not get a second opinion about it.") + check(exp["reason"] == elig.get("reason"), + f"{where}: names reason {exp['reason']!r}; `eligibility.reason` is " + f"{elig.get('reason')!r}") + check(exp["reason"] in senior_limitations, + f"{where}: {exp['reason']!r} is not a limitation `finding-lineage/v1` " + "declares. The junior layer may not invent one from below.") + # ZERO RULES, ALL THREE STAGES. "No rule applied" is not the claim - the + # claim is that no rule was ever MATCHED, because stage 0 absorbs before + # matching happens. Checking only `licensed_by` would accept a fixture + # that ran the whole procedure and then found nothing, which is a + # different decision reached for a different reason. + for field in ("matched_rules", "applicable_rules", "licensed_by"): + check(exp[field] == [], + f"{where}: {field} is {exp[field]!r}. Stage 0 settles the answer " + "before any evidence is read, so every rule stage is empty - not " + "empty-because-nothing-fired, empty-because-nothing-ran.") + exercised_reasons.add(exp["reason"]) + check(bool(ecase["forbid"]), f"{where}: nothing is forbidden") + + check(elig_sides == {"a", "b"}, + f"the eligibility corpus covers sides {sorted(elig_sides)}. Both are needed: " + "the a-side case is a predecessor that cannot be named and the b-side is a " + "successor, and assuming one side covers the other has already been wrong " + "once on this branch.") + # EVERY EMITTED REASON, not merely every outcome. The outcome gate read # `unresolved` as covered while two of its five reasons - `ambiguous-candidates` # and `insufficient-evidence-kind` - were pinned by no fixture at all. The From 7206ed7e69e47e3bf9bf053799ab0b672083eb5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 04:29:49 +0000 Subject: [PATCH 62/84] contract(lineage): prohibitions as tokens, and limitations the producer emits `eligibility.forbids` was four sentences no check read. Replacing all four with `["anything", "else"]` left the eligibility corpus and the integrity suite reporting OK - the defect this branch keeps finding, sitting inside the section added to close another instance of it. It is now an object of prohibition tokens, each carrying the fields that would embody it: `synthetic_occurrence_id` -> `occurrence_id`; `ordinal_standing_in_for_identity` -> `ordinal`/`index`/`position`; `anchor_hash_standing_in_for_identity` -> `anchor_hash`/`physical_anchor`/ `anchor_id`; `null_endpoint_as_pseudo_reference` -> `frm`/`to`. The declared set and the set the suite can enforce must be EQUAL in both directions, and each token's fields are held out of the eligibility expectation schema and out of every fixture's `expect`. Two adjacent sites, found by pointing the fixture census at the corpus this branch added rather than only at the relation matrix: - `identity_limitations` was checked with `bool()`. A case could say "there is a reason" without one. The suite now reads the vocabulary off `occurrence.py`'s `LIMIT_*` constants instead of restating it, and requires at least one limitation under the prefix for which `resolve()` actually returns a null id - `physical-anchor-missing:start-column` alone does not, so a fixture carrying only that would trigger stage 0 with nothing. - the eligibility corpus checked `forbid` for non-emptiness only, while `fixture_shape_failures` checks the relation corpus element by element. Same question, asked of both. The first draft of the limitation check crashed on an unhashable element - `x in frozenset(...)` - which is the tenth guard on this branch to die on the input it exists to report, and the second in this one section. Caught by the census, not by review. Verified: 13 mutation probes, each biting with the message it should. Fixture census 3280 mutations / 0 crashes / 44 known-green; eligibility census 172 / 0 / 2 (both a legal integer for `start_column`); contract census unchanged at 67 crash sites, the scope deliberately still held back. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 13 +-- docs/finding-lineage-decision.md | 19 ++++ identity/tests/test_lineage_decision.py | 118 +++++++++++++++++++-- 3 files changed, 138 insertions(+), 12 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 0345c71..0754a7c 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -78,12 +78,13 @@ "precedence": "before_every_other_stage", "is_not_a_rule": true, "record_shape_rule": "The missing-occurrence-id refusal belongs to the INPUT FINDING ITSELF and is not a relation record. Lineage enrichment annotates the normalized finding, which is already the subject; it does not emit an edge. The exact output field name is frozen in a later schema step - the SEMANTICS are frozen here.", - "forbids": [ - "a synthetic occurrence id minted to make the record referenceable", - "an ordinal or index standing in for identity", - "a hash of the physical anchor standing in for identity", - "`frm: null` or `to: null` used as a pseudo-reference to the subject" - ], + "forbids": { + "synthetic_occurrence_id": "a synthetic occurrence id minted to make the record referenceable", + "ordinal_standing_in_for_identity": "an ordinal or index standing in for identity", + "anchor_hash_standing_in_for_identity": "a hash of the physical anchor standing in for identity", + "null_endpoint_as_pseudo_reference": "`frm: null` or `to: null` used as a pseudo-reference to the subject" + }, + "forbids_rule": "Each key is a PROHIBITION TOKEN the integrity suite carries a field vocabulary for, and each token's fields are held out of the eligibility expectation schema and out of every eligibility fixture's `expect`. The list this replaced was four sentences no check read: swapping them for `[\"anything\", \"else\"]` left the corpus and the suite green, which is the same defect - a claim applied where it was named and never asked of the adjacent site - written into the section added to fix another instance of it. The token set and the suite's enforceable set must be EQUAL: a token the suite cannot enforce is prose again, and a prohibition the suite enforces but the contract stopped declaring is an enforcement nobody agreed to.", "why": [ "A NULL OCCURRENCE ID IS NORMAL OUTPUT, NOT MALFORMED INPUT. `identity/occurrence.py`", "returns `(occurrence_id | None, limitations)` and deliberately returns None when", diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 4330d51..5badca2 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -319,6 +319,17 @@ No synthetic id, no ordinal, no hash of the physical anchor, and no `frm: null` standing in for a reference. Upstream refused to invent an identity; this layer does not restore one. +Those four are `eligibility.forbids`, and they are **tokens, not sentences**. +Each names a set of output fields — `occurrence_id`; `ordinal` / `index` / +`position`; `anchor_hash` / `physical_anchor` / `anchor_id`; `frm` / `to` — that +the integrity suite holds out of the eligibility expectation schema and out of +every fixture's `expect`, and the declared set and the enforceable set must be +equal in both directions. They were four sentences first, and nothing read them: +replacing all four with `["anything", "else"]` left the corpus and the suite +green. That is the defect this whole document keeps describing — a claim applied +where it was named and never asked of the adjacent site — appearing inside the +section written to close another instance of it. + Its cases live in `identity/fixtures/lineage-eligibility/`, apart from the relation matrix, and cover both sides: `an-unidentified-finding-in-revision-a-is-unresolved` (a predecessor that cannot @@ -327,6 +338,14 @@ successor). The relation schema still requires a string occurrence id, and is not relaxed — that requirement is what stops an unanswerable edge being preregistered. +Each case's null id has to come with a reason `identity/occurrence.py` actually +emits, and a reason of the kind that produces a null id: the suite reads the +vocabulary off that module's `LIMIT_*` constants rather than restating it, and +requires at least one limitation under the `occurrence-id-unavailable:` prefix, +since `physical-anchor-missing:start-column` alone does not block one. A fixture +that paired a null id with reasons that would not have produced it would be +triggering stage 0 with nothing. + | # | case | expected | |---|---|---| | 1 | `copy-record-dominates-the-single-match` | `branched` — over a real, applicable 1:1 match | diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 8286581..15ddc03 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -88,6 +88,19 @@ # function, and the whole reason this branch needed a sixth senior amendment is # that nobody was checking fixture ids against the first one. from identity import pattern as finding_pattern # noqa: E402 +# ...and the ONE producer of identity limitations, for the same reason. The +# eligibility corpus asserts that a null occurrence id came with a reason +# `occurrence.py` actually emits; a hand-copied list of tokens here would be a +# second vocabulary, and the fixture would then be checked against the copy. +from identity import occurrence as finding_occurrence # noqa: E402 + +LIMITATION_VOCABULARY = frozenset( + v for k, v in vars(finding_occurrence).items() + if k.startswith("LIMIT_") and isinstance(v, str)) +# `resolve()` returns a null id for exactly the limitations under this prefix - +# `physical-anchor-missing:start-column` does not block an id. The prefix is +# taken from a named constant rather than spelled again here. +BLOCKING_LIMITATION = finding_occurrence.LIMIT_NO_RUN_ID.split(":")[0] + ":" SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") @@ -606,6 +619,28 @@ def walk(node): "outcome": "string", "reason": "string", "matched_rules": "list", "applicable_rules": "list", "licensed_by": "list", } +# THE PROHIBITION VOCABULARY. `eligibility.forbids` names the ways a mapper +# could mint the identity `occurrence.py` refused to mint. Each token maps to +# the OUTPUT FIELDS that would embody it, and that mapping is what makes the +# prohibition checkable: the fields are held out of the expectation schema +# above and out of every eligibility fixture's `expect`. +# +# The declaration this replaced was four sentences of prose. Nothing read them: +# swapping all four for `["anything", "else"]` left the eligibility corpus and +# this suite green - a check covering part of what its declaration claims, which +# is the defect shape this branch has now found more than a dozen times, sitting +# inside the section that was added to close another instance of it. +# +# `occurrence_id` is forbidden in `expect` and REQUIRED in `finding`: the stage +# refuses because the input has no identity, and the prohibition is on the +# mapper answering with one it made up, not on the input stating its absence. +ELIGIBILITY_FORBIDDEN_FIELDS = { + "synthetic_occurrence_id": ("occurrence_id",), + "ordinal_standing_in_for_identity": ("ordinal", "index", "position"), + "anchor_hash_standing_in_for_identity": ("anchor_hash", "physical_anchor", + "anchor_id"), + "null_endpoint_as_pseudo_reference": ("frm", "to"), +} def kind_table_failures(where: str, obj, kinds: dict, required=frozenset()) -> list: @@ -3026,11 +3061,39 @@ def shown(ids_): f"`decision_procedure` opens with {first.strip()[:40]!r}. " "`eligibility.precedence` says this stage runs before every other, and the " "procedure is where that is either true or prose.") - forbids = list_or_empty(elig.get("forbids")) - check(bool(forbids) and all(isinstance(x, str) and x.strip() for x in forbids), - f"`eligibility.forbids` is {elig.get('forbids')!r}. It is the list of ways a " - "mapper could invent the identity upstream refused to invent; an empty or " - "malformed one permits all of them by saying nothing.") + # `forbids`, BOUND TO FIELDS RATHER THAN READ AS PROSE. Each declared token + # is one the suite carries a field vocabulary for, and the two sets must be + # EQUAL in both directions: a token with no vocabulary is a sentence nothing + # reads, and a vocabulary the contract stopped declaring is an enforcement + # nobody agreed to. + forbids = mapping_or_empty(elig.get("forbids")) + check(isinstance(elig.get("forbids"), dict), + f"`eligibility.forbids` is {type(elig.get('forbids')).__name__}, not an " + "object of prohibition-token -> why. It was a list of sentences, and a " + "list of sentences is what let all four be replaced by `['anything', " + "'else']` with this suite still reporting OK.") + check(sorted(forbids) == sorted(ELIGIBILITY_FORBIDDEN_FIELDS), + f"`eligibility.forbids` declares {sorted(forbids)} and the suite can " + f"enforce {sorted(ELIGIBILITY_FORBIDDEN_FIELDS)}. Undeclared-but-enforced " + "is a rule nobody wrote down; declared-but-unenforceable is the prose this " + "check exists to stop being.") + check(isinstance(elig.get("forbids_rule"), str) + and len(elig.get("forbids_rule", "").strip()) > 80, + "`eligibility.forbids_rule` must state what the tokens are and why they " + "are tokens; without it the next reader sees four identifiers and no " + "reason they are not sentences again.") + for token in sorted(set(forbids) & set(ELIGIBILITY_FORBIDDEN_FIELDS)): + why = forbids[token] + check(isinstance(why, str) and len(why.strip()) > 20, + f"`eligibility.forbids.{token}` is {why!r}. The token is what the " + "suite enforces; the value is what a human is owed for it.") + for field in ELIGIBILITY_FORBIDDEN_FIELDS[token]: + check(field not in ELIGIBILITY_EXPECT_KINDS, + f"`eligibility.forbids` declares {token!r}, and the eligibility " + f"expectation schema declares {field!r}. The schema is what a " + "fixture is allowed to say; a prohibition its own schema admits is " + "not a prohibition.") + elig_forbidden = {f: t for t, fs in ELIGIBILITY_FORBIDDEN_FIELDS.items() for f in fs} elig_cases = list_or_empty(elig.get("preregistered_cases")) elig_on_disk = sorted(f[:-5] for f in os.listdir(ELIGDIR) if f.endswith(".json")) @@ -3076,10 +3139,33 @@ def shown(ids_): f"`finding-pattern/v1` computes {want!r}. A finding with no " "occurrence identity still has a PATTERN identity - that is the " "distinction the two contracts exist to keep.") - check(bool(finding["identity_limitations"]), + limits = list_or_empty(finding["identity_limitations"]) + check(bool(limits), f"{where}: the finding records no `identity_limitations`. " "`occurrence.py` never returns a null id without saying why, and a " "fixture that omits the reason is not the record the mapper receives.") + # THE REASON, AND THE RIGHT KIND OF REASON. `bool()` on the list was + # the whole check, so `[{}]` and `[42]` passed it - a fixture saying + # "there is a reason" without one. Same shape as `forbids` before it + # became tokens, one field away. + for index, limit in enumerate(limits): + # `isinstance` FIRST. `limit in LIMITATION_VOCABULARY` raises + # TypeError on an unhashable element - so the check written to + # report a malformed limitation died on one, which is the tenth + # time on this branch that a guard has crashed on the input it + # exists to examine, and the second time in this one section. + check(isinstance(limit, str) and limit in LIMITATION_VOCABULARY, + f"{where}: identity_limitations[{index}] is {limit!r}, which " + "`occurrence.py` does not emit. The fixture stands in for a " + "record that module produced; a token it never produces " + "describes an input the mapper will not receive.") + check(any(isinstance(x, str) and x.startswith(BLOCKING_LIMITATION) + for x in limits), + f"{where}: identity_limitations is {limits!r} and none of them " + f"starts with {BLOCKING_LIMITATION!r}. Those are the only ones for " + "which `resolve()` returns a null id - so this fixture pairs a null " + "id with reasons that would not have produced one, and stage 0 is " + "then triggered by nothing.") exp = ecase["expect"] ebad = kind_table_failures(f"{where}.expect", exp, ELIGIBILITY_EXPECT_KINDS, @@ -3108,8 +3194,28 @@ def shown(ids_): f"{where}: {field} is {exp[field]!r}. Stage 0 settles the answer " "before any evidence is read, so every rule stage is empty - not " "empty-because-nothing-fired, empty-because-nothing-ran.") + # THE SAME PROHIBITION, ASKED OF THE FIXTURE. The kind table above + # rejects undeclared keys, so this is a second reading of one fact - and + # deliberately: the table can grow a field, and if it ever does, the + # failure a reader gets should name the prohibition rather than report a + # key nobody declared. + for field in sorted(set(exp) & set(elig_forbidden)): + check(False, + f"{where}.expect carries {field!r}, which `eligibility.forbids` " + f"rules out as {elig_forbidden[field]!r}. Stage 0 refuses BECAUSE " + "the identity is absent; an expectation that names one has answered " + "with the invention upstream declined to make.") exercised_reasons.add(exp["reason"]) + # THE SAME QUESTION THE RELATION CORPUS ASKS OF ITS OWN `forbid`, which + # `fixture_shape_failures` answers element by element. Here it was + # `bool()` alone: a list of five objects satisfied it, and `forbid` + # states in words what the case must not conclude. check(bool(ecase["forbid"]), f"{where}: nothing is forbidden") + for index, forbidden in enumerate(list_or_empty(ecase["forbid"])): + check(isinstance(forbidden, str) and forbidden.strip(), + f"{where}.forbid[{index}] is {forbidden!r}. `forbid` states in " + "words what the case must NOT conclude; a container states " + "nothing.") check(elig_sides == {"a", "b"}, f"the eligibility corpus covers sides {sorted(elig_sides)}. Both are needed: " From b0d7f5504ab25e152a82d2156e1815b2fdb44cb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 04:52:13 +0000 Subject: [PATCH 63/84] contract(lineage): every off-path successor needs a record, and how records combine Codex found a real defect and I reproduced it: deleting the `Broker/CopyB.cs` copy record from `a-copy-into-two-new-files-is-a-branch` left the suite green and the branch licensed over BOTH successors. The unexplained one was absorbed on its partner profile alone - `same_rule_message` and `anchored_content`, which any unrelated occurrence of that defect anywhere in the tree also shows. The rule's own note says the copy record is what separates a branch from an ambiguity; under `at_least_one` a successor with no record was separated from an ambiguity by nothing. R-BRANCH-COPY's successor quantifier is now `every`, over the pool `excluding` leaves. That is not the reading the old justification rejected - it rejected demanding every successor INCLUDING the one at the predecessor's own path, which `excluding` had already removed by the time the sentence was written. The exclusion arrived and the quantifier was never asked again. WHICH FORCED A SECOND AXIS. `every` over two successors is unsatisfiable under the old single-entry reading, because git writes one copy record per destination. A fold is the opposite: one record names all of its sources, and two records naming one each describe two folds. That difference was a property of whichever rule the checker was written for; it is now declared as `record_binding.coverage`, required of every rule that binds a record, rejected if it is a word the vocabulary does not carry, and with no default in the checker - `union_of_entries` on the copy, `single_entry` on the fold. The fold's value was probed on its own rather than assumed by symmetry: a record naming one of two sources, and a split into two records, are both rejected, and both pass once the fold is weakened to `union_of_entries`. `record_binding_vocabulary.why` claimed this corpus could not distinguish the quantifiers. That was true of the twelve fixtures then present and stopped being true in the same change that added the two-successor branch - a reasoned-for exemption outliving its reason. It is corrected rather than deleted, and the asymmetry in how the two coverage values are pinned is stated: the copy's by a valid preregistered case, the fold's only by being the strictly stronger reading, because the input that would distinguish it is an invalid mapping no valid case can express. Eleventh and twelfth guards to die on the input they exist to examine, both mine, both from this change: `token in vocab["quantifier"]` raises on a scalar axis, so all three token checks crashed on a malformed vocabulary; and the coverage check guarded its predicate but not its message, evaluating `sorted(vocab['coverage'])` eagerly and crashing while REPORTING the fault it had just detected correctly. A sub-form the previous ten did not teach me to look for. Net effect on the held- back scope is a reduction: contract census 67 -> 65 crash sites. Verified: 8 probes re-run against the final code, each biting with the message it should, plus a positive control showing the old quantifier still admits Codex's deletion. Fixture census 3280/0/44 and eligibility census 172/0/2, both unchanged. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 104 +++++++++++++++------ docs/finding-lineage-decision.md | 42 +++++++++ identity/tests/test_lineage_decision.py | 89 +++++++++++++++--- 3 files changed, 192 insertions(+), 43 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 0754a7c..6040efb 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -207,12 +207,26 @@ "quantifier": "every" }, "successor": { - "quantifier": "at_least_one", + "quantifier": "every", "excluding": "partners_where_same_path_holds" }, "why": [ - "The copy record relates the predecessor's path to the path of a successor that is NOT at the predecessor's path - that successor is the one the record explains. The other successor is where the occurrence already was, and it is licensed by a continued rule, not by this record. Demanding EVERY successor appear in `copies[].to` would therefore reject the very shape `copy-record-dominates-the-single-match` was built to preregister." - ] + "EVERY successor the exclusion leaves, and the union of the copy records is what", + "has to cover them. `excluding` removes the partners at the predecessor's own", + "path - those are where the occurrence already was, and a continued rule reaches", + "them, which is why `copy-record-dominates-the-single-match` is not rejected by", + "the strong quantifier. What remains is exactly the set the records are supposed", + "to explain, and each of them has to be explained: a successor at a new path that", + "no copy record names is not a branch of this defect, it is an occurrence that", + "happens to look like it.", + "", + "This said `at_least_one` until `a-copy-into-two-new-files-is-a-branch` showed", + "what that licensed - see `record_binding_vocabulary.why`. The coverage axis is", + "what makes the strong quantifier satisfiable: git writes one copy record per", + "destination, so the branch is described by the records together and by no one of", + "them alone." + ], + "coverage": "union_of_entries" } }, "R-MERGE-FOLD": { @@ -241,8 +255,19 @@ "quantifier": "every" }, "why": [ - "A fold names all of its sources: the record's `from` must carry every predecessor's enclosing symbol, and its `to` must be the successor's. A merge that named only some of the predecessors would be claiming a fold it cannot see, and the quantifier is what makes that different from the copy case rather than a matter of taste." - ] + "A fold names all of its sources: the record's `from` must carry every", + "predecessor's enclosing symbol, and its `to` must be the successor's. A merge", + "that named only some of the predecessors would be claiming a fold it cannot see,", + "and the quantifier is what makes that different from the copy case rather than a", + "matter of taste.", + "", + "`coverage: single_entry` is the other half, and it is NOT inherited from the", + "copy rule by symmetry - it was probed on its own. Two records naming one source", + "each describe two folds into the same successor, not the one transformation this", + "mapping claims; both that split and a record naming a single source are rejected", + "by `a-fold-across-files-is-still-a-merge`." + ], + "coverage": "single_entry" } } }, @@ -1050,29 +1075,38 @@ "the roles the signal's own `matches` names, and the suite rejects a binding", "whose roles disagree with the catalog.", "", - "The quantifiers differ between the two group rules and the difference is a", - "POLICY CHOICE - see each rule's `why`. An earlier revision of this paragraph", - "claimed that swapping them 'makes both rules wrong in opposite directions, and", - "the suite fails on both'. That was false in both directions and is corrected", - "here rather than quietly deleted, because the reason it was false is worth", - "keeping:", + "THE QUANTIFIERS ARE NO LONGER UNPINNED, AND THE REASON THEY WERE IS NOW FALSE.", + "An earlier revision of this paragraph held that the copy rule's quantifier was", + "a policy choice this corpus could not decide, because `excluding` narrowed the", + "successor pool to exactly one occurrence and `every` over one element is", + "`at_least_one` over one element. That was true of the twelve fixtures then in", + "the corpus. It stopped being true in the same change that added", + "`a-copy-into-two-new-files-is-a-branch`, where NEITHER successor sits at the", + "predecessor's path, `excluding` removes nobody, and the pool holds two. The", + "paragraph was left standing across that change - a reasoned-for exemption", + "outliving its reason, which is the defect this document keeps describing.", + "", + "On that fixture the two values are distinguishable and the weaker one is", + "wrong: with `at_least_one`, deleting the `Broker/CopyB.cs` copy record leaves", + "the branch licensed over BOTH successors, so the second successor is absorbed", + "on the strength of its partner profile alone - the same rule, message and", + "anchored content that any unrelated occurrence of that defect anywhere in the", + "tree would also show. The rule's own `note` says the copy record is what", + "separates a branch from an ambiguity; under `at_least_one` a successor with no", + "record was separated from an ambiguity by nothing.", "", - " - On the copy rule, `excluding` narrows the successor pool to exactly the", - " partner the record explains - one occurrence - and `every` over one element", - " is `at_least_one` over one element. The two declarations are", - " indistinguishable on this corpus by construction.", - " - On the fold, `every` is strictly stronger than `at_least_one`, and every", - " fixture satisfies the stronger one, so weakening it cannot fail. A fixture", - " that WOULD detect the weakening is a fold record naming only some of its", - " predecessors - which is an invalid mapping, and so cannot be preregistered", - " as a valid case.", + "So the copy rule now says `every`, over the pool `excluding` leaves. That is", + "not the reading the old `why` rejected: what it rejected was demanding every", + "successor INCLUDING the one at the predecessor's own path, which `excluding`", + "had already removed by the time this was written. The exclusion arrived and the", + "quantifier was never asked again.", "", - "So these two values are declared and not pinned. The suite checks that each is", - "a member of the vocabulary and that the roles match the catalog; it does not,", - "and on this corpus cannot, check that the RIGHT member was chosen. Whether that", - "should be closed - and if so, by a fixture the matrix does not currently admit,", - "or by the checker asserting the value - is an open question for the owner, not", - "something to settle here." + "WHICH FORCED THE SECOND AXIS. `every` over a two-successor pool is unsatisfiable", + "under the old single-entry reading, because git emits ONE COPY RECORD PER", + "DESTINATION: no single entry of `revision_b.copies` can name both. A fold is the", + "opposite - one record names all of its sources, and two records naming one each", + "describe two folds. `coverage` is where that difference is now declared instead", + "of being a property of whichever rule the checker happened to be written for." ], "quantifier": { "every": "the record must name EVERY occurrence in that role for this mapping", @@ -1082,11 +1116,21 @@ "partners_where_same_path_holds": "partners at the predecessor's own path are not what the copy record explains; `same_path` holds of them, so a continued rule reaches them and this record does not have to" }, "rule": [ - "A quantifier or exclusion not listed here is REJECTED rather than ignored.", - "A binding vocabulary that silently accepts unknown tokens is a binding that stops", - "binding the moment someone invents a word." + "A quantifier, exclusion or coverage not listed here is REJECTED rather than", + "ignored. A binding vocabulary that silently accepts unknown tokens is a binding", + "that stops binding the moment someone invents a word.", + "", + "`coverage` is REQUIRED of every rule that declares a `record_binding`. It was", + "added because the quantifier alone could not be satisfied honestly on a 1:N", + "copy, and a rule that omitted it would fall back to whichever reading the", + "checker was written for - which is exactly the unread declaration this", + "vocabulary exists to replace." ], - "at_one_to_one": "A 1:1 rule declares NO `record_binding`, because at one occurrence per role `every` and `at_least_one` are the same condition and there is nothing to choose. The record must still relate the pair: skipping the check there let R-CONT-COPY license a `continued` on a copy pointing at an unrelated path." + "at_one_to_one": "A 1:1 rule declares NO `record_binding`, because at one occurrence per role `every` and `at_least_one` are the same condition and there is nothing to choose. The record must still relate the pair: skipping the check there let R-CONT-COPY license a `continued` on a copy pointing at an unrelated path.", + "coverage": { + "single_entry": "ONE entry must satisfy the quantifiers for both roles on its own. Two entries describe two transformations and do not add up to one - a fold split into `WireA -> Wire` and `WireB -> Wire` is two folds, not the one this mapping claims.", + "union_of_entries": "the entries that relate this mapping are taken TOGETHER and their union must satisfy the quantifiers. A copy into N destinations is N records, one per destination, so requiring a single entry to name them all would require a record shape the producer never emits - and accepting one entry would leave the other destinations explained by nothing." + } }, "evidence_kind_records": { "map": { diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 5badca2..0ec32d0 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -121,6 +121,48 @@ and licenses `branched` over a perfectly good 1:1 match. In successor, explains a *move* — `R-BRANCH-COPY` is guarded out and `R-CONT-COPY` answers. One record, two readings; cardinality chooses. +#### And every off-path successor needs a record of its own + +Cardinality says how many successors there may be. `record_binding` says which +of them the record has to name, and it took two goes to say it correctly. + +The copy rule excludes partners at the predecessor's own path — those are where +the occurrence already was, and a continued rule reaches them. Over what is +left, the quantifier was `at_least_one`, which was right when every fixture had +exactly one off-path successor and wrong the moment +`a-copy-into-two-new-files-is-a-branch` arrived with two. Deleting one of that +fixture's two copy records left the branch licensed over both successors: the +unexplained one was absorbed on the strength of its partner profile alone — +`same_rule_message` and `anchored_content`, which any unrelated occurrence of +that defect anywhere in the tree also shows. The rule's own note says the copy +record is what separates a branch from an ambiguity, and a successor with no +record was separated from an ambiguity by nothing. + +So it is `every`, over the pool the exclusion leaves. That is not the reading +the old justification rejected — what it rejected was demanding *every* +successor including the one at the predecessor's own path, which the exclusion +had already removed by the time the sentence was written. The exclusion arrived +and the quantifier was never asked again. + +Making it satisfiable needed a second axis. Git writes **one copy record per +destination**, so no single entry can name both successors; a fold is the +opposite, one record naming all of its sources, and two records naming one each +describe two folds. That difference used to be a property of whichever rule the +checker was written for. It is now declared: `coverage: union_of_entries` on the +copy, `coverage: single_entry` on the fold, required of every rule that binds a +record and rejected if it is a word the vocabulary does not carry. + +The two values are not pinned equally, and the asymmetry is worth stating. +`union_of_entries` on the copy is pinned by a valid preregistered case — remove +a record and the case fails. `single_entry` on the fold is not, because the +input that would distinguish it is a fold record naming only some of its +sources, which is an invalid mapping and cannot be preregistered as a valid +case. What pins it is that it is the *stronger* of the two readings — anything +one entry relates, the union relates too — so the corpus satisfying it is real +evidence, and the residual risk is a valid mapping wrongly rejected rather than +an invented one accepted. Weakening it was probed directly, and the split fold +that `single_entry` rejects does pass under `union_of_entries`. + ### 4. What the suite refuses to compute `identity/tests/test_lineage_decision.py` never decides which rules a real diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 15ddc03..9fdc46e 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2099,7 +2099,26 @@ def arbitration_leaves(node, trail): "ends of it: with one end unnamed, every occurrence on that side counts " "as reached and the record licenses a mapping it does not describe.") - vocab = policy["record_binding_vocabulary"] + vocab = mapping_or_empty(policy.get("record_binding_vocabulary")) + # THE VOCABULARY ITSELF, BEFORE ANYTHING IS LOOKED UP IN IT. Membership - + # `token in vocab["quantifier"]` - raises TypeError when that axis is a + # scalar, so the three checks that police the binding tokens died on a + # malformed vocabulary + # - the guard crashing on the input it exists to examine, for the eleventh + # time on this branch, and I added the third instance of it here while fixing + # the tenth somewhere else. Asked once, of all three axes, naming the real + # defect instead of reporting that some token is "not carried" by a + # vocabulary that is not a vocabulary. + axes = {} + for axis in ("quantifier", "excluding", "coverage"): + check(isinstance(vocab.get(axis), dict) and bool(vocab[axis]), + f"`record_binding_vocabulary.{axis}` is {vocab.get(axis)!r}. Every " + "binding token is checked for membership in it, and membership in a " + "scalar is not a question with an answer.") + # A LOCAL READING, not a repair of the loaded contract. Writing the empty + # mapping back into `policy` would leave every later reader looking at a + # document this checker quietly fixed. + axes[axis] = mapping_or_empty(vocab.get(axis)) for rid in sorted(rules): rule_ = rules[rid] check("requires_all_scope" not in rule_, @@ -2116,6 +2135,23 @@ def arbitration_leaves(node, trail): check(len(structural) == 1, f"{rid} is a group rule requiring {structural!r} structural signals; " "the binding is about exactly one record") + # HOW THE ENTRIES COMBINE, declared rather than inherited from whichever + # rule the checker was written for. Required, and with no default: a fold + # is one record naming all its sources, a copy into N destinations is N + # records, and reading either as the other licenses a different policy. + # + # `axes`, NOT `vocab`, and the message is the reason. The predicate was + # guarded and this f-string was not, so `sorted(vocab['coverage'])` was + # evaluated eagerly and the check crashed while REPORTING the fault it had + # just detected correctly. Twelfth of these, and the first in a message + # rather than a predicate - a sub-form the previous eleven did not teach + # me to look for. + check(binding.get("coverage") in axes["coverage"], + f"{rid}.record_binding declares coverage {binding.get('coverage')!r}, " + f"which the vocabulary does not carry. It carries " + f"{sorted(axes['coverage'])}. A rule that omits it leaves the " + "quantifier to be read whichever way the checker happens to combine " + "entries - and on a 1:N copy the two readings are different policies.") for sig in structural: roles = {r for _, r, _ in signal_bindings(policy["structural_signals"][sig])} declared = {k for k in binding if k in SUBJECT_ROLES} @@ -2130,11 +2166,11 @@ def arbitration_leaves(node, trail): check(not bad, bad or "") if not isinstance(spec_r, dict) or not spec_r: continue - check(spec_r.get("quantifier") in vocab["quantifier"], + check(spec_r.get("quantifier") in axes["quantifier"], f"{rid}.record_binding.{role} uses quantifier " f"{spec_r.get('quantifier')!r}, which the vocabulary does not carry") if "excluding" in spec_r: - check(spec_r["excluding"] in vocab["excluding"], + check(spec_r["excluding"] in axes["excluding"], f"{rid}.record_binding.{role} excludes {spec_r['excluding']!r}, " "which the vocabulary does not carry. Unknown tokens are " "rejected, not ignored.") @@ -2507,8 +2543,11 @@ def arbitration_leaves(node, trail): # `every` and `at_least_one` are the SAME condition and the # checker chooses no policy by applying it - see # `record_binding_vocabulary.at_one_to_one`. + # ...and `single_entry`, because at one occurrence per role + # there is one pair to relate and no union to take. binding = {"predecessor": {"quantifier": "every"}, - "successor": {"quantifier": "every"}} + "successor": {"quantifier": "every"}, + "coverage": "single_entry"} if not binding: continue for kind in list_or_empty(rule_r.get("requires_all")): @@ -2536,9 +2575,8 @@ def arbitration_leaves(node, trail): "would then explain nothing this mapping needs.") pools[role] = pool - def carries(entry: dict) -> bool: - rel = related_by(entry, pairs, pools["predecessor"], - pools["successor"], by_id) + def satisfied(rel: set) -> bool: + """The quantifiers, asked of a set of related pairs.""" for role in SUBJECT_ROLES: quant = mapping_or_empty(binding.get(role)).get("quantifier") reached = {p[0 if role == "predecessor" else 1] for p in rel} @@ -2552,13 +2590,38 @@ def carries(entry: dict) -> bool: def shown(ids_): return [by_id.get(o, {}).get("enclosing_symbol") or by_id.get(o, {}).get("path") for o in ids_] - check(any(carries(e) for e in entries), - f"{where}: {rid} rests on {sig!r}, and no SINGLE entry of " - f"{read_field} relates this mapping as its `record_binding` " - f"requires. Predecessors {shown(pools['predecessor'])!r}, " + rels = [related_by(e, pairs, pools["predecessor"], + pools["successor"], by_id) for e in entries] + # HOW THE ENTRIES COMBINE. This used to be `any(...)` for every + # rule, which is `single_entry` chosen by the checker and named + # nowhere. It made `every` unsatisfiable on a 1:N copy - git + # writes one record per destination - so the copy rule was left + # at `at_least_one`, and `at_least_one` licensed a branch over a + # successor no record named. + coverage = mapping_or_empty(binding).get("coverage") + if coverage == "union_of_entries": + held, how = satisfied(set().union(*rels) if rels else set()), ( + f"and the records of {read_field} do not TOGETHER relate " + "this mapping as its `record_binding` requires. Under " + "`union_of_entries` every partner the quantifier ranges " + "over has to be named by some record; one that is named " + "by none of them is not part of this transformation.") + elif coverage == "single_entry": + held, how = any(satisfied(r) for r in rels), ( + f"and no SINGLE entry of {read_field} relates this mapping " + "as its `record_binding` requires. Two records describing " + "two transformations do not add up to one transformation.") + else: + held, how = False, ( + f"and its `record_binding` declares coverage " + f"{coverage!r}, so there is no reading of {read_field} to " + "apply. An unreadable binding checks nothing, which is " + "worse than a wrong one.") + check(held, + f"{where}: {rid} rests on {sig!r}, {how} " + f"Predecessors {shown(pools['predecessor'])!r}, " f"successors {shown(pools['successor'])!r}; the records hold " - f"{entries!r}. Two records describing two transformations do " - "not add up to one transformation.") + f"{entries!r}.") # A REFUSED CONFLICT LICENSES NOTHING, and the loop above therefore # skipped it entirely - so `copy-source-that-is-also-a-fold-refuses` From 688ef36217b3f015f98725d3d58a05c727a437ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 05:06:52 +0000 Subject: [PATCH 64/84] test(lineage): scan all three corpora for duplicate keys, and ask the producer Two P2s, both confirmed by mutation before being fixed. CODERABBIT: the eligibility corpus was not in the duplicate-key scan. The comment above that scan already claimed coverage - "and the FIXTURES, which this suite also reads by key" - and meant one directory. The other arrived later and never joined. Verified: injecting a first `expect` naming `continued` into an eligibility fixture left `json.load` keeping the second and the suite green. Hunting the sibling found a third corpus. `test_lineage_contract.py` had no duplicate-key scan AT ALL - not for the senior contract, not for its thirteen cases - and injecting a second `expect` into `added-file-is-an-evidenced-birth` left that suite green too. Rather than copy the scanner into the second suite, it moves to `identity/tests/jsonscan.py` and both import it: a second copy of a checker is a checker that will eventually disagree with itself. CODEX: the limitation check accepted sets `occurrence.resolve()` cannot produce. Both fixtures set `start_column: null` and neither declared `physical-anchor-missing:start-column`, which `resolve()` appends unconditionally in that case, so both froze a record the producer cannot emit; the prefix test would also have accepted `occurrence-id-unavailable:path` on a finding that has a path. The membership-plus-prefix check is replaced by RUNNING the producer: `resolve()` is called on the finding as written, with the two inputs a normalized record does not carry - provenance and anchor ambiguity - read off the declared limitations so the fixture still picks its own scenario, and both the null id and the exact limitation list must come back from it. Reimplementing the predicates here would only have frozen a second opinion about them. The two cases now differ in anchor quality deliberately: revision A has a complete anchor and one limitation, because a finding can lose its identity without its anchor being degraded too; revision B has no start column and carries both tokens, one blocking and one not. Verified: 8 probes, each biting with the message it should - both reviewers' exact mutations, a blocking token contradicted by the finding, a degraded anchor with the limitation undeclared, a non-blocking-only set, an unhashable element that must not crash the round trip, and duplicate keys in a senior fixture and in the senior contract. Eligibility census now 174 mutations / 0 crashes / 0 green - every field of that corpus is load-bearing. Fixture census 3280/0/44 and contract census 65 crash sites, both unchanged. 20 suites green in normal and -O, and both suites verified from a foreign cwd since the shared import is new. No production mapper. MEASURED AND NOT TOUCHED: the senior suite's own corpus is where the decision corpus was many rounds ago - 2008 mutations, 668 crashes, 853 passing unnoticed. Identical numbers against the committed suite and against this one, so it is pre-existing and untouched by this change. It is a separate piece of work and is reported rather than started. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- docs/finding-lineage-decision.md | 28 +++-- ...d-finding-in-revision-a-is-unresolved.json | 4 +- ...d-finding-in-revision-b-is-unresolved.json | 5 +- identity/tests/jsonscan.py | 69 +++++++++++ identity/tests/test_lineage_contract.py | 21 ++++ identity/tests/test_lineage_decision.py | 110 ++++++++---------- 6 files changed, 162 insertions(+), 75 deletions(-) create mode 100644 identity/tests/jsonscan.py diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 0ec32d0..0900be7 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -380,13 +380,27 @@ successor). The relation schema still requires a string occurrence id, and is not relaxed — that requirement is what stops an unanswerable edge being preregistered. -Each case's null id has to come with a reason `identity/occurrence.py` actually -emits, and a reason of the kind that produces a null id: the suite reads the -vocabulary off that module's `LIMIT_*` constants rather than restating it, and -requires at least one limitation under the `occurrence-id-unavailable:` prefix, -since `physical-anchor-missing:start-column` alone does not block one. A fixture -that paired a null id with reasons that would not have produced it would be -triggering stage 0 with nothing. +Each case's limitations are checked by **running the producer**, not by +restating its rules. The suite calls `identity/occurrence.py`'s `resolve()` on +the finding as written — reading the two inputs a normalized record does not +carry, producer provenance and anchor ambiguity, off the declared limitations so +the fixture still picks its own scenario — and requires that the returned id is +`None` and the returned limitation list matches the declared one exactly. + +That replaced a weaker check which asked only that some declared token carried +the `occurrence-id-unavailable:` prefix. It passed limitation sets `resolve()` +could not have produced: both cases set `start_column: null` and neither declared +`physical-anchor-missing:start-column`, which `resolve()` appends unconditionally +in that case, so both fixtures froze a record the producer cannot emit. It would +also have accepted `occurrence-id-unavailable:path` on a finding that has a path. +Reimplementing the predicates in the checker would only have frozen a second +opinion about them; calling the producer freezes the producer. + +The two cases now differ in anchor quality on purpose. The revision-A finding has +a complete anchor and exactly one limitation — the missing provenance — because a +finding can lose its identity without its anchor being degraded too. The +revision-B finding has no start column and carries both tokens, one blocking and +one not. | # | case | expected | |---|---|---| diff --git a/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json index 1137b3f..a8a4e72 100644 --- a/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json +++ b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-a-is-unresolved.json @@ -3,7 +3,7 @@ "contract": "finding-lineage-decision/v1", "status": "preregistered-unimplemented", "title": "A revision A finding whose occurrence id was never earned", - "why": "`identity/occurrence.py` returns None for the occurrence id when the producer run id is missing, and `aggregate/normalize.py` writes that None into the record. This is ordinary output of a run without producer provenance, not malformed input. The decision layer had no path for it at all: the procedure began at COLLECT, no reason_mapping branch emitted the senior contract's frozen `missing-occurrence-id`, and no case in the relation corpus could express it - the relation schema requires a string id, correctly, because an edge has to name its subject. Stage 0 answers it before the relation problem is posed.", + "why": "`identity/occurrence.py` returns None for the occurrence id when the producer run id is missing, and `aggregate/normalize.py` writes that None into the record. This is ordinary output of a run without producer provenance, not malformed input. The decision layer had no path for it at all: the procedure began at COLLECT, no reason_mapping branch emitted the senior contract's frozen `missing-occurrence-id`, and no case in the relation corpus could express it - the relation schema requires a string id, correctly, because an edge has to name its subject. Stage 0 answers it before the relation problem is posed. Its anchor is COMPLETE - a real start column - so the only limitation is the missing provenance, and `resolve()` returns that one and no other. A finding can lose its identity without its anchor being degraded too, and the corpus says so on this side.", "revision": "a", "finding": { "occurrence_id": null, @@ -11,7 +11,7 @@ "rule": "DOTNET_RESOURCE_LEAK", "message": "event handler subscribed and never removed", "start_line": 42, - "start_column": null, + "start_column": 17, "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;", diff --git a/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json index 75667fb..36b9721 100644 --- a/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json +++ b/identity/fixtures/lineage-eligibility/an-unidentified-finding-in-revision-b-is-unresolved.json @@ -3,7 +3,7 @@ "contract": "finding-lineage-decision/v1", "status": "preregistered-unimplemented", "title": "A revision B finding whose physical anchor is ambiguous", - "why": "The mirror, and written separately rather than inferred: the a-side case is about a predecessor that cannot be named and this one is about a successor, and on this branch assuming that a fix for one side covers the other has already been wrong once. Here the id is null because two findings share a physical anchor, so `occurrence.py` refuses to choose between them. The refusal is the same and it is reached the same way - before any evidence is read - which is the point: stage 0 does not care WHY the identity is missing, only that it is.", + "why": "The mirror, and written separately rather than inferred: the a-side case is about a predecessor that cannot be named and this one is about a successor, and on this branch assuming that a fix for one side covers the other has already been wrong once. Here the id is null because two findings share a physical anchor, so `occurrence.py` refuses to choose between them. The refusal is the same and it is reached the same way - before any evidence is read - which is the point: stage 0 does not care WHY the identity is missing, only that it is. Its anchor has NO start column, and `resolve()` appends `physical-anchor-missing:start-column` whenever that is true - unconditionally, alongside whatever blocks the id. Both fixtures omitted it and both were records the producer could not emit; this side now carries the pair, and the a-side carries a complete anchor instead.", "revision": "b", "finding": { "occurrence_id": null, @@ -16,7 +16,8 @@ "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;", "identity_limitations": [ - "occurrence-id-unavailable:ambiguous-physical-anchor" + "occurrence-id-unavailable:ambiguous-physical-anchor", + "physical-anchor-missing:start-column" ] }, "expect": { diff --git a/identity/tests/jsonscan.py b/identity/tests/jsonscan.py new file mode 100644 index 0000000..347dbd4 --- /dev/null +++ b/identity/tests/jsonscan.py @@ -0,0 +1,69 @@ +"""Duplicate-key detection for the raw JSON parse, shared by both suites. + +`json.load` keeps the last of a duplicated key and drops the other without a +word. Every contract and every fixture in this project is read BY KEY, so a +duplicated key silently deletes half of what a document says before any check +sees it - a rule, a limitation, an evidence kind, or half a preregistered +expectation. + +This lived inside `test_lineage_decision.py`, where it scanned that suite's two +contracts and one fixture directory. The eligibility corpus arrived later and +never joined the list; the SENIOR suite never had a scan at all, and its +thirteen cases carry the same exposure - verified by injecting a second +`expect` into `added-file-is-an-evidenced-birth`, which left that suite green. +It is here rather than copied into the second suite because a second copy of a +checker is a checker that will disagree with itself, which is the defect this +project keeps finding in its own drafts. +""" +import json + + +class _Marked(dict): + """A parsed JSON object that remembers the keys declared twice inside it. + + A plain dict cannot carry that fact to a later walk; this can.""" + __slots__ = ("duplicate_keys",) + + +def duplicate_json_keys(path: str) -> list: + """Dotted paths to keys declared twice in ONE object, from the RAW parse. + + Two earlier versions of this function got its own description wrong, in + opposite directions. The first promised dotted paths and returned bare names. + The second returned the key plus its siblings and asserted that a path was not + obtainable, because `object_pairs_hook` is called innermost-first and never + learns where it is. That second claim was false: the hook does not know, but a + walk from the root afterwards does, once each object carries what it saw. So + the paths are real - `arbitration.conflict.reason`, not `reason` - and the + docstring finally matches the code. + + Stating something impossible when it is merely inconvenient is the same defect + this suite keeps finding elsewhere: a description that claims more, or less, + than the code does.""" + def hook(pairs): + obj = _Marked(pairs) + seen, dups = set(), [] + for key, _ in pairs: + if key in seen: + dups.append(key) + seen.add(key) + obj.duplicate_keys = dups + return obj + + with open(path, encoding="utf-8") as fh: + root = json.load(fh, object_pairs_hook=hook) + + found: list = [] + + def walk(node, trail): + if isinstance(node, _Marked): + for key in node.duplicate_keys: + found.append(".".join(trail + [key]) or key) + for key, value in node.items(): + walk(value, trail + [key]) + elif isinstance(node, list): + for i, value in enumerate(node): + walk(value, trail + [f"[{i}]"]) + + walk(root, []) + return sorted(set(found)) diff --git a/identity/tests/test_lineage_contract.py b/identity/tests/test_lineage_contract.py index 2819bf3..7259959 100644 --- a/identity/tests/test_lineage_contract.py +++ b/identity/tests/test_lineage_contract.py @@ -47,6 +47,16 @@ HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(HERE)) sys.path.insert(0, ROOT) +sys.path.insert(0, HERE) + +# The RAW parse, before anything is read by key. This suite had no such scan at +# all: `json.load` keeps the last of a duplicated key and drops the other in +# silence, so a case declaring `expect` twice was checked against half of what it +# says. Verified by injecting a second `expect` into +# `added-file-is-an-evidenced-birth` - the first declaration vanished and this +# suite stayed green. The scanner is imported rather than copied from the +# decision suite, which is where it lived while covering only that suite's files. +from jsonscan import duplicate_json_keys # noqa: E402 CONTRACT = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") FIXDIR = os.path.join(ROOT, "identity", "fixtures", "lineage") @@ -168,6 +178,17 @@ def amendment_ordinal_failures(note) -> list: def main() -> int: + scanned = [("outcome contract", CONTRACT)] + scanned += [(f"fixture {f}", os.path.join(FIXDIR, f)) + for f in sorted(os.listdir(FIXDIR)) if f.endswith(".json")] + for label, cpath in scanned: + for dup in duplicate_json_keys(cpath): + check(False, + f"the {label} declares {dup!r} twice. `json.load` keeps the last " + "one and drops the other without a word, so an outcome, a " + "limitation, an evidence kind or half a preregistered expectation " + "would vanish between the file and every check below.") + with open(CONTRACT, encoding="utf-8") as fh: contract = json.load(fh) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 9fdc46e..604c4b0 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -88,6 +88,10 @@ # function, and the whole reason this branch needed a sixth senior amendment is # that nobody was checking fixture ids against the first one. from identity import pattern as finding_pattern # noqa: E402 +# The duplicate-key scanner, now shared with the senior suite rather than +# living here and being copied there. See `identity/tests/jsonscan.py`. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from jsonscan import duplicate_json_keys # noqa: E402 # ...and the ONE producer of identity limitations, for the same reason. The # eligibility corpus asserts that a null occurrence id came with a reason # `occurrence.py` actually emits; a hand-copied list of tokens here would be a @@ -97,10 +101,6 @@ LIMITATION_VOCABULARY = frozenset( v for k, v in vars(finding_occurrence).items() if k.startswith("LIMIT_") and isinstance(v, str)) -# `resolve()` returns a null id for exactly the limitations under this prefix - -# `physical-anchor-missing:start-column` does not block an id. The prefix is -# taken from a named constant rather than spelled again here. -BLOCKING_LIMITATION = finding_occurrence.LIMIT_NO_RUN_ID.split(":")[0] + ":" SENIOR = os.path.join(ROOT, "contracts", "finding-lineage-v1.json") POLICY = os.path.join(ROOT, "contracts", "finding-lineage-decision-v1.json") @@ -171,59 +171,6 @@ def load(path: str) -> dict: return json.load(fh) -class _Marked(dict): - """A parsed JSON object that remembers the keys declared twice inside it. - - `json.load` keeps the last of a duplicated key and drops the other without a - word, so the duplication has to be caught during the raw parse. A plain dict - cannot carry that fact to a later walk; this can.""" - __slots__ = ("duplicate_keys",) - - -def duplicate_json_keys(path: str) -> list: - """Dotted paths to keys declared twice in ONE object, from the RAW parse. - - Two earlier versions of this function got its own description wrong, in - opposite directions. The first promised dotted paths and returned bare names. - The second returned the key plus its siblings and asserted that a path was not - obtainable, because `object_pairs_hook` is called innermost-first and never - learns where it is. That second claim was false: the hook does not know, but a - walk from the root afterwards does, once each object carries what it saw. So - the paths are real - `arbitration.conflict.reason`, not `reason` - and the - docstring finally matches the code. - - Stating something impossible when it is merely inconvenient is the same defect - this suite keeps finding elsewhere: a description that claims more, or less, - than the code does.""" - def hook(pairs): - obj = _Marked(pairs) - seen, dups = set(), [] - for key, _ in pairs: - if key in seen: - dups.append(key) - seen.add(key) - obj.duplicate_keys = dups - return obj - - with open(path, encoding="utf-8") as fh: - root = json.load(fh, object_pairs_hook=hook) - - found: list = [] - - def walk(node, trail): - if isinstance(node, _Marked): - for key in node.duplicate_keys: - found.append(".".join(trail + [key]) or key) - for key, value in node.items(): - walk(value, trail + [key]) - elif isinstance(node, list): - for i, value in enumerate(node): - walk(value, trail + [f"[{i}]"]) - - walk(root, []) - return sorted(set(found)) - - def rule_needs(rule) -> set: """Every signal a rule needs to apply - group requirements AND the per-partner profile. @@ -1859,6 +1806,14 @@ def arbitration_leaves(node, trail): # declaration vanishes and the suite stays green. scanned += [(f"fixture {f}", os.path.join(FIXDIR, f)) for f in sorted(os.listdir(FIXDIR)) if f.endswith(".json")] + # ...AND THE ELIGIBILITY CORPUS, which the comment above already claimed was + # covered by saying "the FIXTURES". It meant one directory. The other arrived + # later and never joined the list, so an eligibility case could declare + # `expect` twice - an invalid one first, the valid one second - and lose the + # invalid half to `json.load` before any check saw it. Verified: injecting a + # first `expect` naming `continued` left the suite green. + scanned += [(f"eligibility fixture {f}", os.path.join(ELIGDIR, f)) + for f in sorted(os.listdir(ELIGDIR)) if f.endswith(".json")] for label, cpath in scanned: for dup in duplicate_json_keys(cpath): check(False, @@ -3222,13 +3177,40 @@ def shown(ids_): "`occurrence.py` does not emit. The fixture stands in for a " "record that module produced; a token it never produces " "describes an input the mapper will not receive.") - check(any(isinstance(x, str) and x.startswith(BLOCKING_LIMITATION) - for x in limits), - f"{where}: identity_limitations is {limits!r} and none of them " - f"starts with {BLOCKING_LIMITATION!r}. Those are the only ones for " - "which `resolve()` returns a null id - so this fixture pairs a null " - "id with reasons that would not have produced one, and stage 0 is " - "then triggered by nothing.") + # THE PRODUCER'S OWN ANSWER, asked of the finding as written. A + # prefix test stood here - "at least one token blocks an id" - and it + # passed a limitation set `resolve()` could not have produced for THIS + # finding: both cases set `start_column: null` and neither declared + # `physical-anchor-missing:start-column`, which `resolve()` appends + # unconditionally in that case. The fixtures froze a record the + # producer cannot emit, and a checker that reimplements the predicate + # would only have frozen a second opinion about it. + # + # The two inputs `resolve()` takes that a normalized finding does not + # carry - producer provenance and anchor ambiguity - are read OFF the + # declared limitations, so the fixture still chooses its own scenario. + # Everything derivable from the finding's own fields is then the + # producer's to decide, and the answer has to match exactly. + if limits and all(isinstance(x, str) for x in limits): + phys = {"path": finding["path"], "start_line": finding["start_line"], + "start_column": finding["start_column"]} + blind = finding_occurrence.LIMIT_NO_RUN_ID in limits + got_id, got_limits = finding_occurrence.resolve( + finding["pattern_id"], phys, + None if blind else "preregistered-run", + None if blind else "preregistered-producer", + finding_occurrence.LIMIT_AMBIGUOUS_ANCHOR in limits) + check(got_id is None, + f"{where}: `resolve()` returns {got_id!r} for this finding, not " + "None. The limitations it declares do not withhold an identity, " + "so the mapper would receive an identified finding and stage 0 " + "would never see it.") + check(sorted(limits) == got_limits, + f"{where}: the finding declares {sorted(limits)} and `resolve()` " + f"returns {got_limits} for it. It returns EVERY applicable " + "limitation, not the first - so a set that differs from the " + "producer's is a record the producer cannot emit, whichever " + "direction it differs in.") exp = ecase["expect"] ebad = kind_table_failures(f"{where}.expect", exp, ELIGIBILITY_EXPECT_KINDS, From 193ceae4d3d88bfeccba09d1d4683a70798c82d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 05:21:09 +0000 Subject: [PATCH 65/84] contract(lineage): pin every coverage value to a witness it must actually decide Codex: flipping `R-MERGE-FOLD.record_binding.coverage` from `single_entry` to `union_of_entries` left all fourteen cases green, so two records naming one source each would have been accepted as one fold against the contract's own words. Confirmed. I had disclosed exactly this in the previous round as an "asymmetry worth stating" instead of closing it - a reasoned-for exemption, which is the shape this branch keeps finding, written this time into my own report of the shape. The general law, not the special case: a declared `coverage` that nothing can distinguish from its alternatives is a policy nobody chose - which is how `R-BRANCH-COPY` sat at `at_least_one` for twelve fixtures. Each rule that binds a record now names a `coverage_witness`: a committed case, a transform of it the suite applies, and the verdict the declared reading must reach on the result. The sweep evaluates every value in the vocabulary and requires BOTH that the declared one disagrees with each other value there and that it reaches the declared verdict. R-BRANCH-COPY a-copy-into-two-new-files-is-a-branch none accepts R-MERGE-FOLD a-fold-across-files-is-still-a-merge split_records_one_partner_each rejects The two readings are hoisted to module level and the sweep calls the SAME functions the binding check calls, under a value the rule did not declare; a copy of them written for the sweep would only prove the copy agrees with itself. THE FIRST DRAFT OF THIS LAW DID NOT CATCH THE FINDING IT WAS WRITTEN FOR. It required only that the readings be DISTINGUISHABLE on the witness, and the fold flipped to `union_of_entries` stayed green under it: the witness still separated the two readings, because a law proving two options differ says nothing about which was taken. The verdict is the half that bites, and the probe found that, not the reading. Two false claims are corrected rather than deleted. The contract and the docs both said the fold's separating input was "an invalid mapping no valid case can express" - which confused the RECORD being partial with the INPUT being unpreregisterable; a repository where two folds were recorded separately is an ordinary state with a definite answer, and it is now the fold's witness. And `R-MERGE-FOLD.record_binding.why` said the value "was probed on its own", which was true and is the difference between a decision and a recollection of one. Verified: 7 probes, each biting with the message it should - Codex's exact mutation, the copy's mirror, both verdicts inverted, a witness with no verdict, a witness whose transform separates nothing, and a witness naming a case the binding never ran on. Pointing the fold's witness at `merge-record-dominates-the-single-match` is green and correct: that case witnesses the same claim, and the contract may name either. Fixture census 3280/0/44, eligibility census 174/0/0, contract census 65 crash sites - all unchanged, no new crash site. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 46 ++++-- docs/finding-lineage-decision.md | 35 +++-- identity/tests/test_lineage_decision.py | 157 +++++++++++++++++++-- 3 files changed, 206 insertions(+), 32 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 6040efb..4f9dd5f 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -226,7 +226,13 @@ "destination, so the branch is described by the records together and by no one of", "them alone." ], - "coverage": "union_of_entries" + "coverage": "union_of_entries", + "coverage_witness": { + "case": "a-copy-into-two-new-files-is-a-branch", + "transform": "none", + "verdict": "accepts", + "why": "The case as committed already separates the two readings: two off-path successors and one copy record each, which `union_of_entries` accepts and `single_entry` rejects because no one record names both. No transform is needed, and `none` is declared rather than the field omitted - an absent witness and a witness that needs nothing done to it are different claims." + } } }, "R-MERGE-FOLD": { @@ -261,13 +267,21 @@ "and the quantifier is what makes that different from the copy case rather than a", "matter of taste.", "", - "`coverage: single_entry` is the other half, and it is NOT inherited from the", - "copy rule by symmetry - it was probed on its own. Two records naming one source", - "each describe two folds into the same successor, not the one transformation this", - "mapping claims; both that split and a record naming a single source are rejected", - "by `a-fold-across-files-is-still-a-merge`." + "`coverage: single_entry` is the other half, and it is NOT inherited from the copy", + "rule by symmetry. Two records naming one source each describe two folds into the", + "same successor, not the one transformation this mapping claims - and that claim", + "is EXECUTED rather than argued: `coverage_witness` below names the case, the", + "transform that produces exactly that input, and the verdict this reading has to", + "reach on it. It was a probe run once in a scratch directory before that, which is", + "the difference between a decision and a recollection of one." ], - "coverage": "single_entry" + "coverage": "single_entry", + "coverage_witness": { + "case": "a-fold-across-files-is-still-a-merge", + "transform": "split_records_one_partner_each", + "verdict": "rejects", + "why": "The corpus as committed CANNOT separate the fold's two readings: every case satisfies the stronger one, so weakening it changes nothing and the choice would be made on paper. The separating input is that case's own record spread over one source each - `from: [A, B]` becomes two records - which is two folds into one successor rather than the one transformation the mapping claims. `single_entry` rejects it and `union_of_entries` accepts it, which is the whole difference between the two values.\n\nAn earlier revision of this contract called that input an invalid mapping that could not be preregistered, and reported the fold's value as pinned only by being the stronger reading. The first half confused the RECORD being partial with the INPUT being unpreregisterable, and the second half was a disclosed gap standing in for a closed one. The transform is applied by the suite now, so the claim in `why` above is executed rather than believed." + } } } }, @@ -1106,7 +1120,23 @@ "DESTINATION: no single entry of `revision_b.copies` can name both. A fold is the", "opposite - one record names all of its sources, and two records naming one each", "describe two folds. `coverage` is where that difference is now declared instead", - "of being a property of whichever rule the checker happened to be written for." + "of being a property of whichever rule the checker happened to be written for.", + "", + "AND BOTH VALUES ARE PINNED, BY A LAW RATHER THAN BY ARGUMENT. A declared", + "coverage that nothing can distinguish from its alternatives is a policy nobody", + "chose, which is how the copy sat at `at_least_one` for twelve fixtures. Each", + "rule therefore names a `coverage_witness`: a committed case, a transform of it", + "the suite applies, and the verdict the declared reading must reach. The sweep", + "requires the declared value to DISAGREE with every other value there and to", + "reach the DECLARED VERDICT - the second half added after the first draft of the", + "law stayed green while the fold's value was flipped, because proving two", + "readings differ says nothing about which one was taken.", + "", + "A paragraph here reported the fold as pinned only by being the stronger", + "reading, its separating input being 'an invalid mapping no valid case can", + "express'. That confused the RECORD being partial with the INPUT being", + "unpreregisterable: a repository where two folds were recorded separately is an", + "ordinary state with a definite answer. It is now the fold's witness." ], "quantifier": { "every": "the record must name EVERY occurrence in that role for this mapping", diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 0900be7..04c62b0 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -152,16 +152,31 @@ checker was written for. It is now declared: `coverage: union_of_entries` on the copy, `coverage: single_entry` on the fold, required of every rule that binds a record and rejected if it is a word the vocabulary does not carry. -The two values are not pinned equally, and the asymmetry is worth stating. -`union_of_entries` on the copy is pinned by a valid preregistered case — remove -a record and the case fails. `single_entry` on the fold is not, because the -input that would distinguish it is a fold record naming only some of its -sources, which is an invalid mapping and cannot be preregistered as a valid -case. What pins it is that it is the *stronger* of the two readings — anything -one entry relates, the union relates too — so the corpus satisfying it is real -evidence, and the residual risk is a valid mapping wrongly rejected rather than -an invented one accepted. Weakening it was probed directly, and the split fold -that `single_entry` rejects does pass under `union_of_entries`. +Both values are pinned, and by the same law. A declared `coverage` that no +input can distinguish from its alternatives is a policy nobody chose — which is +precisely how the copy rule sat at `at_least_one` for twelve fixtures. So each +rule names a **coverage witness**: a committed case, a transform of it the suite +can apply, and the verdict the declared reading must reach on the result. The +sweep applies the transform, evaluates every value in the vocabulary, and +requires two things — that the declared value *disagrees* with each of the +others there, and that it reaches the *declared verdict*. + +The copy's witness is `a-copy-into-two-new-files-is-a-branch` untransformed +(`accepts`): two off-path successors with one record each, which +`union_of_entries` admits and `single_entry` cannot. The fold's is +`a-fold-across-files-is-still-a-merge` with its record spread over one source +each (`rejects`): two folds into one successor, not the one transformation the +mapping claims. + +An earlier version of this section reported the fold's value as pinned only by +being the *stronger* reading, on the grounds that the separating input was an +invalid mapping no valid case could express. That confused the *record* being +partial with the *input* being unpreregisterable, and it was a disclosed gap +standing in for a closed one. The first draft of the law that replaced it was +also wrong, and in an instructive way: it required only that the readings be +*distinguishable* on the witness, which stayed green when the fold's value was +flipped — a law proving two options differ says nothing about which was taken. +The verdict is the half that makes it bite. ### 4. What the suite refuses to compute diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 604c4b0..a1aed0f 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -430,6 +430,62 @@ def related_by(entry: dict, pairs: list, preds, succs, by_id: dict) -> set: return out +def quantifiers_hold(binding: dict, pools: dict, rel: set) -> bool: + """The quantifiers of one binding, asked of a set of related pairs.""" + for role in SUBJECT_ROLES: + quant = mapping_or_empty(binding.get(role)).get("quantifier") + reached = {p[0 if role == "predecessor" else 1] for p in rel} + if quant == "every" and any(o not in reached for o in pools[role]): + return False + if quant == "at_least_one" and not reached: + return False + return True + + +def coverage_holds(coverage, binding: dict, pools: dict, rels: list) -> bool: + """The quantifiers under ONE reading of how the record entries combine. + + Module level, and taking `coverage` as an argument rather than reading it + off the binding, because the discrimination sweep has to ask the SAME + question under a value the rule did not declare. A second copy of these two + readings written for the sweep would be a sweep that proves the copy agrees + with itself.""" + if coverage == "union_of_entries": + return quantifiers_hold(binding, pools, set().union(*rels) if rels else set()) + if coverage == "single_entry": + return any(quantifiers_hold(binding, pools, r) for r in rels) + return False + + +def split_one_partner_each(entries: list, pairs: list) -> list: + """Every multi-valued matched field spread over one entry per value. + + The transform a fold record must not survive: `from: [A, B]` becomes two + records naming one source each, which describes two folds into the same + successor rather than the one transformation the mapping claims.""" + out = [] + keys = {key for key, _, _ in pairs} + for entry in entries: + if not isinstance(entry, dict): + out.append(entry) + continue + multi = [k for k in sorted(keys) + if isinstance(entry.get(k), list) and len(entry[k]) > 1] + if not multi: + out.append(entry) + continue + for key in multi: + for value in entry[key]: + out.append({**entry, key: [value]}) + return out + + +COVERAGE_TRANSFORMS = { + "none": lambda entries, pairs: list(entries), + "split_records_one_partner_each": split_one_partner_each, +} + + def catalog_read(cat_spec: dict, rev_b: dict) -> tuple: """(field path, entries, match keys) for one structural signal.""" field = str(cat_spec.get("observable_from", "")).split("[].")[0] @@ -2186,6 +2242,7 @@ def arbitration_leaves(node, trail): licensed_rules: set[str] = set() exercised_refusals: set = set() exercised_reasons: set = set() + coverage_inputs: dict = {} for name in sorted(set(on_disk) & set(preregistered)): with open(os.path.join(FIXDIR, f"{name}.json"), encoding="utf-8") as fh: case = json.load(fh) @@ -2530,18 +2587,6 @@ def arbitration_leaves(node, trail): "would then explain nothing this mapping needs.") pools[role] = pool - def satisfied(rel: set) -> bool: - """The quantifiers, asked of a set of related pairs.""" - for role in SUBJECT_ROLES: - quant = mapping_or_empty(binding.get(role)).get("quantifier") - reached = {p[0 if role == "predecessor" else 1] for p in rel} - if quant == "every" and any(o not in reached - for o in pools[role]): - return False - if quant == "at_least_one" and not reached: - return False - return True - def shown(ids_): return [by_id.get(o, {}).get("enclosing_symbol") or by_id.get(o, {}).get("path") for o in ids_] @@ -2554,15 +2599,19 @@ def shown(ids_): # at `at_least_one`, and `at_least_one` licensed a branch over a # successor no record named. coverage = mapping_or_empty(binding).get("coverage") + # WHAT THE DISCRIMINATION SWEEP NEEDS, taken from the real + # evaluation rather than rebuilt later from the fixture. + coverage_inputs.setdefault((name, rid), []).append( + (binding, pools, entries, pairs, by_id)) if coverage == "union_of_entries": - held, how = satisfied(set().union(*rels) if rels else set()), ( + held, how = coverage_holds(coverage, binding, pools, rels), ( f"and the records of {read_field} do not TOGETHER relate " "this mapping as its `record_binding` requires. Under " "`union_of_entries` every partner the quantifier ranges " "over has to be named by some record; one that is named " "by none of them is not part of this transformation.") elif coverage == "single_entry": - held, how = any(satisfied(r) for r in rels), ( + held, how = coverage_holds(coverage, binding, pools, rels), ( f"and no SINGLE entry of {read_field} relates this mapping " "as its `record_binding` requires. Two records describing " "two transformations do not add up to one transformation.") @@ -3052,6 +3101,86 @@ def shown(ids_): "case. A conflict the contract deliberately refuses is a decision, and a " "decision with no case is frozen in name only.") + # ---- 3c-bis. EVERY DECLARED `coverage` HAS TO MATTER. ------------------- + # A value the corpus cannot distinguish is a policy nobody chose. That is + # exactly how `R-BRANCH-COPY` sat at `at_least_one` for twelve fixtures: no + # case had two off-path successors, so the weaker reading was indistinguishable + # from the stronger one and the weaker one licensed a branch over a successor + # no record named. The contract said so in prose - "declared and not pinned" - + # and the prose was where it stopped. + # + # The fold then repeated it one field over. `single_entry` was chosen, argued + # for, and distinguished by NOTHING in the corpus: flipping it to + # `union_of_entries` left all fourteen cases green, so two records naming one + # source each would have been accepted as one fold against the contract's own + # words. I reported that as a disclosed asymmetry instead of closing it, which + # is the same move as the comment above - a reasoned-for exemption. + # + # So the law, and not the special case: for every rule that declares a + # coverage, the declared value and some other value in the vocabulary must + # DISAGREE on an input this suite actually evaluated. Where the corpus as + # committed cannot separate them, the rule names a transform of a committed + # case that can - `coverage_witness` - and the transform is applied here + # rather than described. + for rid in sorted(rules): + binding_d = mapping_or_empty(rules[rid].get("record_binding")) + declared = binding_d.get("coverage") + if not declared: + continue + others = [v for v in sorted(axes["coverage"]) if v != declared] + witness = mapping_or_empty(binding_d.get("coverage_witness")) + wcase, wtransform = witness.get("case"), witness.get("transform") + check(wtransform in COVERAGE_TRANSFORMS, + f"{rid}.record_binding.coverage_witness names transform " + f"{wtransform!r}; this suite can apply " + f"{sorted(COVERAGE_TRANSFORMS)}. A witness it cannot run is a witness " + "nobody hears.") + # AND WHAT IT MUST CONCLUDE. Separation alone was the first draft of this + # check and it did not catch the finding it was written for: flipping the + # fold to `union_of_entries` left the witness still separating the two + # readings, so the sweep passed a value the contract's own `why` rejects. + # A law that proves two options are distinguishable says nothing about + # which one was taken. The verdict is declared beside the value, in the + # contract, and executed here. + verdict = witness.get("verdict") + check(verdict in ("accepts", "rejects"), + f"{rid}.record_binding.coverage_witness declares verdict {verdict!r}, " + "not 'accepts' or 'rejects'. Without it the witness shows only that the " + "readings differ, which is true whichever one the rule picked.") + recorded = coverage_inputs.get((wcase, rid)) + check(bool(recorded), + f"{rid}.record_binding.coverage_witness names case {wcase!r}, and this " + f"rule's binding was not evaluated on it. `coverage_inputs` holds " + f"{sorted(k[0] for k in coverage_inputs if k[1] == rid)} for {rid}. A " + "witness has to be a case where the binding actually ran.") + if (wtransform not in COVERAGE_TRANSFORMS or not recorded + or verdict not in ("accepts", "rejects")): + continue + separated, verdicts = set(), set() + for binding_w, pools_w, entries_w, pairs_w, by_id_w in recorded: + moved = COVERAGE_TRANSFORMS[wtransform](entries_w, pairs_w) + rels_w = [related_by(e, pairs_w, pools_w["predecessor"], + pools_w["successor"], by_id_w) for e in moved] + mine = coverage_holds(declared, binding_w, pools_w, rels_w) + verdicts.add(mine) + for other in others: + if coverage_holds(other, binding_w, pools_w, rels_w) != mine: + separated.add(other) + check(separated == set(others), + f"{rid} declares coverage {declared!r}, and on {wcase!r} under " + f"transform {wtransform!r} it agrees with {sorted(set(others) - separated)} " + "- so those readings are the same policy here and this rule chose " + "between them on paper only. Either the witness does not separate " + "them or the declaration is not load-bearing; both are a value nobody " + "picked.") + check(verdicts == {verdict == "accepts"}, + f"{rid} declares coverage {declared!r} and a witness that it " + f"{verdict} {wcase!r} under {wtransform!r} - and it does not: the " + f"reading concludes {sorted(verdicts)} where " + f"{verdict == 'accepts'} was declared. Two records naming one partner " + "each are two transformations or they are one; the contract says which, " + "and this is where it either holds or is a sentence.") + # ---- 3d. THE ELIGIBILITY CORPUS. ---------------------------------------- # Stage 0 is answered before the relation problem is posed, so its cases live # in their own corpus and are shaped like the INPUT they refuse rather than From c47c4d2d8ab3c001e038194e91618e8291d94664 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 05:33:05 +0000 Subject: [PATCH 66/84] contract(lineage): the exclusion's promise becomes a condition Codex: an excluded same-path successor need not be reached by any continued rule, and `R-BRANCH-COPY` absorbs it on `same_rule_message` and `anchored_content` alone. Confirmed, on the third attempt - the first two probes fired unrelated checks first, which proves nothing and is a hazard this branch has now hit five times. The witness that works: three successors, two off-path with copy records and one at the predecessor's own path whose ENCLOSING SYMBOL changed. `same_path` holds, so the exclusion lifts the record requirement off it; `structural_context` fails, so no continued rule reaches it. Two successors explained by records, one explained by nothing, and the suite reported OK. `excluding.partners_where_same_path_holds` was a token whose prose said the partners it drops "are where the occurrence already was, so a continued rule reaches them and this record does not have to". The second clause is a CLAIM ABOUT THE MAPPING and nothing asked it. It is now `requires_applicable_outcome: continued`, and where the exclusion actually removed a partner the mapping must declare an applicable rule reaching that outcome. It binds ONLY when something was removed. Where no partner sits at the predecessor's path the exclusion lifts nothing and assumes nothing - so `a-copy-into-two-new-files-is-a-branch` is untouched, and `copy-record-dominates-the-single-match`, whose `R-CONT-SAME-SITE` is precisely the rule the promise meant, satisfies it unchanged. The outcome is NAMED in the contract rather than spelled in the checker, for the reason the quantifiers are: a checker deciding on its own which outcome rescues an excluded partner holds the policy's opinion for it. The vocabulary entry is an object now, and the suite rejects a required outcome no rule concludes. Verified: Codex's scenario is RED and names the excluded partner; the corpus is green and untouched; removing the requirement from the vocabulary is RED; and changing it to `merged` turns `copy-record-dominates-the-single-match` RED, which is what shows the check consults that fixture's declarations rather than passing it by. That fixture is the positive control - a probe that ADDED a continued rule to the branch case instead broke its `rule_licensed_alone` obligation and proved nothing, so it is not counted. Fixture census 3280/0/44, eligibility 174/0/0, contract census 65 crash sites - unchanged. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 28 ++++++++++- docs/finding-lineage-decision.md | 19 ++++++++ identity/tests/test_lineage_decision.py | 56 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 4f9dd5f..376eec3 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -1143,7 +1143,33 @@ "at_least_one": "the record must name at least one, because the remaining partners are explained by a different rule rather than by this record" }, "excluding": { - "partners_where_same_path_holds": "partners at the predecessor's own path are not what the copy record explains; `same_path` holds of them, so a continued rule reaches them and this record does not have to" + "partners_where_same_path_holds": { + "means": "partners at the predecessor's own path are not what the copy record explains; `same_path` holds of them, so a continued rule reaches them and this record does not have to", + "requires_applicable_outcome": "continued", + "why": [ + "THE SECOND CLAUSE OF `means` IS A CLAIM ABOUT THE MAPPING, AND IT USED TO BE", + "AN ASSUMPTION. `requires_applicable_outcome` is that clause made a condition:", + "where this exclusion actually removes a partner, the mapping has to declare", + "an applicable rule that concludes the named outcome - otherwise the record", + "requirement is lifted on a promise nobody keeps.", + "", + "The hole it closes: a successor at the predecessor's own path whose ENCLOSING", + "SYMBOL changed. `same_path` holds, so the exclusion drops it from the copy", + "record's coverage - and `structural_context` fails, so no continued rule", + "reaches it either. It was then absorbed into the branch by `same_rule_message`", + "and `anchored_content` alone, which any unrelated occurrence of that defect", + "anywhere in the tree also shows. Three successors, two explained by copy", + "records and one explained by nothing, and the suite reported OK.", + "", + "It binds ONLY when the exclusion removed someone. Where no partner sits at the", + "predecessor's path the exclusion lifts nothing and assumes nothing, which is", + "why `a-copy-into-two-new-files-is-a-branch` is untouched by it.", + "", + "The outcome is NAMED here rather than spelled in the checker, for the same", + "reason the quantifiers are: a checker that decided on its own which outcome", + "rescues an excluded partner would be holding the policy's opinion for it." + ] + } }, "rule": [ "A quantifier, exclusion or coverage not listed here is REJECTED rather than", diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 04c62b0..7800759 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -144,6 +144,25 @@ successor including the one at the predecessor's own path, which the exclusion had already removed by the time the sentence was written. The exclusion arrived and the quantifier was never asked again. +The exclusion carries a promise, and the promise is now a condition. Dropping +the same-path partners is justified by the claim that *a continued rule reaches +them* — and nothing asked whether one did. A successor at the predecessor's own +path whose **enclosing symbol changed** satisfies `same_path`, so the exclusion +lifts the record requirement off it, and fails `structural_context`, so no +continued rule reaches it either. It was then absorbed into the branch by +`same_rule_message` and `anchored_content` alone. Three successors, two explained +by copy records and one explained by nothing, and the suite reported OK. + +So `excluding.partners_where_same_path_holds` now declares +`requires_applicable_outcome: continued`, and where the exclusion actually +removes a partner, the mapping must declare an applicable rule reaching that +outcome. It binds only when something was removed — where no partner sits at the +predecessor's path the exclusion lifts nothing and assumes nothing, which is why +`a-copy-into-two-new-files-is-a-branch` is untouched by it and +`copy-record-dominates-the-single-match`, whose `R-CONT-SAME-SITE` is exactly the +rule the promise meant, satisfies it. The outcome is named in the contract rather +than spelled in the checker, for the same reason the quantifiers are. + Making it satisfiable needed a second axis. Git writes **one copy record per destination**, so no single entry can name both successors; a fold is the opposite, one record naming all of its sources, and two records naming one each diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index a1aed0f..89e30d9 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -430,6 +430,12 @@ def related_by(entry: dict, pairs: list, preds, succs, by_id: dict) -> set: return out +def shown_ids(ids_, by_id: dict) -> list: + """Occurrences named the way a reader recognises them.""" + return [by_id.get(o, {}).get("enclosing_symbol") or by_id.get(o, {}).get("path") + for o in ids_] + + def quantifiers_hold(binding: dict, pools: dict, rel: set) -> bool: """The quantifiers of one binding, asked of a set of related pairs.""" for role in SUBJECT_ROLES: @@ -2185,6 +2191,20 @@ def arbitration_leaves(node, trail): f"{rid}.record_binding.{role} excludes {spec_r['excluding']!r}, " "which the vocabulary does not carry. Unknown tokens are " "rejected, not ignored.") + # AND WHAT THE TOKEN ASSUMES, DECLARED. Its `means` promised that a + # continued rule reaches the partners it drops; the promise is now a + # named outcome the mapping has to declare applicable. + excl_spec = mapping_or_empty(axes["excluding"].get(spec_r["excluding"])) + want_out = excl_spec.get("requires_applicable_outcome") + check(want_out in set(outcomes.values()), + f"`record_binding_vocabulary.excluding.{spec_r['excluding']}` " + f"requires applicable outcome {want_out!r}, and no rule here " + f"concludes it - the rules conclude " + f"{sorted(set(outcomes.values()))}. An exclusion rests on some " + "OTHER rule reaching what it drops, so the outcome it names has " + "to be one a rule can actually reach; it is named in the " + "contract rather than spelled here so the checker does not pick " + "which rule rescues an excluded partner.") senior_limitations_set = set(senior["limitations"].values()) unavailable_from = str(policy["unavailable_inputs"].get("observable_from", "")) @@ -2585,6 +2605,42 @@ def arbitration_leaves(node, trail): f"{where}: {rid} excludes partners at the " f"predecessor's path and no {role} is left. The record " "would then explain nothing this mapping needs.") + # WHAT THE EXCLUSION ASSUMES, ASKED OF THE MAPPING. + # The token's whole justification is that a partner at + # the predecessor's own path "is where the occurrence + # already was, so a continued rule reaches it and this + # record does not have to". That is a CLAIM ABOUT THE + # MAPPING, and nothing asked it: an occurrence at the + # predecessor's path whose enclosing symbol changed is + # reached by no continued rule at all, and the exclusion + # still lifted the record requirement off it - leaving it + # absorbed into the branch by `same_rule_message` and + # `anchored_content`, which any unrelated occurrence of + # that defect anywhere in the tree also shows. + # + # Only when the exclusion REMOVED someone. Where no + # partner sits at the predecessor's path it lifts nothing + # and assumes nothing, and demanding a continued rule + # there would reject `a-copy-into-two-new-files-is-a-branch`. + dropped = [o for o in (frm if role == "predecessor" else to) + if o not in pool] + excl_spec = mapping_or_empty( + axes["excluding"].get(spec_r["excluding"])) + want_out = excl_spec.get("requires_applicable_outcome") + if dropped and want_out: + reached = [r for r in app + if mapping_or_empty(rules.get(r)).get("outcome") + == want_out] + check(bool(reached), + f"{where}: {rid} excludes {shown_ids(dropped, by_id)!r} " + "from the record's coverage because they sit at the " + "predecessor's path - and this mapping declares no " + f"applicable rule that concludes {want_out!r}, so " + "nothing reaches them. The exclusion lifts the " + "record requirement on the promise that another " + "rule carries it; where no such rule applies, the " + "promise is the only thing holding the partner in " + "and it holds nothing.") pools[role] = pool def shown(ids_): From f836d6f2a9b027a7241b3773375bd1eb17de03bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 05:49:32 +0000 Subject: [PATCH 67/84] contract(lineage): name the rescuer per partner, and stop crashing on witness fields Two findings, both confirmed by mutation. CODEX: `coverage_witness.transform` as a list raises TypeError on the membership test, and an unhashable `case` raises at `coverage_inputs.get((wcase, rid))`. Thirteenth and fourteenth guards on this branch to die on the input they exist to examine - both mine, both introduced by the check that closed the twelfth. Both fields are now required to be strings before either is used as a key. AND THE INSTRUMENT WAS HALF-BLIND, which is the more useful half of that finding. Both censuses mutate CONTAINERS INTO SCALARS and never the reverse, so a string field becoming a list is a direction they never swept - which is exactly where Codex went. The contract census now sweeps both ways, and the held-back scope is much larger than the figure this branch has been reporting: 65 crash sites in the old direction, 296 more in the new one, 361 in total, plus a green residue of 2035. The 65 was never the size of the work; it was the size of what the instrument could see. Reported, not started. CODERABBIT: last round's exclusion fix accepted the expectation when ANY applicable rule concluded `continued`, so one rule discharged the promise for EVERY dropped partner. Confirmed: adding a second same-path successor with a different enclosing symbol to `copy-record-dominates-the-single-match` let it ride in on the first one's rescuer while being reached by nothing. Membership standing in for the correct member - the sub-form this branch has found more often than any other - written into the fix for the previous instance of it. The rescue is now recorded PER PARTNER. `excluded_partners_reached_by` maps occurrence id -> the rule that reaches it, the same shape as `signals_defeated` and `boundary_defeated`. The named rule must be in `applicable_rules` and must conclude the outcome the exclusion names; naming a partner the exclusion did not drop is rejected. It need NOT be in `licensed_by` - a rule that reached a partner and was then dominated still reached it, which is precisely what `copy-record-dominates-the-single-match` records, and that fixture is green unchanged apart from declaring the field. Verified: 7 probes, no tracebacks - both of Codex's malformed fields, a second same-path successor riding in, a rescuer not in applicable_rules, a rescuer concluding the wrong outcome, a partner named that was never dropped, and the field removed. Positive control: the rescuer is applicable and NOT licensed, and that is accepted. Fixture census 3289/0/44 and eligibility census 174/0/0. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 3 +- docs/finding-lineage-decision.md | 26 +++++-- ...opy-record-dominates-the-single-match.json | 8 +- identity/tests/test_lineage_decision.py | 77 +++++++++++++++---- 4 files changed, 90 insertions(+), 24 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 376eec3..cf170e3 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -504,7 +504,8 @@ "signals_defeated": "signal name -> the record that defeated it. TOP LEVEL, and spelled this way everywhere: an earlier revision also called it `defeated_signals` inside `decision_detail`, so two conforming implementations could have produced different JSON while each followed part of this contract. Present whenever step 2 removed anything.", "inputs_unavailable": "signal names that could not be evaluated. TOP LEVEL, like `signals_defeated`, and declared in exactly one place. Present whenever any were.", "evidence_surviving": "the evidence kinds still standing after step 2 removed the defeated ones. TOP LEVEL, like `signals_defeated`, and present whenever a defeat occurred: which kinds survive is what decides whether the refusal is `insufficient-evidence-kind` or `insufficient-evidence-combination`, and a record that omits it leaves a consumer unable to tell an honest limitation from the other one. It may be EMPTY - a defeat can remove everything - so emptiness is a fact, not an absence.", - "boundary_defeated": "boundary kind -> the revision B record that defeated it, for each boundary whose evidence this revision pair carries and whose outcome the mapping did not reach. The senior contract says which record defeats which boundary; this field is where a mapper writes down that it applied one. Present whenever a carried boundary was not honoured: `deleted_paths` proves `ended` unless something says otherwise, and a mapping that concludes `continued` in silence is the absence-of-record defect one layer up." + "boundary_defeated": "boundary kind -> the revision B record that defeated it, for each boundary whose evidence this revision pair carries and whose outcome the mapping did not reach. The senior contract says which record defeats which boundary; this field is where a mapper writes down that it applied one. Present whenever a carried boundary was not honoured: `deleted_paths` proves `ended` unless something says otherwise, and a mapping that concludes `continued` in silence is the absence-of-record defect one layer up.", + "excluded_partners_reached_by": "occurrence id -> the rule id that reaches it, for every partner a `record_binding` exclusion dropped from the record's coverage. Same shape as `signals_defeated` and `boundary_defeated`: the thing, and what explains it. The named rule must be in `applicable_rules` and must conclude the outcome the exclusion's `requires_applicable_outcome` names - it need NOT be in `licensed_by`, because a rule that reached a partner and was then dominated still reached it; that is exactly what happens in `copy-record-dominates-the-single-match`. Naming a partner the exclusion did not drop is rejected. Without this field the promise was discharged at the level of the whole mapping: ONE applicable continued rule covered EVERY dropped partner, so a second same-path successor with a different enclosing symbol rode in on the first one's rescuer while being reached by nothing." }, "preregistered_cases": [ "copy-record-dominates-the-single-match", diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 7800759..7588c31 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -154,14 +154,26 @@ continued rule reaches it either. It was then absorbed into the branch by by copy records and one explained by nothing, and the suite reported OK. So `excluding.partners_where_same_path_holds` now declares -`requires_applicable_outcome: continued`, and where the exclusion actually -removes a partner, the mapping must declare an applicable rule reaching that -outcome. It binds only when something was removed — where no partner sits at the +`requires_applicable_outcome: continued`, and the expectation names, **per +dropped partner**, the rule that reaches it — `excluded_partners_reached_by`, +the same shape as `signals_defeated` and `boundary_defeated`: the thing, and what +explains it. The named rule must be in `applicable_rules` and must conclude that +outcome. It need *not* be in `licensed_by`: a rule that reached a partner and was +then dominated still reached it, which is exactly what +`copy-record-dominates-the-single-match` records. + +The per-partner half was the second attempt. The first asked only whether *some* +applicable rule concluded `continued` — and one such rule then discharged the +promise for every dropped partner at once, so a second same-path successor with a +different enclosing symbol rode in on the first one's rescuer while being reached +by nothing itself. Membership standing in for the correct member, written into +the fix for the previous instance of it. + +It binds only when something was removed — where no partner sits at the predecessor's path the exclusion lifts nothing and assumes nothing, which is why -`a-copy-into-two-new-files-is-a-branch` is untouched by it and -`copy-record-dominates-the-single-match`, whose `R-CONT-SAME-SITE` is exactly the -rule the promise meant, satisfies it. The outcome is named in the contract rather -than spelled in the checker, for the same reason the quantifiers are. +`a-copy-into-two-new-files-is-a-branch` is untouched by it. The outcome is named +in the contract rather than spelled in the checker, for the same reason the +quantifiers are. Making it satisfiable needed a second axis. Git writes **one copy record per destination**, so no single entry can name both successors; a fold is the diff --git a/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json b/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json index 7703386..fca2803 100644 --- a/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json +++ b/identity/fixtures/lineage-decision/copy-record-dominates-the-single-match.json @@ -73,7 +73,10 @@ "R-CONT-COPY": "cardinality 1:1, and the copy record explains a second successor here", "R-MERGE-FOLD": "no merge record, and the shape is 1:N not N:1" }, - "note": "R-CONT-SAME-SITE stays in applicable_rules and out of licensed_by. It proposed `continued` to occ-b1 and lost; recording it as licensing `branched` would attribute a conclusion to a rule that never reached it, and dropping it would lose the recall set - retiring R-BRANCH-COPY later must find exactly this mapping." + "note": "R-CONT-SAME-SITE stays in applicable_rules and out of licensed_by. It proposed `continued` to occ-b1 and lost; recording it as licensing `branched` would attribute a conclusion to a rule that never reached it, and dropping it would lose the recall set - retiring R-BRANCH-COPY later must find exactly this mapping.", + "excluded_partners_reached_by": { + "occ-b1": "R-CONT-SAME-SITE" + } } ], "forbid": [ @@ -81,7 +84,8 @@ "R-CONT-SAME-SITE named in licensed_by", "R-CONT-SAME-SITE missing from applicable_rules - the disagreement IS the record", "unresolved - this pair is classified in dominance, not refused", - "an empty licensed_by on an outcome that is not a refusal" + "an empty licensed_by on an outcome that is not a refusal", + "the exclusion of occ-b1 discharged by any applicable continued rule rather than by the one that reaches occ-b1 - a second same-path successor would then ride in on this one's rescuer" ], "contract": "finding-lineage-decision/v1", "status": "preregistered-unimplemented" diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 89e30d9..f7bf8a7 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -597,6 +597,10 @@ def walk(node): UNAVAILABLE_FIELD: "list", "not_applicable": "object", "signals_defeated": "object", "boundary_defeated": "object", "decision_detail": "object", + # occurrence id -> the rule that reaches it, for partners a record binding + # excluded. Same shape as `signals_defeated` and `boundary_defeated`: the + # thing, and what explains it. + "excluded_partners_reached_by": "object", } DETAIL_KINDS = { "conflicting_rules": "list", "rules_without_a_unique_candidate": "list", @@ -2226,7 +2230,8 @@ def arbitration_leaves(node, trail): # answering it here would be the checker deciding what belongs in a record. validated_record_fields = {"applicable_rules", "licensed_by", "decision_detail", "signals_defeated", UNAVAILABLE_FIELD, - "evidence_surviving", "boundary_defeated"} + "evidence_surviving", "boundary_defeated", + "excluded_partners_reached_by"} missing_declarations = sorted(validated_record_fields - set(policy["record_additions"])) check(not missing_declarations, f"`record_additions` does not declare {missing_declarations}, which this " @@ -2628,19 +2633,44 @@ def arbitration_leaves(node, trail): axes["excluding"].get(spec_r["excluding"])) want_out = excl_spec.get("requires_applicable_outcome") if dropped and want_out: - reached = [r for r in app - if mapping_or_empty(rules.get(r)).get("outcome") - == want_out] - check(bool(reached), - f"{where}: {rid} excludes {shown_ids(dropped, by_id)!r} " - "from the record's coverage because they sit at the " - "predecessor's path - and this mapping declares no " - f"applicable rule that concludes {want_out!r}, so " - "nothing reaches them. The exclusion lifts the " - "record requirement on the promise that another " - "rule carries it; where no such rule applies, the " - "promise is the only thing holding the partner in " - "and it holds nothing.") + # PER PARTNER, and that is the whole difference. + # The first form of this check asked only whether + # SOME applicable rule concluded the outcome, and + # one such rule then discharged the promise for + # every dropped partner at once: a second same-path + # successor with a different enclosing symbol rode + # in on the first one's rescuer, reached by nothing + # itself. Membership standing in for the correct + # member - the sub-form this branch has now found + # more times than any other, written into the fix + # for the previous instance of it. + rescue = mapping_or_empty( + exp.get("excluded_partners_reached_by")) + for o_ in dropped: + named = rescue.get(o_) + check(isinstance(named, str) + and mapping_or_empty(rules.get(named)).get( + "outcome") == want_out + and named in app, + f"{where}: {rid} excludes " + f"{shown_ids([o_], by_id)[0]!r} from the " + "record's coverage because it sits at the " + "predecessor's path, and " + "`excluded_partners_reached_by` names " + f"{named!r} for it - which has to be a rule " + f"this mapping declares applicable AND that " + f"concludes {want_out!r}. The exclusion lifts " + "the record requirement on the promise that " + "another rule carries THIS partner; a rule " + "that carries a different one carries " + "nothing here.") + stray = sorted(set(rescue) - set(dropped)) + check(not stray, + f"{where}: `excluded_partners_reached_by` names " + f"{stray}, which {rid} did not exclude. The field " + "records who the exclusion dropped and who " + "catches them; naming anyone else describes a " + "rescue nothing needed.") pools[role] = pool def shown(ids_): @@ -3186,6 +3216,25 @@ def shown(ids_): others = [v for v in sorted(axes["coverage"]) if v != declared] witness = mapping_or_empty(binding_d.get("coverage_witness")) wcase, wtransform = witness.get("case"), witness.get("transform") + # STRINGS FIRST, because both are about to be used as dictionary keys. + # `wtransform in COVERAGE_TRANSFORMS` raises TypeError on a list, and + # `coverage_inputs.get((wcase, rid))` raises on an unhashable case - the + # thirteenth and fourteenth guards on this branch to die on the input they + # exist to examine, both introduced by the check that closed the twelfth. + # + # The fixture and contract censuses did not see these: both mutate + # CONTAINERS INTO SCALARS and never the reverse, so a string field turning + # into a list is a direction the instrument does not sweep. That is a gap + # in the measurement, not only in the code. + shape_bad = [f"{rid}.record_binding.coverage_witness.{field} is {value!r}, " + "not a string. It is used as a dictionary key, and an " + "unhashable one raises instead of reporting." + for field, value in (("case", wcase), ("transform", wtransform)) + if not isinstance(value, str)] + for msg in shape_bad: + check(False, msg) + if shape_bad: + continue check(wtransform in COVERAGE_TRANSFORMS, f"{rid}.record_binding.coverage_witness names transform " f"{wtransform!r}; this suite can apply " From 4f4f3581b858de9b86159ed6b843e36279b61d04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 06:02:34 +0000 Subject: [PATCH 68/84] contract(lineage): one rescuer cannot catch two partners, and the boundary that remains Both reviewers found the same defect independently, on the fix from the round before: `excluded_partners_reached_by` checked each entry only for the rule's id, its outcome and its membership in `applicable_rules` - so the SAME rescuer answered for every dropped partner, and a second same-path successor with a changed enclosing symbol rode in on the first one's. Confirmed by mutation. Codex found a second: the stray-entry check ran INSIDE the exclusion branch, so an expectation whose licensed rules exclude nothing carried any rescue map it liked, unread - the contract said naming an undropped partner is rejected and the empty case was the one nobody asked. Also confirmed. The stray check now runs once per expectation against every exclusion it made, the empty case included. And a rescuer may not catch more partners in a role than its own `cardinality` relates: a 1:1 rule named for two dropped successors is rejected, because two candidates satisfying a 1:1 rule is `arbitration.multiplicity` - an ambiguity - not two rescues. That is checkable from the shape alone, without this suite deciding applicability. AND THE PART THAT IS NOT FIXED IS WRITTEN DOWN RATHER THAN LEFT TO THE NEXT REVIEW. `excluded_partners_rescuer_rule` states what the map establishes and what it does not: with ONE dropped partner and ONE named 1:1 rescuer, nothing shows that rule reaches THAT occurrence. Showing it means evaluating the rescuer's `requires_all` against the pair, which is applicability, which this suite refuses to compute on purpose - a checker that did would be a second mapper. The section gives the three ways out and what each costs, and says the choice is the owner's. This is the third round in a row on this one clause; disclosing the residue as a decision is different from disclosing it as an asymmetry and moving on, which is what I did two rounds ago and was right to be caught for. While writing that section I cited `what_the_suite_refuses_to_compute`, a key this contract has never had - a cross-reference to nothing, inside a section whose whole subject is a claim nothing checks. Every backticked snake_case name in the contract's prose is now required to be a name the contract actually defines: a section, rule, record field, case obligation, structural signal, evidence kind or binding token. The first form of that gate knew only top-level keys and flagged the real obligation `rule_licensed_alone`; a cross-reference gate that does not know its own vocabulary is worse than none, because it teaches the next reader to silence it. Verified: 5 probes - the repeated 1:1 rescuer, a rescue map where no exclusion ran, a cited name nothing defines, a control citing a real obligation and a real record field (green, no false positive), and a dropped partner left unnamed. Fixture census 3289/0/44, eligibility census 174/0/0. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 40 ++++++++- docs/finding-lineage-decision.md | 27 +++++++ identity/tests/test_lineage_decision.py | 94 ++++++++++++++++++++-- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index cf170e3..e61d0ad 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -1223,5 +1223,43 @@ "this map was first made to unbind the rule it exists for." ], "no_structural_record": "the explicit value for a kind that is a property of the occurrence PAIR rather than a record in revision B. `same_path` compares two paths; no entry in the diff witnesses it. Written out because the map must be TOTAL over the kinds rules require directly - a map that is merely well-formed can be replaced with a valid entry nobody consumes, and then `path_rename` resolves to nothing and its record goes unchecked while every per-entry check still passes." - } + }, + "excluded_partners_rescuer_rule": [ + "WHAT THE RESCUE MAP ESTABLISHES, AND WHAT IT DOES NOT. Both are stated because", + "the gap is real and three rounds of review walked into it in a row.", + "", + "ESTABLISHED. Every partner an exclusion dropped is named. Every named partner", + "was actually dropped - including the case where nothing was dropped, where the", + "field may not appear at all. Each named rescuer is a rule this mapping declares", + "APPLICABLE and one that concludes the outcome the exclusion requires. And no", + "rescuer catches more partners in a role than its own `cardinality` relates: a", + "1:1 rule named for two dropped successors is rejected, because two candidates", + "satisfying a 1:1 rule is `arbitration.multiplicity` - an ambiguity - not two", + "rescues.", + "", + "NOT ESTABLISHED. That a named rescuer's application actually INCLUDES the", + "partner it is named for. With one dropped partner and one named 1:1 rescuer,", + "nothing here shows that rule reaches THAT occurrence rather than some other.", + "Showing it means evaluating the rescuer's `requires_all` against the pair -", + "deciding which rules a record produces, which is APPLICABILITY, and", + "`rule_coverage_rule` says this suite refuses to compute it. A checker that", + "did would be a second mapper: hidden, unversioned, and disagreeing with the", + "first one the day either changed.", + "", + "So the residue is a DELIBERATE BOUNDARY, not an oversight, and closing it is a", + "decision about what the integrity suite is allowed to be - not a repair anyone", + "should make quietly. Three ways out, none of them free:", + "", + " - let the suite compute applicability for THIS narrow purpose, and accept a", + " second implementation of the evidence predicates alongside the mapper's;", + " - have the fixture declare the rescuing relation as its own preregistered", + " expectation, which the corpus cannot currently express: in", + " `copy-record-dominates-the-single-match` the rescuer REACHED its partner and", + " was then dominated, so there is no `continued` mapping to point at;", + " - remove the exclusion, and require every successor to be named by a copy", + " record - which rejects that same case, the one the exclusion exists for.", + "", + "Until it is decided, an exclusion rests on a rescuer named by the fixture and", + "checked for everything except the one thing only a mapper can answer." + ] } diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 7588c31..1f2a271 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -175,6 +175,33 @@ predecessor's path the exclusion lifts nothing and assumes nothing, which is why in the contract rather than spelled in the checker, for the same reason the quantifiers are. +And **one rescuer cannot catch two partners**. Naming a rule per partner was not +enough on its own: the same rescuer answered for every dropped partner, so a +second same-path successor with a changed enclosing symbol rode in on the first +one's. What is checkable without the suite becoming a second mapper is the +*shape* — a rule whose cardinality relates one occurrence in a role cannot relate +two of them in one mapping, because two candidates satisfying a 1:1 rule is an +ambiguity, not two rescues. + +#### What the rescue map does not establish + +That a named rescuer's application actually *includes* the partner it is named +for. With one dropped partner and one named 1:1 rescuer, nothing shows that rule +reaches *that* occurrence rather than some other. Showing it would mean +evaluating the rescuer's `requires_all` against the pair — deciding which rules a +record produces, which is applicability, and which this suite refuses to compute +on purpose. + +The residue is a deliberate boundary, not an oversight, and it is written down in +`excluded_partners_rescuer_rule` with the three ways out and what each costs: +let the suite compute applicability for this one purpose and accept a second +implementation of the evidence predicates; have the fixture declare the rescuing +relation as its own expectation, which the corpus cannot currently express +because in `copy-record-dominates-the-single-match` the rescuer reached its +partner and was then *dominated*; or remove the exclusion entirely, which rejects +the very case it exists for. Closing it is a decision about what the integrity +suite is allowed to be, and it belongs to the repository owner. + Making it satisfiable needed a second axis. Git writes **one copy record per destination**, so no single entry can name both successors; a fold is the opposite, one record naming all of its sources, and two records naming one each diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index f7bf8a7..3b063d5 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2572,6 +2572,13 @@ def arbitration_leaves(node, trail): # quantifiers live inside one entry: a fold split into # `WireA -> WireA` and `WireB -> WireA` describes two separate folds, # and two records do not add up to one transformation. + # WHAT THE EXCLUSIONS DROPPED, ACROSS THE WHOLE EXPECTATION. The + # stray-entry check used to live inside the exclusion branch, so an + # expectation whose licensed rules exclude NOTHING carried any + # `excluded_partners_reached_by` it liked, unread - the contract said + # naming an undropped partner is rejected and the empty case was the + # one nobody asked. Collected here, judged once, below. + dropped_all: dict = {} for rid in lic: rule_r = rules[rid] binding = rule_r.get("record_binding") @@ -2632,6 +2639,8 @@ def arbitration_leaves(node, trail): excl_spec = mapping_or_empty( axes["excluding"].get(spec_r["excluding"])) want_out = excl_spec.get("requires_applicable_outcome") + for o_ in dropped: + dropped_all.setdefault(o_, (rid, role, want_out)) if dropped and want_out: # PER PARTNER, and that is the whole difference. # The first form of this check asked only whether @@ -2664,13 +2673,6 @@ def arbitration_leaves(node, trail): "another rule carries THIS partner; a rule " "that carries a different one carries " "nothing here.") - stray = sorted(set(rescue) - set(dropped)) - check(not stray, - f"{where}: `excluded_partners_reached_by` names " - f"{stray}, which {rid} did not exclude. The field " - "records who the exclusion dropped and who " - "catches them; naming anyone else describes a " - "rescue nothing needed.") pools[role] = pool def shown(ids_): @@ -2713,6 +2715,52 @@ def shown(ids_): f"successors {shown(pools['successor'])!r}; the records hold " f"{entries!r}.") + # THE RESCUE MAP, JUDGED AGAINST EVERY EXCLUSION THIS EXPECTATION MADE + # - the empty case included. `an-edit-elsewhere-is-still-the-same-defect` + # excludes nobody and accepted `{"occ-nobody": "R-MERGE-FOLD"}` in + # silence, because the only reader of the field ran inside a branch + # that case never entered. + rescue_all = mapping_or_empty(exp.get("excluded_partners_reached_by")) + stray = sorted(set(rescue_all) - set(dropped_all)) + check(not stray, + f"{where}: `excluded_partners_reached_by` names {stray}, which no " + "exclusion in this expectation dropped. The field records who an " + "exclusion dropped and who catches them; naming anyone else " + "describes a rescue nothing needed - and an expectation that " + "excludes NOBODY may not carry the field at all.") + # ONE 1:1 RESCUER CANNOT CATCH TWO PARTNERS. The map was checked + # partner by partner, and each entry only for the rule's id, outcome + # and membership in `applicable_rules` - so the SAME rescuer answered + # for every dropped partner, and a second same-path successor with a + # changed enclosing symbol rode in on the first one's. Both reviewers + # found this independently, on the fix for the round before. + # + # What is checkable WITHOUT this suite becoming a second mapper is the + # SHAPE: a rule whose cardinality relates one occurrence in a role + # cannot relate two of them in one mapping. Two candidates satisfying a + # 1:1 rule is `arbitration.multiplicity` - an ambiguity, not two + # rescues. See `excluded_partners_rescuer_rule` for what this still does + # NOT establish. + per_rescuer: dict = {} + for partner, named in sorted(rescue_all.items()): + if isinstance(named, str) and partner in dropped_all: + per_rescuer.setdefault(named, []).append(partner) + for named, partners in sorted(per_rescuer.items()): + shape = str(mapping_or_empty( + mapping_or_empty(rules.get(named)).get("cardinality")).get("shape")) + role = dropped_all[partners[0]][1] + side = shape.partition(":")[0 if role == "predecessor" else 2] + if side == "1" and len(partners) > 1: + check(False, + f"{where}: `excluded_partners_reached_by` names {named!r} as " + f"the rescuer of {sorted(partners)} - {len(partners)} " + f"partners in the {role} role - and {named} declares " + f"cardinality {shape!r}, which relates ONE. A 1:1 rule " + "reaching two occurrences of this mapping is an ambiguity " + "under `arbitration.multiplicity`, not two rescues; one of " + "them is being absorbed on a rescuer that cannot have " + "reached it.") + # A REFUSED CONFLICT LICENSES NOTHING, and the loop above therefore # skipped it entirely - so `copy-source-that-is-also-a-fold-refuses` # kept its `conflicting-evidence` while its copy record pointed at @@ -3180,6 +3228,38 @@ def shown(ids_): # A declared refusal with no case is a decision nothing pins - the defect this # project keeps finding in its own drafts. The N:M pair was frozen in the # contract and exercised by no fixture at all until this check was written. + # EVERY BACKTICKED TOP-LEVEL KEY A PROSE BLOCK CITES HAS TO EXIST. I wrote + # `what_the_suite_refuses_to_compute` into a new section this round - a + # section whose whole subject is a claim nothing checks - and the key it + # names has never existed. A cross-reference to nothing is the same defect as + # a declaration nothing reads, and cheaper to catch. + # THE NAMES THIS CONTRACT DEFINES, from everywhere it defines them - not + # top-level keys alone. The first form of this check knew only top-level keys + # and flagged `rule_licensed_alone`, which is a real case obligation: a + # cross-reference gate that does not know the vocabulary it polices is worse + # than none, because it teaches the next reader to silence it. + defined = set(policy) | set(rules) | set(policy["record_additions"]) + defined |= set(mapping_or_empty(policy["case_obligations"]).get("meanings") or {}) + defined |= set(policy["structural_signals"]) | set(senior["evidence_kinds"]) + defined |= set(senior) | set(senior["outcomes"]) + for axis in ("quantifier", "excluding", "coverage"): + defined |= set(mapping_or_empty(vocab.get(axis))) + defined |= set(EXPECTATION_KINDS) | set(OCCURRENCE_KINDS) | set(FIXTURE_KINDS) + for section in sorted(policy): + for line in list_or_empty(policy[section]): + if not isinstance(line, str): + continue + for cited in sorted(set(re.findall(r"`([a-z][a-z0-9]*(?:_[a-z0-9]+){2,})`", + line))): + check(cited in defined, + f"`{section}` cites `{cited}`, which is not a name this contract " + "defines anywhere - not a section, rule, record field, " + "obligation, signal, evidence kind or binding token. A " + "cross-reference to nothing reads as though something were " + "checking it, which is how " + "`what_the_suite_refuses_to_compute` got written into a section " + "whose whole subject is a claim nothing checks.") + for pair in sorted(({frozenset(c["between"]) for c in policy["deliberately_unresolved_conflicts"]}), key=sorted): check(pair in exercised_refusals, From c221c95d4ed2dc3af96622649d996fbc6ea63710 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 06:35:42 +0000 Subject: [PATCH 69/84] contract(lineage): the cross-reference gate reads all prose, and one real dangling name Three findings, all on checks I added last round, all confirmed by mutation. BOTH REVIEWERS, INDEPENDENTLY: the cross-reference gate read only direct string entries of top-level LIST-valued sections, so `rules.*.why`, `record_binding_vocabulary.excluding.*.why`, `record_additions.*` and every object-valued section went unread; and its regex demanded two underscores, so `missing_reference` walked through the part it did read. A gate covering part of what its own declaration claims - written into the gate whose entire subject is claims nothing checks. It now walks every string leaf of the policy and recognises any snake_case name. The universe it checks against is every key EITHER contract carries at any depth, plus the fixture schema, plus the revision-B field names the contract declares through its own signal catalog and the senior boundary kinds, plus `retired_names`. Its own second draft was worse than the first in one way, and that is worth keeping: matching the identifier pattern anywhere in a line found `excluded_by_cardinality` INSIDE the correct reference `decision_detail.rules_excluded_by_cardinality` - the gate inventing a dangling reference out of a sound one, which would have been reported as a contract defect and "fixed" by editing prose that was already right. Whole backticked spans are now parsed segment by segment. Run properly it found ONE real defect in the contract: a `why` contrasting two classifications named `at_or_above_the_floor` by a truncation of it, so the sentence pointed at no branch of the mapping it was describing. Corrected. Everything else it flagged was a genuine nested key, a fixture field, or a name the contract mentions BECAUSE IT IS GONE - and those are now declared in `retired_names` with a rule saying that listing one is a claim the prose is history, not a way to silence the gate. CODEX, SEPARATELY: `excluded_partners_reached_by: {}` passed on an expectation that dropped nobody, though the diagnostic one line above said the field may not appear at all. The message claimed more than the code, again. Absence is now required where nothing was dropped: an empty map and an absent one would be two spellings of one fact, and a record field with two spellings is one a reader has to test twice. FIFTEENTH GUARD TO DIE ON ITS OWN INPUT, mine, added in this same change: `set(list_or_empty(policy.get("retired_names")))` raises on an unhashable element. Found by the contract census, which sees it only because it now sweeps scalar -> container - the direction it was blind to until last round. Contract census back to 361 from 367; the six it added were all this. Verified: 9 probes - an empty rescue map with nothing dropped, dangling names in `rules.*.note` and in a deeply nested `why` list, a single-underscore name, a retired name removed from the list, three malformed `retired_names` entries, and a control confirming real dotted references like `decision_detail.rules_excluded_by_cardinality` and `revision_b.copies[].from` do not fire. Fixture census 3289/0/44, eligibility 174/0/0, contract census 361. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 31 +++++- docs/finding-lineage-decision.md | 31 ++++++ identity/tests/test_lineage_decision.py | 113 +++++++++++++++++---- 3 files changed, 151 insertions(+), 24 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index e61d0ad..33d1a18 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -643,7 +643,7 @@ "contract's own exception was honoured: a surviving kind with", "`sufficient_alone: true` clears the floor and the count does not apply to it.", "A mapper reading this section would have classified such a defeat", - "`below_the_floor` while the integrity suite classified it `at_or_above`.", + "`below_the_floor` while the integrity suite classified it `at_or_above_the_floor`.", "", "The exception was taught to four checker call sites and to none of the places", "the CONTRACT states it - which is the same claim at a fifth site, found by a", @@ -940,7 +940,7 @@ "What each case must MECHANICALLY exhibit, beyond reaching the right answer.", "Without this a case can be right for the wrong reason and stay green: drop", "R-CONT-SAME-SITE out of `copy-record-dominates-the-single-match` and into", - "`not_applicable`, and the fixture still reports `branched` licensed by", + [], "R-BRANCH-COPY - correct, and no longer showing dominance doing any work at", "all. The suite cannot catch that on its own, because deciding which rules", "really fired is applicability, and a checker doing applicability is a second", @@ -1261,5 +1261,30 @@ "", "Until it is decided, an exclusion rests on a rescuer named by the fixture and", "checked for everything except the one thing only a mapper can answer." + ], + "retired_names": [ + "requires_all_scope", + "defeated_signals", + "excluded_by_cardinality" + ], + "retired_names_rule": [ + "NAMES THIS CONTRACT MENTIONS BECAUSE THEY NO LONGER EXIST. Each is cited in", + "prose that explains what it was and why it went, and each would otherwise fail", + "the cross-reference gate as a dangling reference.", + "", + "Listing them is not a way of silencing the gate. It is the difference between a", + "reference the contract keeps ON PURPOSE - `requires_all_scope` was prose nothing", + "read and `record_binding` replaced it; `defeated_signals` was the same field", + "under a second spelling; `excluded_by_cardinality` was a top-level twin of", + "`decision_detail.rules_excluded_by_cardinality` - and one nobody noticed had", + "gone stale. Adding a name here is a claim that the prose citing it is HISTORY,", + "and a reader can check that claim against the sentence.", + "", + "The gate that needs this list found exactly one real defect on the corpus it was", + "first run against: a `why` contrasting two classifications named", + "`at_or_above_the_floor` by a truncation of it, so the sentence pointed at no", + "branch of the mapping it was describing. That was a TYPO, not a retired name,", + "and it was corrected rather than listed here - which is the distinction this", + "section exists to keep." ] -} +} \ No newline at end of file diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 1f2a271..99d4e06 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -236,6 +236,37 @@ also wrong, and in an instructive way: it required only that the readings be flipped — a law proving two options differ says nothing about which was taken. The verdict is the half that makes it bite. +### And a smaller law: no cross-reference to nothing + +The contract's prose is full of backticked names — sections, rules, record +fields, obligations, signals, evidence kinds, binding tokens. Every one of them +must now be a name one of the two contracts actually defines, at any depth, or a +fixture field, or a name declared in `retired_names`. + +The gate exists because I wrote `what_the_suite_refuses_to_compute` into a +section whose entire subject is claims nothing checks. The key has never existed. + +Its own first two drafts are the more useful lesson. The first read only +top-level list-valued sections and demanded two underscores, so `rules.*.why`, +every object-valued section and any name like `missing_reference` went unread — +a gate covering part of what its declaration claimed, written into the gate +about claims nothing checks. The second matched the identifier pattern anywhere +in a line and so found `excluded_by_cardinality` *inside* the correct reference +`decision_detail.rules_excluded_by_cardinality`, inventing a dangling reference +out of a sound one; whole backticked spans are now parsed segment by segment. + +`retired_names` carries the names the contract mentions *because they are gone* — +`requires_all_scope`, replaced by `record_binding`; `defeated_signals`, a second +spelling of `signals_defeated`; `excluded_by_cardinality`, a top-level twin of +`decision_detail.rules_excluded_by_cardinality`. Listing a name there is a claim +that the prose citing it is history, and a reader can check that claim against +the sentence. It is not a way to silence the gate. + +Run against the corpus for the first time, it found exactly one real defect: a +`why` contrasting two classifications named `at_or_above_the_floor` by a +truncation of it, so the sentence pointed at no branch of the mapping it was +describing. + ### 4. What the suite refuses to compute `identity/tests/test_lineage_decision.py` never decides which rules a real diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 3b063d5..b5986de 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2721,6 +2721,16 @@ def shown(ids_): # silence, because the only reader of the field ran inside a branch # that case never entered. rescue_all = mapping_or_empty(exp.get("excluded_partners_reached_by")) + # ABSENT, not empty. The diagnostic below says the field "may not + # appear at all" where nothing was dropped, and `{}` satisfied the + # stray check because both sets were empty - the message claiming more + # than the code, one line above the code. Two conforming mappers would + # then disagree about whether to write the key. + check(not (not dropped_all and "excluded_partners_reached_by" in exp), + f"{where}: no exclusion in this expectation dropped anybody, and it " + "still carries `excluded_partners_reached_by`. An empty map and an " + "absent one would be two spellings of the same fact, and a record " + "field with two spellings is one a reader has to test twice.") stray = sorted(set(rescue_all) - set(dropped_all)) check(not stray, f"{where}: `excluded_partners_reached_by` names {stray}, which no " @@ -3238,27 +3248,88 @@ def shown(ids_): # and flagged `rule_licensed_alone`, which is a real case obligation: a # cross-reference gate that does not know the vocabulary it polices is worse # than none, because it teaches the next reader to silence it. - defined = set(policy) | set(rules) | set(policy["record_additions"]) - defined |= set(mapping_or_empty(policy["case_obligations"]).get("meanings") or {}) - defined |= set(policy["structural_signals"]) | set(senior["evidence_kinds"]) - defined |= set(senior) | set(senior["outcomes"]) - for axis in ("quantifier", "excluding", "coverage"): - defined |= set(mapping_or_empty(vocab.get(axis))) - defined |= set(EXPECTATION_KINDS) | set(OCCURRENCE_KINDS) | set(FIXTURE_KINDS) - for section in sorted(policy): - for line in list_or_empty(policy[section]): - if not isinstance(line, str): - continue - for cited in sorted(set(re.findall(r"`([a-z][a-z0-9]*(?:_[a-z0-9]+){2,})`", - line))): - check(cited in defined, - f"`{section}` cites `{cited}`, which is not a name this contract " - "defines anywhere - not a section, rule, record field, " - "obligation, signal, evidence kind or binding token. A " - "cross-reference to nothing reads as though something were " - "checking it, which is how " - "`what_the_suite_refuses_to_compute` got written into a section " - "whose whole subject is a claim nothing checks.") + # EVERY KEY EITHER CONTRACT CARRIES, AT ANY DEPTH - not the top level. The + # first form of this gate read `set(policy)` and scanned only direct string + # entries of top-level LIST-valued sections, so `rules.*.why`, + # `record_binding_vocabulary.excluding.*.why` and every object-valued section + # went unread; and its regex demanded two underscores, so `missing_reference` + # walked through the part it did read. A gate covering part of what its own + # declaration claims - written, this time, into the gate whose entire subject + # is claims nothing checks. Both reviewers found it independently. + def every_key(value) -> set: + out: set = set() + if isinstance(value, dict): + for key, item in value.items(): + out.add(key) + out |= every_key(item) + elif isinstance(value, list): + for item in value: + out |= every_key(item) + return out + + def string_leaves(value, trail=()): + if isinstance(value, dict): + for key, item in value.items(): + yield from string_leaves(item, trail + (str(key),)) + elif isinstance(value, list): + for pos, item in enumerate(value): + yield from string_leaves(item, trail + (str(pos),)) + elif isinstance(value, str): + yield ".".join(trail), value + + defined = every_key(policy) | every_key(senior) + defined |= set(senior["evidence_kinds"]) | set(senior["outcomes"]) + # ...and the FIXTURE vocabulary, which the contract legitimately cites and + # which lives in this suite's schema tables rather than in either document. + for table in (EXPECTATION_KINDS, OCCURRENCE_KINDS, FIXTURE_KINDS, REVISION_KINDS, + DETAIL_KINDS, ELIGIBILITY_KINDS, ELIGIBILITY_FINDING_KINDS, + ELIGIBILITY_EXPECT_KINDS): + defined |= set(table) + defined |= {f.split(".")[-1] for f in (UNAVAILABLE_FIELD,)} + # ...and the revision-B field names, which the contract defines through its + # own catalog rather than as keys: `revision_b.merged_symbols` is where + # `merged_symbols` is declared. + specs = list(policy["structural_signals"].values()) + [policy["unavailable_inputs"]] + specs += list(mapping_or_empty(senior.get("boundary_evidence_kinds")).values()) + for spec in specs: + for read in (mapping_or_empty(spec).get("observable_from"), + mapping_or_empty(spec).get("defeated_by")): + for one in str(read or "").replace("[]", "").split(","): + defined |= {seg.strip() for seg in one.split(".") if seg.strip()} + # STRINGS, CHECKED AND THEN USED. `set(list_or_empty(...))` raises on an + # unhashable element - the fifteenth guard on this branch to die on the input + # it exists to examine, added in the same change that closed the thirteenth + # and fourteenth, and caught by the census rather than by reading it. The + # census only sees it because it now sweeps scalar -> container, which is the + # direction it was blind to until this round. + for pos, name_ in enumerate(list_or_empty(policy.get("retired_names"))): + check(isinstance(name_, str) and name_.strip(), + f"`retired_names[{pos}]` is {name_!r}. Each entry is a name the " + "contract mentions because it no longer exists; a container names " + "nothing and cannot be compared against a citation.") + defined |= {n_ for n_ in list_or_empty(policy.get("retired_names")) + if isinstance(n_, str)} + # WHOLE BACKTICKED SPANS, then segment by segment. Matching the identifier + # pattern anywhere in the line found `excluded_by_cardinality` INSIDE + # `decision_detail.rules_excluded_by_cardinality` - the gate inventing a + # dangling reference out of a correct one, which would have been reported as + # a contract defect and "fixed" by editing prose that was already right. + for where_, line in string_leaves(policy): + cited_names = set() + for span in re.findall(r"`([a-z][A-Za-z0-9_.\[\]]*)`", line): + for seg in span.replace("[]", "").split("."): + if re.fullmatch(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)+", seg): + cited_names.add(seg) + for cited in sorted(cited_names): + check(cited in defined, + f"`{where_}` cites `{cited}`, which is not a name either contract " + "defines at any depth, nor a fixture field, nor a declared retired " + "name. A cross-reference to nothing reads as though something were " + "checking it - which is how `what_the_suite_refuses_to_compute` came " + "to be cited in a section whose whole subject is claims nothing " + "checks. A name the contract deliberately mentions because it NO " + "LONGER exists belongs in `retired_names`, where it is recorded " + "rather than merely tolerated.") for pair in sorted(({frozenset(c["between"]) for c in policy["deliberately_unresolved_conflicts"]}), key=sorted): From 9cf5559dcbac47e711c5c999862087ddc2a6e824 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 06:54:42 +0000 Subject: [PATCH 70/84] fix(lineage): restore a sentence my own census deleted, and stop it happening again CodeRabbit found `case_obligations.why[3]` was an empty list where a sentence carrying a `not_applicable` reference had been. It was not a contract defect: I put it there. My extended contract census rewrites real files in place, a ten-minute tool timeout killed it mid-run before it reached its restore line, and I committed the damage in c221c95 because I read `git diff --stat` and took the line count for my own edits. Two later census runs then re-read the damaged file as their baseline and restored to it, which is why it survived. The sentence is restored verbatim from 4f4f358. An audit comparing every JSON file at HEAD against 4f4f358 for strings that became containers finds nothing else, and the only other removal in the contract since then is the intended `at_or_above` correction. THE CHECK THAT WOULD HAVE CAUGHT IT. The cross-reference walk visits string leaves, so a hole in a paragraph reads as a shorter paragraph - a hole is not a string. Any list holding prose must hold prose in every position, at any depth, in both contracts. Its first two drafts are the point. The first ran over top-level sections only, and the hole it was written for is at `case_obligations.why`, one level down: it would have shipped without catching the defect its own message cites. Sixteenth time on this branch a check has covered part of what it claims, and the second inside a check written about exactly that. The second keyed on `value[0]` being a string, so a hole AT INDEX 0 stopped the list looking like prose at all - blind to precisely the position it was least able to recover, found by probing index 0 after three other positions passed. THE HARNESS IS NOW CRASH-SAFE. All four censuses stash originals up front and restore from an atexit hook and on SIGTERM/SIGINT/SIGHUP. Verified by killing both the contract census and the fixture census mid-run: the tree comes back clean. The probe discipline on this branch already said never to use `git checkout` to restore; it did not say a mutation harness must survive its own death, and that is the rule this cost. Contract census GREEN falls from 2196 to 267: the prose-hole check turns 1929 previously silent scalar-into-container mutations into clean failures. Crashes unchanged at 361. Fixture census 3289/0/44, eligibility 174/0/0. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 4 +- identity/tests/test_lineage_decision.py | 48 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 33d1a18..2a504f3 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -940,7 +940,7 @@ "What each case must MECHANICALLY exhibit, beyond reaching the right answer.", "Without this a case can be right for the wrong reason and stay green: drop", "R-CONT-SAME-SITE out of `copy-record-dominates-the-single-match` and into", - [], + "`not_applicable`, and the fixture still reports `branched` licensed by", "R-BRANCH-COPY - correct, and no longer showing dominance doing any work at", "all. The suite cannot catch that on its own, because deciding which rules", "really fired is applicability, and a checker doing applicability is a second", @@ -1287,4 +1287,4 @@ "and it was corrected rather than listed here - which is the distinction this", "section exists to keep." ] -} \ No newline at end of file +} diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index b5986de..95ec30b 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -3277,6 +3277,54 @@ def string_leaves(value, trail=()): elif isinstance(value, str): yield ".".join(trail), value + # A PROSE LIST HOLDS PROSE. This walk SKIPS anything that is not a string, so + # a paragraph with a hole in it reads as a shorter paragraph - and one + # arrived: `case_obligations.why[3]` was an empty list in the commit before + # this, where a sentence carrying a `not_applicable` reference had been. My + # own contract census put it there when a timeout killed it mid-run, and I + # committed it because I read `git diff --stat` instead of the diff. The gate + # that walks this prose could not see the hole, because a hole is not a + # string. Reported by review, not by anything here. + # + # Every entry of a top-level list of strings must be a string: if the first + # entry is prose, they all are. That is narrow on purpose - `preregistered_cases` + # and `retired_names` are lists of names, and `deliberately_unresolved_conflicts` + # is a list of objects, and none of them should be dragged into a rule about + # paragraphs. + # AT ANY DEPTH. The first form of this ran over top-level sections only - and + # the hole it was written for is at `case_obligations.why`, one level down, + # so it would have shipped without catching the defect it cites. Sixteenth + # time on this branch that a check has covered part of what its own message + # claims, and the second time inside a check written about exactly that. + def prose_holes(value, trail=()): + out = [] + if isinstance(value, dict): + for key, item in value.items(): + out += prose_holes(item, trail + (str(key),)) + elif isinstance(value, list): + # ANY string in the list, not the FIRST. Keying on `value[0]` meant a + # hole AT INDEX 0 stopped the list from looking like prose at all - + # the check blind to precisely the position it was least able to + # recover, found by probing index 0 after the other three positions + # passed. + if any(isinstance(entry, str) for entry in value): + for pos, entry in enumerate(value): + if not isinstance(entry, str): + out.append((".".join(trail), pos, entry)) + for pos, item in enumerate(value): + out += prose_holes(item, trail + (str(pos),)) + return out + + for doc_name, doc in (("decision policy", policy), ("outcome contract", senior)): + for where_, pos, entry in prose_holes(doc): + check(False, + f"the {doc_name}'s `{where_}[{pos}]` is {entry!r}, and other " + "entries of that list are lines of prose. A container in the middle " + "of a paragraph is a sentence that was deleted without anyone " + "reading the paragraph afterwards - which is exactly how a " + "`not_applicable` reference went missing from `case_obligations.why` " + "and was committed.") + defined = every_key(policy) | every_key(senior) defined |= set(senior["evidence_kinds"]) | set(senior["outcomes"]) # ...and the FIXTURE vocabulary, which the contract legitimately cites and From f6f87c52bbd32dcc694d4c1ac4f3b45f619b65d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 07:38:45 +0000 Subject: [PATCH 71/84] test(lineage): identify prose by the field's name, never by what is left in it Both reviewers, independently, on the check I added last round: replacing an entire prose list with `[]`, `[[]]` or `[{}]` produces no failure, because the guard asked whether ANY entry was still a string. Emptying the field stops it looking like prose at all - so the check written for `case_obligations.why` reported nothing when that exact field was erased. Confirmed on all four shapes, plus Codex's `notes = [[]]`. Third draft of one check inside two rounds, and the three drafts are the lesson: asked `value[0]`, then asked ANY entry, and both are defeated by changing the data the guard is reading. A prose field is now identified by its NAME - `why`, `notes`, `note`, `*_why`, `*_note`, `*_notes`, `*_rule` - and must be a string or a NON-EMPTY list of strings. Emptiness is a failure rather than a way out. `*_rule` is the one ambiguous suffix: this contract uses it for prose and for two machine-readable declarations. `eligibility.is_not_a_rule` and `arbitration.licensed_by_rule` are named as exemptions rather than inferred, AND the exemption list is itself checked - each path must exist and really be the structured kind claimed, so a prose field cannot be exempted by adding its path. THE CENSUS CAUGHT A REGRESSION BEFORE THIS WAS COMMITTED, which is the whole point of running it first. Replacing the content rule with the name rule moved 335 mutations from clean failure to silent pass: the name rule cannot see a hole in a list that is not prose-named - `preregistered_cases`, `requires_all`, `forbids` - and the content rule cannot see a prose field emptied. Neither subsumes the other, so both now run. Contract census GREEN falls to 168, from 267 last round and from 602 with the name rule alone; crashes unchanged at 361. Verified: 13 probes re-run against the final code, no escapes and no new crashes - both reviewers' witnesses, the original damage at `why[3]`, an emptied senior `notes`, a deeply nested `record_binding.why`, and both exemption-integrity cases. Two probes crash on pre-existing sites inside the held-back 361 and do so identically at the committed HEAD, so they are not this change. Fixture census 3289/0/44, eligibility 174/0/0. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 87 ++++++++++++++++++++----- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 95ec30b..bb45244 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -3296,34 +3296,89 @@ def string_leaves(value, trail=()): # so it would have shipped without catching the defect it cites. Sixteenth # time on this branch that a check has covered part of what its own message # claims, and the second time inside a check written about exactly that. - def prose_holes(value, trail=()): + # PROSE IS IDENTIFIED BY THE FIELD'S NAME, NEVER BY WHAT IS CURRENTLY IN IT. + # Two earlier drafts asked the contents: the first whether `value[0]` was a + # string, the second whether ANY entry was. Both are defeated by emptying the + # field - `case_obligations.why = []` or `notes = [[]]` erases a whole section + # and stops it looking like prose at all, so the guard written for exactly that + # field reported nothing. Both reviewers found it, independently, and both + # named the same remedy: classify the field, do not interrogate its contents. + # + # A prose-named key must be a string, or a NON-EMPTY list of strings. Nothing + # else, and emptiness is a failure rather than a way out. + def prose_named(key: str) -> bool: + return key in ("why", "notes", "note") or bool( + re.search(r"_(why|note|notes|rule)$", key)) or key == "rule" + + # `*_rule` is the one ambiguous suffix: this contract uses it for prose AND + # for two machine-readable declarations. They are named here rather than + # inferred, and the naming is itself checked below - an exception list that + # could quietly cover a prose field would be the hole one level up. + STRUCTURED_PROSE_NAMED = { + "eligibility.is_not_a_rule": bool, + "arbitration.licensed_by_rule": dict, + } + + def prose_faults(value, trail=()): out = [] if isinstance(value, dict): for key, item in value.items(): - out += prose_holes(item, trail + (str(key),)) + here = ".".join(trail + (str(key),)) + if prose_named(str(key)) and here not in STRUCTURED_PROSE_NAMED: + if isinstance(item, str): + pass + elif not isinstance(item, list) or not item: + out.append((here, f"is {item!r}", "a prose field is a " + "sentence or a non-empty list of them")) + else: + for pos, entry in enumerate(item): + if not isinstance(entry, str): + out.append((f"{here}[{pos}]", f"is {entry!r}", + "every line of a paragraph is a line")) + out += prose_faults(item, trail + (str(key),)) elif isinstance(value, list): - # ANY string in the list, not the FIRST. Keying on `value[0]` meant a - # hole AT INDEX 0 stopped the list from looking like prose at all - - # the check blind to precisely the position it was least able to - # recover, found by probing index 0 after the other three positions - # passed. + # AND THE CONTENT RULE, KEPT ALONGSIDE THE NAME RULE. They catch + # different things and neither subsumes the other: the name rule sees + # a prose field EMPTIED, which the content rule cannot; the content + # rule sees a hole in any list of strings whatever its field is + # called - `preregistered_cases`, `requires_all`, `forbids` - which + # the name rule does not reach. + # + # Replacing the content rule with the name rule alone moved 335 + # mutations from clean failure to silent pass. The contract census + # reported it BEFORE this was committed, which is the only reason it + # is not the next round's finding. if any(isinstance(entry, str) for entry in value): for pos, entry in enumerate(value): if not isinstance(entry, str): - out.append((".".join(trail), pos, entry)) + out.append((f"{'.'.join(trail)}[{pos}]", f"is {entry!r}", + "the other entries of that list are strings")) for pos, item in enumerate(value): - out += prose_holes(item, trail + (str(pos),)) + out += prose_faults(item, trail + (str(pos),)) return out for doc_name, doc in (("decision policy", policy), ("outcome contract", senior)): - for where_, pos, entry in prose_holes(doc): + for where_, saw, why_ in prose_faults(doc): check(False, - f"the {doc_name}'s `{where_}[{pos}]` is {entry!r}, and other " - "entries of that list are lines of prose. A container in the middle " - "of a paragraph is a sentence that was deleted without anyone " - "reading the paragraph afterwards - which is exactly how a " - "`not_applicable` reference went missing from `case_obligations.why` " - "and was committed.") + f"the {doc_name}'s `{where_}` {saw}, and {why_}. A container where " + "a sentence belongs is a sentence deleted without anyone reading " + "the paragraph afterwards - which is how a `not_applicable` " + "reference went missing from `case_obligations.why` and was " + "committed. An EMPTY prose list is the same erasure with no " + "survivor to notice it by.") + # THE EXCEPTION LIST, CHECKED. Each entry must exist and must really be the + # structured kind claimed - otherwise a prose field could be exempted by + # adding its path here, which is the hole this list would otherwise open. + for path_, kind_ in sorted(STRUCTURED_PROSE_NAMED.items(), key=lambda kv: kv[0]): + node = policy + for seg in path_.split("."): + node = mapping_or_empty(node).get(seg) + check(isinstance(node, kind_), + f"`{path_}` is exempt from the prose rule as a {kind_.__name__}, and it " + f"is {type(node).__name__}. The exemption exists because two " + "`*_rule` fields are machine-readable declarations, not paragraphs; an " + "exemption that no longer describes its field is a prose field nobody " + "is checking.") defined = every_key(policy) | every_key(senior) defined |= set(senior["evidence_kinds"]) | set(senior["outcomes"]) From 738b8f1124e41c21b13d6441b1381bbc70fcf065 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:27:34 +0000 Subject: [PATCH 72/84] test(lineage): a prose field has to say something Both reviewers, independently, on the check from the round before: a prose field replaced by `""` or `[""]` keeps the shape the name rule accepts and erases the section anyway. Confirmed on `case_obligations.why = [""]`, `[" "]`, `""`, on `eligibility.record_shape_rule = ""`, and on Codex's `eligibility.why = [""]`. Fourth way to empty one field that this single check has been shown to permit, and the fourth found by review rather than here. The rule specified a SHAPE each time, and a shape is satisfiable vacuously. Scalar prose must now carry non-whitespace text. A prose list must be non-empty, hold only strings, and hold at least one entry with text in it. Blank entries stay legal INSIDE a list - 53 of them across 16 lists are paragraph separators, and a control probe confirms a blank between two paragraphs is still accepted. Verified before commit, not after: 9 probes, no escapes, plus that control. Contract census 361 crashes / 168 green - identical to the shipped round-58 figures, so the added coverage costs nothing. Fixture census 3289/0/44, eligibility 174/0/0, 20 suites green in normal and -O. No production mapper. The review loop is not being re-triggered on this commit. F and G landed at 1b09ffc and every commit since has been JSON validation hardening - the thing the owner said Step 1 must not turn into. Stopping there is his call, and the branch waits for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index bb45244..4d14fab 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -3325,8 +3325,22 @@ def prose_faults(value, trail=()): for key, item in value.items(): here = ".".join(trail + (str(key),)) if prose_named(str(key)) and here not in STRUCTURED_PROSE_NAMED: + # AND IT HAS TO SAY SOMETHING. Type and length were the whole + # test, so `""` and `[""]` erased a section while keeping the + # shape the rule accepts - the fourth way to empty a prose + # field that this one check has now been shown to permit, and + # the fourth found by review rather than here. A shape is + # satisfiable vacuously; text is not. + # + # BLANK ENTRIES STAY LEGAL INSIDE A LIST: 53 of them across 16 + # lists are paragraph separators. What is required is that + # SOME entry carries text. if isinstance(item, str): - pass + if not item.strip(): + out.append((here, "is blank", + "a prose field is text, and an empty " + "string is the same erasure as a missing " + "one with nothing to notice it by")) elif not isinstance(item, list) or not item: out.append((here, f"is {item!r}", "a prose field is a " "sentence or a non-empty list of them")) @@ -3335,6 +3349,10 @@ def prose_faults(value, trail=()): if not isinstance(entry, str): out.append((f"{here}[{pos}]", f"is {entry!r}", "every line of a paragraph is a line")) + if not any(isinstance(e, str) and e.strip() for e in item): + out.append((here, "holds no text", + "blank entries are paragraph separators " + "and some entry has to be a paragraph")) out += prose_faults(item, trail + (str(key),)) elif isinstance(value, list): # AND THE CONTENT RULE, KEPT ALONGSIDE THE NAME RULE. They catch From 531f142393c64ebbc7aee4ae735a610a99fa65fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 09:43:28 +0000 Subject: [PATCH 73/84] contract(lineage): rank the rejecting stages, unbind the b-side, and make a drift stay the same defect The owner's decision on the remaining contract questions, in one change. A, B and E are semantic corrections; C, D and the rescuer boundary are classifications that were being carried as open questions and are not. A. TWO REJECTING STAGES CAN FIRE AT ONCE AND ONLY ONE IS AN ANSWER. Step 4 has always recorded the uniqueness and cardinality rejections separately and never said which supplies the record's `reason` when both happen - so a mapper could have reported either and been reading the contract correctly. `reason_selector` ranks them: eligibility, conflicting-evidence, ambiguous-candidates, insufficient-* by the floor, otherwise no-mapping-evidence. Every branch is readable from the record's own fields, so no step of it needs applicability. Cardinality is deliberately absent from that order. It says a rule does not govern this shape, which is not a statement about evidence and not a reason a relation failed; `rules_excluded_by_cardinality` stays as recall provenance and selects nothing. `mandated_reason` used to REFUSE this shape - correctly, while nothing ranked it - and now reads the ranking instead of a hard-coded precedence. `an-ambiguity-outranks-a-cardinality-rejection` is the case: a fold record names two sources, one of which carried this defect, so R-MERGE-FOLD matched its record and formed a group of one - excluded by cardinality - while the fold produced a method holding two instances, so both 1:1 rules match and neither singles one out. The ambiguity answers. B. A REFUSAL THAT NAMES NO PREDECESSOR CARRIES NO PAIR EVIDENCE. Three fixtures carried the a-side's `signals_defeated` and `evidence_surviving` across to the b-side by symmetry. A defeat says why a RELATION signal was removed and a b-side record has `frm: null`, so there was no pair for it to be about - and the b-side reason then followed from the a-side's floor arithmetic rather than from anything observable on the b-side. They now say what is true there: nothing links the occurrence back and nothing says it is new. Enforced, not merely applied. E. R-CONT-DRIFT REQUIRES `same_pattern_id`. Same path, same enclosing symbol and a line difference identify the SITE; they do not identify the diagnostic. Without the pattern, a `pat-A` that disappears and a single `pat-B` a few lines lower in the same method inherits the lineage because a chair came free nearby. Safe to require precisely because this rule is same-path - it never pretended to survive a move, which is what the sixth senior amendment was for. `a-different-defect-at-the-same-site-is-not-a-drift` is the witness, with real messages and two genuinely different computed pattern ids. C, D, RESCUER: classified in `docs` and in the contract rather than left as alarms. The suite does not compute applicability - architectural boundary of step 1, and a checker that did would be a second mapper. D was closed by the coverage witnesses two rounds ago and is removed from the open list rather than given another way to be checked. The rescuer residue is a proof boundary that follows from C, closed by the reference evaluator at the real-history gate. Two defects of my own, both found by probing rather than by reading. The `required_kind_withheld_a_rule` obligation first asked whether SOME rule named SOME kind it requires, which any case satisfies - membership standing in for the correct member again - so the rule and kind are named in `required_kind_witnesses` and checked there. And the cardinality obligation accepted a rule "excluded from a match it never had": deleting `revision_b.merged_symbols` left the new case green, and so did emptying the record's `from`. Both now fail. PR body rewritten to the real state: 16 relation cases and 2 eligibility cases, six senior amendments, seven questions decided, and the two measured debts recorded as baselines rather than work lists. Verified: 17 probes, each biting with the message it should, including a control that the fold's provenance and the ambiguity are each independently load-bearing. Fixture census 3800/0 crashes/55 green, eligibility 174/0/0. Contract census 377 crash sites, 16 more than before and exactly 16 on the paths this change added entries to - every one a reader that already crashed identically on its pre-existing siblings, and none in the new sections. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 145 +++++++++++---- docs/finding-lineage-decision.md | 58 ++++++ .../a-defeat-can-leave-too-few-kinds.json | 9 +- ...efect-at-the-same-site-is-not-a-drift.json | 77 ++++++++ ...uity-outranks-a-cardinality-rejection.json | 168 ++++++++++++++++++ ...defeated-signal-drops-below-the-floor.json | 12 +- ...med-symbol-defeats-structural-context.json | 13 +- identity/tests/test_lineage_decision.py | 154 +++++++++++++--- 8 files changed, 554 insertions(+), 82 deletions(-) create mode 100644 identity/fixtures/lineage-decision/a-different-defect-at-the-same-site-is-not-a-drift.json create mode 100644 identity/fixtures/lineage-decision/an-ambiguity-outranks-a-cardinality-rejection.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 2a504f3..bb215a1 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -152,10 +152,11 @@ "requires_all": [ "same_path", "structural_context", - "line_drift" + "line_drift", + "same_pattern_id" ], "means": "same file and same enclosing symbol, moved by an edit elsewhere", - "note": "content is NOT required: a defect may be edited without ceasing to be the same defect" + "note": "content is NOT required: a defect may be edited without ceasing to be the same defect. The PATTERN is required, and that is a different claim - `same_pattern_id` says the analyser is still saying the same thing about the same file, which is what makes this the same defect rather than the same PLACE. Without it, a `pat-A` that disappears and a single `pat-B` appearing a few lines lower in the same method inherits the lineage because a chair came free nearby. Same path and same enclosing symbol identify the SITE well enough; they do not identify the diagnostic, and for a fail-closed lineage `unresolved` is the better answer than a false continuity. `same_pattern_id` is safe to require here precisely because this rule is same-path: nothing about it pretends to survive a move, which is the thing the sixth senior amendment was needed for." }, "R-CONT-RENAME": { "outcome": "continued", @@ -521,7 +522,9 @@ "a-recorded-rename-is-not-an-unresolved", "an-edit-elsewhere-is-still-the-same-defect", "a-copy-into-two-new-files-is-a-branch", - "a-fold-across-files-is-still-a-merge" + "a-fold-across-files-is-still-a-merge", + "an-ambiguity-outranks-a-cardinality-rejection", + "a-different-defect-at-the-same-site-is-not-a-drift" ], "preregistration_rule": [ "Fixed before the mapper, same as slice 2 step 0. These are about the POLICY", @@ -948,56 +951,64 @@ "the fixture's own declarations." ], "obligations": { - "copy-record-dominates-the-single-match": [ - "dominance_did_work" + "a-copy-into-two-new-files-is-a-branch": [ + "rule_licensed_alone" ], - "merge-record-dominates-the-single-match": [ - "dominance_did_work" + "a-defeat-can-leave-too-few-kinds": [ + "defeat_removed_a_signal" ], - "copy-at-one-to-one-is-not-a-branch": [ - "cardinality_excluded_a_rule" + "a-different-defect-at-the-same-site-is-not-a-drift": [ + "required_kind_withheld_a_rule" ], - "defeated-signal-drops-below-the-floor": [ - "defeat_removed_a_signal" + "a-fold-across-files-is-still-a-merge": [ + "rule_licensed_alone" ], - "renamed-symbol-defeats-structural-context": [ - "defeat_removed_a_signal" + "a-recorded-rename-is-not-an-unresolved": [ + "rule_licensed_alone" ], - "unavailable-diff-is-not-a-deletion": [ - "input_was_unavailable" + "an-ambiguity-outranks-a-cardinality-rejection": [ + "blunt_rule_recorded", + "cardinality_excluded_a_rule" + ], + "an-edit-elsewhere-is-still-the-same-defect": [ + "rule_licensed_alone" ], "blunt-rule-loses-to-uniqueness": [ "blunt_rule_recorded" ], + "copy-at-one-to-one-is-not-a-branch": [ + "cardinality_excluded_a_rule" + ], + "copy-record-dominates-the-single-match": [ + "dominance_did_work" + ], "copy-source-that-is-also-a-fold-refuses": [ "refusal_was_recorded" ], - "every-candidate-blunted-is-an-ambiguity": [ - "blunt_rule_recorded" - ], - "a-defeat-can-leave-too-few-kinds": [ + "defeated-signal-drops-below-the-floor": [ "defeat_removed_a_signal" ], - "a-recorded-rename-is-not-an-unresolved": [ - "rule_licensed_alone" + "every-candidate-blunted-is-an-ambiguity": [ + "blunt_rule_recorded" ], - "an-edit-elsewhere-is-still-the-same-defect": [ - "rule_licensed_alone" + "merge-record-dominates-the-single-match": [ + "dominance_did_work" ], - "a-copy-into-two-new-files-is-a-branch": [ - "rule_licensed_alone" + "renamed-symbol-defeats-structural-context": [ + "defeat_removed_a_signal" ], - "a-fold-across-files-is-still-a-merge": [ - "rule_licensed_alone" + "unavailable-diff-is-not-a-deletion": [ + "input_was_unavailable" ] }, "meanings": { - "dominance_did_work": "some rule in `applicable_rules` is absent from `licensed_by` AND is dominated by a rule that is in it. The conflict is real and arbitration resolved it, rather than there having been one candidate all along.", + "blunt_rule_recorded": "`decision_detail.rules_without_a_unique_candidate` is non-empty. The blunt rule must be visible as having failed to choose, not merely absent.", "cardinality_excluded_a_rule": "the case names `decision_detail.rules_excluded_by_cardinality`, and each rule there is genuinely unable to fit the declared shape - checked against its own `min_successors` / `min_predecessors`, not against prose. An earlier revision of this entry still named a top-level `excluded_by_cardinality`, abandoned when the stage got a declared home; a fixture author following it would have produced a shape the checker rejects and the recall union never sees.", "defeat_removed_a_signal": "`signals_defeated` is non-empty, the defeating record is carried by revision B, and it names this occurrence.", + "dominance_did_work": "some rule in `applicable_rules` is absent from `licensed_by` AND is dominated by a rule that is in it. The conflict is real and arbitration resolved it, rather than there having been one candidate all along.", "input_was_unavailable": "`inputs_unavailable` is non-empty and revision B declares the same signal in `unavailable_signals`.", - "blunt_rule_recorded": "`decision_detail.rules_without_a_unique_candidate` is non-empty. The blunt rule must be visible as having failed to choose, not merely absent.", "refusal_was_recorded": "the case exercises a pair declared in `deliberately_unresolved_conflicts` - both ids applicable together - and records them in `decision_detail.conflicting_rules` with an EMPTY `licensed_by`. Until this obligation existed the N:M refusal was frozen in the contract and pinned by no case at all, which is the shape of defect this matrix exists to catch.", + "required_kind_withheld_a_rule": "the case names, in `not_applicable`, a rule that stood down because a kind its own `requires_all` lists did not hold - and names that kind. This is what pins a rule's requirement to a case rather than to an assertion in the suite: R-CONT-DRIFT gained `same_pattern_id` because a same-site drift must stay the same DEFECT and not merely the same PLACE, and `a-different-defect-at-the-same-site-is-not-a-drift` is where that requirement is either doing work or is decoration.", "rule_licensed_alone": "exactly one rule is in `applicable_rules` and the same rule is in `licensed_by`. Nothing was dominated, nothing was filtered out, and the conclusion therefore rests ENTIRELY on that rule's own requirements - which is what makes the case a pin on them. A rule reachable only in cases where some other rule also applies is a rule whose requirements can be rewritten without any fixture noticing." }, "rule": [ @@ -1195,6 +1206,7 @@ "line_drift": "no_structural_record", "path_rename": "rename_record", "same_path": "no_structural_record", + "same_pattern_id": "no_structural_record", "structural_context": "no_structural_record" }, "why": [ @@ -1286,5 +1298,78 @@ "branch of the mapping it was describing. That was a TYPO, not a retired name,", "and it was corrected rather than listed here - which is the distinction this", "section exists to keep." - ] + ], + "reason_selector": { + "order": [ + { + "when": "eligibility_refused", + "reason": "lineage-id-unavailable:missing-occurrence-id", + "observed_from": "the finding's own null `occurrence_id`" + }, + { + "when": "conflicting_rules_present", + "reason": "lineage-id-unavailable:conflicting-evidence", + "observed_from": "decision_detail.conflicting_rules" + }, + { + "when": "ambiguous_candidates_present", + "reason": "lineage-id-unavailable:ambiguous-candidates", + "observed_from": "decision_detail.ambiguous_candidates" + }, + { + "when": "a_defeat_left_no_surviving_rule", + "reason": "reason_mapping.no_rule_applied_after_a_defeat.reason_by_surviving_kinds", + "observed_from": "signals_defeated" + }, + { + "when": "otherwise", + "reason": "lineage-id-unavailable:no-mapping-evidence", + "observed_from": "nothing licensed a link and nothing explains why more precisely" + } + ], + "cardinality_selects_no_reason": "`decision_detail.rules_excluded_by_cardinality` is PROVENANCE, never a reason. A cardinality filter says only that a rule does not govern this shape; it does not say why the relation failed. Where it coincides with `rules_without_a_unique_candidate`, the honest account of why a known 1:1 hypothesis produced no relation is the AMBIGUITY, and the cardinality-excluded rule stays recorded beside it for the recall set.", + "why": [ + "TWO REJECTING STAGES CAN FIRE AT ONCE, AND ONLY ONE OF THEM IS AN ANSWER.", + "", + "`decision_procedure` step 4 already insisted the two rejections are recorded", + "SEPARATELY, and was right to. What it did not say is which of them the RECORD's", + "`reason` comes from when both happen in one mapping - and a mapper reading that", + "step alone could reasonably have reported either.", + "", + "The order here is total and it is checkable from the record's own fields, which", + "is the point: no stage of it needs applicability. Eligibility absorbs before any", + "evidence is read. A conflict is grounds that argue with each other. An ambiguity", + "is several partners with nothing to choose between them. A defeat that leaves no", + "rule standing is answered by the floor. Everything else is", + "`no-mapping-evidence`, which is what the senior contract already means by", + "'nothing was observed that could license a link'.", + "", + "Cardinality is deliberately nowhere in that order. It is the one rejection that", + "is not about the evidence at all." + ] + }, + "required_kind_witnesses": { + "a-different-defect-at-the-same-site-is-not-a-drift": { + "rule": "R-CONT-DRIFT", + "kind": "same_pattern_id" + }, + "rule": [ + "WHICH RULE STOOD DOWN FOR WHICH KIND, NAMED BY THE CONTRACT RATHER THAN BY THE", + "SUITE. A case carrying `required_kind_withheld_a_rule` declares its witness here,", + "and the suite checks that the named rule appears in that case's", + "`not_applicable`, that the text there names the kind, and that the kind is one", + "the rule's own `requires_all` lists.", + "", + "The first form of the obligation asked only whether SOME rule in", + "`not_applicable` mentioned SOME kind it requires - which any case satisfies,", + "because rules stand down naming their requirements all the time. It would have", + "passed with R-CONT-DRIFT's entry rewritten to say nothing about", + "`same_pattern_id` at all, so the obligation written to pin E's decision did not", + "pin it. Membership standing in for the correct member, one more time.", + "", + "Hard-coding the pair in the checker was the other option and it is the one this", + "project keeps refusing: the value that decides a case belongs in the frozen", + "document, where a reader can disagree with it." + ] + } } diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 99d4e06..aee1017 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -522,6 +522,8 @@ one not. | 12 | `an-edit-elsewhere-is-still-the-same-defect` | `continued` — the line moved and the text changed | | 13 | `a-copy-into-two-new-files-is-a-branch` | `branched` — and no successor shares the predecessor's `pattern_id` | | 14 | `a-fold-across-files-is-still-a-merge` | `merged` — the folded predecessor lives in another file | +| 15 | `an-ambiguity-outranks-a-cardinality-rejection` | `unresolved` / `ambiguous-candidates` — both rejecting stages fire, and only one of them is an answer | +| 16 | `a-different-defect-at-the-same-site-is-not-a-drift` | `unresolved` / `no-mapping-evidence` — the site is shared and the diagnostic is not | Cases 1 and 2 carry an extra burden, because a fixture can be right for the wrong reason: move the losing rule out of `applicable_rules` and the answer is @@ -581,9 +583,65 @@ one naming the *wrong* outcome — which is worse than a gap. That is also how A half-matrix of the cases that happened to pass would have hidden all of it, which is why partial credit is not on offer. +## Two rejecting stages, one answer + +Step 4 records a uniqueness rejection and a cardinality rejection separately, and +always has. What it never said is which of them the record's `reason` comes from +when both fire in one mapping — so a mapper could have reported either and been +reading the contract correctly. No case exercised the coincidence: fourteen +fixtures had one rejection or the other and never both. + +`reason_selector` ranks them, and the order is total and readable from the +record's own fields, which is the point — no step of it needs applicability: + +1. eligibility refused → `missing-occurrence-id` (absorbs before evidence is read) +2. conflicting rules → `conflicting-evidence` +3. ambiguous candidates → `ambiguous-candidates` +4. a defeat left no surviving rule → `insufficient-evidence-{kind,combination}` +5. otherwise → `no-mapping-evidence` + +**Cardinality is deliberately nowhere in that order.** It is the one rejection +that is not about the evidence at all: it says a rule does not govern this shape, +which is not a reason a relation failed. `rules_excluded_by_cardinality` stays in +the record as provenance for the recall set and selects nothing. +`an-ambiguity-outranks-a-cardinality-rejection` is where both stages fire at once +and the ambiguity answers. + +## A refusal that names no predecessor carries no pair evidence + +A b-side `unresolved` has `frm: null`. `signals_defeated` says why a **relation** +signal was removed and `evidence_surviving` says which kinds survived **in a +pair** — and there is no pair here for either to be about. + +Three fixtures used to carry the a-side's defeat across to the b-side by +symmetry. That recorded evidence about a relation the record itself does not +state, and it also made the b-side reason follow from the a-side's floor +arithmetic rather than from anything observable on the b-side. They now say what +is true there: nothing links this occurrence back, and nothing says it is new — +`no-mapping-evidence`. If the reason ever needs to explain *which* candidate +predecessors were tried and rejected, that is a candidate-attempt trace and a +different record shape, not this one. + ## What is deliberately not decided here - **The mapper.** Still none. This is the decision policy, not the code. +- **Whether the integrity suite may compute applicability. It may not, and that + is settled.** Fixtures preregister `applicable_rules`; this suite checks shape, + carried records and arbitration, and never re-evaluates a rule's predicates. A + checker that did would be a second mapper — hidden, unversioned, and certain to + disagree with the first one the day either changed, with both equally + confident. Applicability is first computed by the reference evaluator at the + real-history gate, once, in one place. This is an architectural boundary of + step 1, not an open question. +- **Whether a named rescuer actually reaches its excluded partner.** It follows + from the line above that this cannot be shown here: + `excluded_partners_reached_by` pins that every dropped partner is named, that + the named rescuer is applicable and concludes the required outcome, and that no + rescuer catches more partners than its cardinality relates — but proving the + rule's application *includes* that occurrence means evaluating its + `requires_all`, which is applicability. `excluded_partners_rescuer_rule` + records the boundary. In the reference evaluator the link becomes computable + naturally, by the one evaluator rather than by a second hidden inside a test. - **Whether a rule set this small is enough.** It is explicitly not exhaustive. Adding a rule is a contract edit: declare it, declare its cardinality and partner profile, and classify its conflicts. The completeness law covers diff --git a/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json b/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json index 83c2658..db0a06d 100644 --- a/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json +++ b/identity/fixtures/lineage-decision/a-defeat-can-leave-too-few-kinds.json @@ -68,15 +68,10 @@ "to": [ "occ-b1" ], - "reason": "lineage-id-unavailable:insufficient-evidence-kind", + "reason": "lineage-id-unavailable:no-mapping-evidence", "applicable_rules": [], "licensed_by": [], - "signals_defeated": { - "anchored_content": "reformatted_paths" - }, - "evidence_surviving": [ - "same_rule_message" - ] + "note": "the mirror side, and its reason comes from what is observable HERE. A defeat is a statement about why a RELATION signal was removed, and this record names no predecessor - `frm` is null - so there is nothing for a predecessor-bound defeater to attach to. Carrying the a-side's `signals_defeated` and `evidence_surviving` across would have recorded evidence about a relation the record itself does not state. What is left observable is that nothing links this occurrence back and nothing says it is new." } ], "forbid": [ diff --git a/identity/fixtures/lineage-decision/a-different-defect-at-the-same-site-is-not-a-drift.json b/identity/fixtures/lineage-decision/a-different-defect-at-the-same-site-is-not-a-drift.json new file mode 100644 index 0000000..da7c8b8 --- /dev/null +++ b/identity/fixtures/lineage-decision/a-different-defect-at-the-same-site-is-not-a-drift.json @@ -0,0 +1,77 @@ +{ + "case": "a-different-defect-at-the-same-site-is-not-a-drift", + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented", + "title": "One diagnostic leaves a method, another appears in it, and the site is the same", + "why": "THE CASE R-CONT-DRIFT NEEDED BEFORE IT COULD REQUIRE A PATTERN. The rule used to ask for `same_path`, `structural_context` and `line_drift` and nothing about the diagnostic - so it identified the SITE and called that the same defect. Here `DocView.Wire` keeps its path and its name, the old finding is gone from line 42 and a DIFFERENT finding of the same rule sits at line 57 with a different message. Under the old requirements every one of them held and the lineage was inherited because a chair came free a few lines lower.\n\n`finding-pattern/v1` hashes path, rule AND message, so a changed message is a changed pattern id: f964274604b5c187 for the predecessor and a different value for the successor, both computed here rather than asserted. Adding `same_pattern_id` to the rule's `requires_all` is what makes this case `unresolved` instead of a false continuity, and it is safe to require precisely because R-CONT-DRIFT is a same-path rule - it never pretended to survive a move, which is what the sixth senior amendment was for.\n\nFor a fail-closed lineage, refusing here is the conservative answer: if the diagnostic changed enough to change the pattern, `unresolved` costs a link and a wrong `continued` costs the truth.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "disposable field is never disposed", + "start_line": 57, + "start_column": null, + "pattern_id": "c4c27796b2347407", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_timer = new Timer(Tick);" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": "occ-a1", + "to": [], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "not_applicable": { + "R-CONT-SAME-SITE": "anchored_content differs - the content at the new site is not the old content", + "R-CONT-DRIFT": "same_pattern_id does not hold: the message changed, so `finding-pattern/v1` computes a different id", + "R-CONT-RENAME": "no rename record, and the path did not change", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record, and the shape is 1:1", + "R-MERGE-FOLD": "no merge record, and the shape is 1:1" + }, + "note": "same path, same enclosing symbol and a line difference all hold. They identify the place, and the place is not the defect." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "note": "the mirror side, and its reason comes from what is observable HERE: no record links this occurrence back, and nothing says it is new. No signal is reported as defeated, because a defeat is a statement about a relation and this record names no predecessor." + } + ], + "forbid": [ + "continued from occ-a1 to occ-b1 - the site is shared and the diagnostic is not", + "ended for occ-a1 or new for occ-b1 - neither carries boundary evidence", + "same_pattern_id treated as holding because the rule and the path match - the message is hashed too", + "insufficient-evidence-kind or insufficient-evidence-combination - nothing was defeated here" + ] +} diff --git a/identity/fixtures/lineage-decision/an-ambiguity-outranks-a-cardinality-rejection.json b/identity/fixtures/lineage-decision/an-ambiguity-outranks-a-cardinality-rejection.json new file mode 100644 index 0000000..cb495be --- /dev/null +++ b/identity/fixtures/lineage-decision/an-ambiguity-outranks-a-cardinality-rejection.json @@ -0,0 +1,168 @@ +{ + "case": "an-ambiguity-outranks-a-cardinality-rejection", + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented", + "title": "Two rejecting stages fire together, and only one of them is the reason", + "why": "THE CASE THAT DECIDES WHICH REJECTION ANSWERS. `decision_procedure` step 4 has always recorded the two rejections separately and never said which one the record's `reason` comes from when both happen at once - so a mapper could have reported either and been reading the contract correctly. No case exercised the coincidence: twelve fixtures had one rejection or the other and never both.\n\nHere a fold record names two sources, only one of which carried this defect, so R-MERGE-FOLD matched its record and formed a group of ONE predecessor - below `min_predecessors`, and excluded by cardinality. Meanwhile the fold produced a method holding two instances of the leak, so both 1:1 same-site rules match and neither can single one out.\n\nThe answer is the AMBIGUITY. A cardinality filter says only that a rule does not govern this shape; it is not a statement about the evidence and it is not why the relation failed. The excluded rule stays recorded beside it, because the recall set needs to know R-MERGE-FOLD was considered here - see `reason_selector`.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "removed_symbols": [ + "LegacyView.Wire" + ], + "merged_symbols": [ + { + "from": [ + "DocView.Wire", + "LegacyView.Wire" + ], + "to": "DocView.Wire" + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 44, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + }, + { + "occurrence_id": "occ-b2", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 51, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": "occ-a1", + "to": [], + "reason": "lineage-id-unavailable:ambiguous-candidates", + "applicable_rules": [], + "licensed_by": [], + "decision_detail": { + "rules_without_a_unique_candidate": [ + "R-CONT-DRIFT", + "R-CONT-SAME-SITE" + ], + "rules_excluded_by_cardinality": [ + "R-MERGE-FOLD" + ], + "ambiguous_candidates": { + "R-CONT-DRIFT": [ + "occ-b1", + "occ-b2" + ], + "R-CONT-SAME-SITE": [ + "occ-b1", + "occ-b2" + ] + } + }, + "not_applicable": { + "R-CONT-RENAME": "no rename record in this revision pair", + "R-CONT-COPY": "no copy record in this revision pair", + "R-BRANCH-COPY": "no copy record, so nothing explains the multiplicity", + "R-MERGE-FOLD": "the merge record is present and its shape is not this one: N:1 with min_predecessors 2, and only occ-a1 carried the defect among the fold's sources" + }, + "note": "both rejecting stages are populated and the reason comes from the ambiguity. `rules_excluded_by_cardinality` is provenance for the recall set and selects nothing." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:ambiguous-candidates", + "applicable_rules": [], + "licensed_by": [], + "decision_detail": { + "rules_without_a_unique_candidate": [ + "R-CONT-DRIFT", + "R-CONT-SAME-SITE" + ], + "rules_excluded_by_cardinality": [ + "R-MERGE-FOLD" + ], + "ambiguous_candidates": { + "R-CONT-DRIFT": [ + "occ-b1", + "occ-b2" + ], + "R-CONT-SAME-SITE": [ + "occ-b1", + "occ-b2" + ] + } + }, + "note": "the mirror side. This successor is not preferred over occ-b2, and nothing in revision B says it is new. The reason is the ambiguity here too - both rejecting stages are carried, and the cardinality one selects nothing on this side either." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b2" + ], + "reason": "lineage-id-unavailable:ambiguous-candidates", + "applicable_rules": [], + "licensed_by": [], + "decision_detail": { + "rules_without_a_unique_candidate": [ + "R-CONT-DRIFT", + "R-CONT-SAME-SITE" + ], + "rules_excluded_by_cardinality": [ + "R-MERGE-FOLD" + ], + "ambiguous_candidates": { + "R-CONT-DRIFT": [ + "occ-b1", + "occ-b2" + ], + "R-CONT-SAME-SITE": [ + "occ-b1", + "occ-b2" + ] + } + }, + "note": "the twin, stated separately rather than assumed. An unresolved(b) names exactly one occurrence, so a shape that refuses to choose between two of them needs two records to say so." + } + ], + "forbid": [ + "no-mapping-evidence as the reason - candidates were observed and could not be chosen between", + "a reason drawn from the cardinality rejection - a rule that does not govern this shape says nothing about why the relation failed", + "rules_excluded_by_cardinality dropped because the ambiguity outranked it - the recall set would then miss that R-MERGE-FOLD was considered", + "continued to occ-b1 or occ-b2 - picking whichever was enumerated first", + "merged - the fold record is real and it does not describe THIS mapping" + ] +} diff --git a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json index 8f0c561..0f80d4c 100644 --- a/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json +++ b/identity/fixtures/lineage-decision/defeated-signal-drops-below-the-floor.json @@ -73,18 +73,10 @@ "to": [ "occ-b1" ], - "reason": "lineage-id-unavailable:insufficient-evidence-combination", + "reason": "lineage-id-unavailable:no-mapping-evidence", "applicable_rules": [], "licensed_by": [], - "signals_defeated": { - "anchored_content": "reformatted_paths" - }, - "evidence_surviving": [ - "same_path", - "same_pattern_id", - "structural_context" - ], - "note": "the mirror side. Nothing about revision B says this occurrence is new, and no rule links it back." + "note": "the mirror side, and its reason comes from what is observable HERE. A defeat is a statement about why a RELATION signal was removed, and this record names no predecessor - `frm` is null - so there is nothing for a predecessor-bound defeater to attach to. Carrying the a-side's `signals_defeated` and `evidence_surviving` across would have recorded evidence about a relation the record itself does not state. What is left observable is that nothing links this occurrence back and nothing says it is new." } ], "forbid": [ diff --git a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json index 6d12138..13da245 100644 --- a/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json +++ b/identity/fixtures/lineage-decision/renamed-symbol-defeats-structural-context.json @@ -77,19 +77,10 @@ "to": [ "occ-b1" ], - "reason": "lineage-id-unavailable:insufficient-evidence-combination", + "reason": "lineage-id-unavailable:no-mapping-evidence", "applicable_rules": [], "licensed_by": [], - "signals_defeated": { - "structural_context": "renamed_symbol_record" - }, - "evidence_surviving": [ - "same_path", - "same_pattern_id", - "anchored_content", - "line_drift" - ], - "note": "occ-b1 is not `new` either: nothing records its site as added, and a birth is earned rather than left over." + "note": "the mirror side, and its reason comes from what is observable HERE. A defeat is a statement about why a RELATION signal was removed, and this record names no predecessor - `frm` is null - so there is nothing for a predecessor-bound defeater to attach to. Carrying the a-side's `signals_defeated` and `evidence_surviving` across would have recorded evidence about a relation the record itself does not state. What is left observable is that nothing links this occurrence back and nothing says it is new." } ], "forbid": [ diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 4d14fab..18b691f 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -188,7 +188,7 @@ def rule_needs(rule) -> set: return needs | set(list_or_empty(profile.get("requires_all"))) -def mandated_reason(exp, mapping, floor, senior): +def mandated_reason(exp, mapping, floor, senior, selector=None): """The reason the policy REQUIRES for this refusal, or None if the contract has not settled the shape. @@ -220,28 +220,53 @@ def of(key, surviving_kinds=None): unavailable = as_list(exp.get(UNAVAILABLE_FIELD)) detail = exp.get("decision_detail") or {} blunted = detail.get("rules_without_a_unique_candidate") or [] - miscard = detail.get("rules_excluded_by_cardinality") or [] - - if app: - # Rules applied and the answer is still a refusal: they disagreed. - return of("conflicting_rules"), "rules applied and disagreed" - # More than one rejecting stage fired at once. Each has its own reason and - # the contract does not rank them; picking one here would be this file - # deciding a contract question in a checker. - stages = [bool(defeated), bool(unavailable), bool(blunted), bool(miscard)] - if sum(stages) > 1: - return None, "several rejecting stages fired; the contract has not ranked them" - if defeated: - surv = {k for k in (exp.get("evidence_surviving") or [])} - return (of("no_rule_applied_after_a_defeat", surv), - f"a defeat left {len(surv)} kind(s) against a floor of {floor}") - if blunted: - return of("several_candidates"), "rules matched and singled nobody out" - if unavailable: - return of("no_rule_applied"), "a signal could not be evaluated" - if miscard: - return of("no_rule_applied"), "the only matches were the wrong shape" - return of("no_rule_applied"), "nothing matched at all" + conflicting = detail.get("conflicting_rules") or [] + ambiguous = detail.get("ambiguous_candidates") or {} + + # THE ORDER IS THE CONTRACT'S, READ NOT RESTATED. This used to refuse the + # moment two rejecting stages fired at once - correctly, because nothing + # ranked them and choosing here would have been a checker settling a policy + # question. `reason_selector` ranks them now, so the refusal is replaced by + # reading the ranking. + # + # `rules_excluded_by_cardinality` is DELIBERATELY not a condition of any + # branch. A cardinality filter says a rule does not govern this shape; it is + # not a statement about the evidence and it selects no reason. It stays in + # the record for the recall set - see `reason_selector.cardinality_selects_no_reason`. + holds = { + "conflicting_rules_present": bool(app) or bool(conflicting), + "ambiguous_candidates_present": bool(blunted) or bool(ambiguous), + "a_defeat_left_no_surviving_rule": bool(defeated), + "otherwise": True, + } + says = { + "conflicting_rules_present": ("conflicting_rules", "rules applied and disagreed"), + "ambiguous_candidates_present": ("several_candidates", + "rules matched and singled nobody out"), + "a_defeat_left_no_surviving_rule": ("no_rule_applied_after_a_defeat", None), + "otherwise": ("no_rule_applied", "nothing licensed a link"), + } + for step in list_or_empty(selector): + when = mapping_or_empty(step).get("when") + if when == "eligibility_refused": + continue # stage 0 never reaches the relation corpus + if not isinstance(when, str) or when not in holds: + return None, (f"`reason_selector` names condition {when!r}, which this " + "suite cannot evaluate from a record. An order it cannot " + "read is an order it is not applying") + if not holds[when]: + continue + key, why_ = says[when] + if key == "no_rule_applied_after_a_defeat": + surv = {k for k in (exp.get("evidence_surviving") or []) if isinstance(k, str)} + return (of(key, surv), + f"a defeat left {len(surv)} kind(s) against a floor of {floor}") + if unavailable and key == "no_rule_applied": + why_ = "a signal could not be evaluated" + return of(key), why_ + return None, ("`reason_selector.order` ran out without matching. It has to end in " + "a branch that always holds, or a record exists that the policy " + "assigns no reason") def as_list(v) -> list: @@ -2538,7 +2563,25 @@ def arbitration_leaves(node, trail): # AND it must be the reason the POLICY BRANCH mandates. Vocabulary # membership alone accepted any of the six - `missing-occurrence-id` # passed on a case whose occurrences both carry ids. - want, why_branch = mandated_reason(exp, policy["reason_mapping"], floor, senior) + # A REFUSAL THAT NAMES NO PREDECESSOR CARRIES NO PAIR EVIDENCE. Both + # `signals_defeated` and `evidence_surviving` are statements about a + # RELATION - which signal was removed from it, which kinds survived in + # it - and a b-side record has `frm: null`, so there is no pair for + # either to be about. Three fixtures carried the a-side's defeat across + # by symmetry, which recorded evidence about a relation the record does + # not state. If the reason a b-side refusal happened ever needs the + # candidates that were tried, that is a candidate-attempt trace and a + # different shape, not this one. + if exp.get("frm") is None: + for field in ("signals_defeated", "evidence_surviving"): + check(not exp.get(field), + f"{where}: names no predecessor and still carries " + f"{field}={exp.get(field)!r}. That is a claim about a pair " + "this record does not name; a defeat is why a relation signal " + "was removed, and there is no relation here to remove it from.") + want, why_branch = mandated_reason(exp, policy["reason_mapping"], floor, senior, + mapping_or_empty(policy.get("reason_selector")) + .get("order")) if want is None: # FAIL-CLOSED on an unranked shape. Abstaining was the right call # for a CHECKER - the contract has not ranked these stages, so @@ -3164,6 +3207,37 @@ def shown(ids_): # with a second one that did the real work, which is exactly the # incidental reach this obligation exists to rule out. met |= len(app_s) == 1 and app_s == lic_s + elif duty == "required_kind_withheld_a_rule": + # A rule that did NOT apply because a kind its `requires_all` + # names failed to hold - and `not_applicable` has to say which + # kind, by its senior name. Written for R-CONT-DRIFT gaining + # `same_pattern_id`: the value that decides the case must be + # preregistered as the reason the rule stood down, not left as + # an assertion in this file. + # THE WITNESS IS NAMED, not searched for. Asking whether SOME + # rule mentioned SOME kind it requires is satisfied by any + # case - rules stand down naming their requirements all the + # time - so the obligation written to pin E's decision passed + # with R-CONT-DRIFT's entry saying nothing about + # `same_pattern_id`. See `required_kind_witnesses`. + spec_w = mapping_or_empty( + mapping_or_empty(policy.get("required_kind_witnesses")).get(name)) + w_rule, w_kind = spec_w.get("rule"), spec_w.get("kind") + check(isinstance(w_rule, str) and w_rule in rules + and isinstance(w_kind, str), + f"{name}: carries `required_kind_withheld_a_rule` and " + f"`required_kind_witnesses` names {spec_w or None!r}. The rule " + "and the kind the case turns on are declared in the contract, " + "not looked for here.") + if (isinstance(w_rule, str) and w_rule in rules + and isinstance(w_kind, str)): + check(w_kind in rule_needs(rules[w_rule]), + f"{name}: the witness names {w_kind!r}, which " + f"{w_rule} does not require. A rule cannot stand down " + "over a requirement it does not have.") + said = mapping_or_empty(exp.get("not_applicable")).get(w_rule) + if isinstance(said, str) and w_kind in said: + met = True elif duty == "blunt_rule_recorded": met |= bool((exp.get("decision_detail") or {}) .get("rules_without_a_unique_candidate")) @@ -3197,6 +3271,38 @@ def shown(ids_): f"{name}: {rid} is claimed excluded by cardinality, but " f"{card} does not rule out a {nf}:{nt} shape. The guard has " "to do the excluding, not the note.") + # AND THE RULE HAS TO HAVE MATCHED SOMETHING. "Excluded by + # cardinality" means it matched its record and then met a + # shape it does not govern - so the record must be there. + # Deleting `revision_b.merged_symbols` from + # `an-ambiguity-outranks-a-cardinality-rejection` left the + # suite green: the case asserted a rule was considered and + # the thing it was considered on was gone. The same + # question is already asked of LICENSED rules one loop + # over; this is it asked of the adjacent site. + for kind_c in list_or_empty(mapping_or_empty(rules.get(rid)) + .get("requires_all")): + sig_c = (kind_c if kind_c in catalog + else kind_records.get(kind_c)) + cat_c = catalog.get(sig_c) + if not cat_c: + continue + read_c, entries_c, pairs_c = catalog_read(cat_c, rev_b) + # ...and an entry that NAMES something. `from: []` + # left a fold record present and relating nobody, so + # "it matched" was satisfied by a record with no + # sources in it. Shape present, content vacuous - the + # same reading that let a prose field be emptied. + entries_c = [e for e in entries_c + if isinstance(e, dict) and all( + e.get(k) for k, _, _ in pairs_c)] + check(bool(entries_c), + f"{name}: {rid} is recorded as excluded by " + f"cardinality, which says it MATCHED {sig_c!r} and " + f"then met the wrong shape - and {read_c} carries " + "nothing. A rule excluded from a match it never had " + "is a recall-set entry for a consideration that did " + "not happen.") met = True check(met, f"{name}: preregistered to exhibit {duty!r}, and no expectation " f"does. {obligation_meanings.get(duty, '')}") From 08d63c685ebf969f0cbe53e965309f4fe2bc59a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 10:13:51 +0000 Subject: [PATCH 74/84] test(lineage): bind the selector's declared reasons to the branches they label CodeRabbit, on the semantic closure: a silent-acceptance path in `reason_selector`. Confirmed by mutation - setting the `reason` on every branch to `no-mapping-evidence` left the suite green. `mandated_reason` enforces the ORDER and takes each reason from `reason_mapping`. The `reason` written beside every step was read by nothing, so the contract could declare that an ambiguity yields one value while the policy applied another. A declared claim nothing checks, inside the section added two commits ago to close exactly that shape - and blocking under the acceptance rule, because a reader of the contract would have been told something untrue with the suite reporting OK. Each step's declared reason is now compared against what its branch actually yields: the eligibility step against `eligibility.reason`, the three rule-stage steps against their `reason_mapping` entries, and the defeat step against the node whose `reason_by_surviving_kinds` the floor is read from, since that branch has no single literal. Verified: 6 probes, all biting - CodeRabbit's own mutation, an ambiguity branch claiming conflicting-evidence, the defeat branch claiming a literal instead of the floor node, an eligibility branch disagreeing with `eligibility.reason`, a step with no reason at all, and the stale-pointer case where `reason_mapping` moves and the selector is left behind. The first form of the check added 10 crash sites of its own on unhashable `when` tokens; guarded, and the contract census is back to 377 with none in `reason_selector` and ten more mutations now failing cleanly. Fixture census 3800/0/55, eligibility 174/0/0. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 18b691f..951725b 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2145,6 +2145,39 @@ def arbitration_leaves(node, trail): "ends of it: with one end unnamed, every occurrence on that side counts " "as reached and the record licenses a mapping it does not describe.") + # THE SELECTOR'S OWN REASONS, BOUND TO THE BRANCH THEY LABEL. `mandated_reason` + # enforces the ORDER and takes each reason from `reason_mapping`; the `reason` + # written beside every step was read by nothing. Setting all four to + # `no-mapping-evidence` left the suite green, so the contract could declare + # that an ambiguity yields one reason while the checker applied another - a + # declared claim nothing checks, inside the section added to close exactly + # that. Found by review. + SELECTOR_SOURCE = { + "conflicting_rules_present": "conflicting_rules", + "ambiguous_candidates_present": "several_candidates", + "a_defeat_left_no_surviving_rule": "no_rule_applied_after_a_defeat", + "otherwise": "no_rule_applied", + } + for step in list_or_empty(mapping_or_empty(policy.get("reason_selector")).get("order")): + when_ = mapping_or_empty(step).get("when") + said_ = mapping_or_empty(step).get("reason") + if not isinstance(when_, str): + continue # `mandated_reason` reports the malformed token itself + if when_ == "eligibility_refused": + want_ = mapping_or_empty(policy.get("eligibility")).get("reason") + elif when_ in SELECTOR_SOURCE: + node_ = mapping_or_empty(policy["reason_mapping"].get(SELECTOR_SOURCE[when_])) + want_ = node_.get("reason") or ( + f"reason_mapping.{SELECTOR_SOURCE[when_]}.reason_by_surviving_kinds" + if "reason_by_surviving_kinds" in node_ else None) + else: + continue # unknown token: `mandated_reason` refuses it already + check(said_ == want_, + f"`reason_selector` step {when_!r} declares reason {said_!r}, and the " + f"branch it labels yields {want_!r}. The order is enforced and the " + "reasons beside it were not, so this section could say one thing while " + "the policy did another - which is the shape it was written to close.") + vocab = mapping_or_empty(policy.get("record_binding_vocabulary")) # THE VOCABULARY ITSELF, BEFORE ANYTHING IS LOOKED UP IN IT. Membership - # `token in vocab["quantifier"]` - raises TypeError when that axis is a From c6cfd634ab8c88a07a7eda419f76a8a6d0420ad5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 10:38:51 +0000 Subject: [PATCH 75/84] docs+test(lineage): the rule table must match the rule, and observed_from must name what is read Two findings from the scoped round, both blocking-class, both confirmed by mutation. The second was found by BOTH reviewers independently. CODEX: the doc's rule table still described R-CONT-DRIFT as `same_path`, `structural_context`, `line_drift` after the contract gained `same_pattern_id`. The frozen policy demanded a refusal while the documentation described the false continuity `a-different-defect-at-the-same-site-is-not-a-drift` exists to forbid - so a mapper implemented from the table would have written the defect the case was written to catch. That is a policy contradiction between the two documents a reader is given. The row is corrected, and the class with it: every rule's `requires_all` must be named in that rule's table row, and every rule must have a row. Deleting a row or staling one now fails. BOTH REVIEWERS: `reason_selector.observed_from` was read by nothing. Pointing the ambiguity branch at `signals_defeated` left the suite green, with the frozen selector telling a mapper to look where the decision does not come from. The `reason` beside each step was bound one commit ago and this was the other unread field in the same object - the adjacent site, not asked at the time. `SELECTOR_FIELDS` is now one table: `mandated_reason` builds its predicate from it and the contract's `observed_from` is checked against it, so a branch cannot read one field while the document names another. WRITING IT DOWN CORRECTED IT. Two branches were UNDER-DECLARED and the prose had named one field each: a conflict is observed from `applicable_rules` as well as `decision_detail.conflicting_rules` - rules that applied and still produced a refusal disagreed - and an ambiguity from `rules_without_a_unique_candidate` as well as `ambiguous_candidates`. `otherwise` names none, which is what makes it the branch that always holds; `eligibility_refused` names `occurrence_id`, a field on the FINDING rather than on a relation record, which is the whole of `record_shape: input_local`. The cross-reference gate then refused prose naming `eligibility_refused`, and it was right to: `when` values are names this contract defines as an enumeration rather than as keys, and they are now part of the universe it checks against. Verified: 7 probes, all biting - both reviewers' exact mutations, an under-declared conflict branch, an `otherwise` claiming to read a field, an eligibility branch claiming a relation-record field, `observed_from` removed entirely, the stale doc row, and a deleted table row. Contract census 377 crash sites, none in `reason_selector`, and 61 more mutations failing cleanly than before. Fixture census 3800/0/55, eligibility 174/0/0. 20 suites green in normal and -O. No production mapper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 39 ++++++++++-- docs/finding-lineage-decision.md | 2 +- identity/tests/test_lineage_decision.py | 73 ++++++++++++++++++++-- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index bb215a1..cba9277 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -1304,27 +1304,37 @@ { "when": "eligibility_refused", "reason": "lineage-id-unavailable:missing-occurrence-id", - "observed_from": "the finding's own null `occurrence_id`" + "observed_from": [ + "occurrence_id" + ] }, { "when": "conflicting_rules_present", "reason": "lineage-id-unavailable:conflicting-evidence", - "observed_from": "decision_detail.conflicting_rules" + "observed_from": [ + "applicable_rules", + "decision_detail.conflicting_rules" + ] }, { "when": "ambiguous_candidates_present", "reason": "lineage-id-unavailable:ambiguous-candidates", - "observed_from": "decision_detail.ambiguous_candidates" + "observed_from": [ + "decision_detail.ambiguous_candidates", + "decision_detail.rules_without_a_unique_candidate" + ] }, { "when": "a_defeat_left_no_surviving_rule", "reason": "reason_mapping.no_rule_applied_after_a_defeat.reason_by_surviving_kinds", - "observed_from": "signals_defeated" + "observed_from": [ + "signals_defeated" + ] }, { "when": "otherwise", "reason": "lineage-id-unavailable:no-mapping-evidence", - "observed_from": "nothing licensed a link and nothing explains why more precisely" + "observed_from": [] } ], "cardinality_selects_no_reason": "`decision_detail.rules_excluded_by_cardinality` is PROVENANCE, never a reason. A cardinality filter says only that a rule does not govern this shape; it does not say why the relation failed. Where it coincides with `rules_without_a_unique_candidate`, the honest account of why a known 1:1 hypothesis produced no relation is the AMBIGUITY, and the cardinality-excluded rule stays recorded beside it for the recall set.", @@ -1345,7 +1355,24 @@ "'nothing was observed that could license a link'.", "", "Cardinality is deliberately nowhere in that order. It is the one rejection that", - "is not about the evidence at all." + "is not about the evidence at all.", + "", + "`observed_from` NAMES RECORD FIELDS, and it is checked against the predicate.", + "It was a sentence beside each step and nothing read it, so pointing the", + "ambiguity branch at `signals_defeated` left the suite green and this document", + "telling a mapper to look where the decision does not come from. Both reviewers", + "found that independently.", + "", + "Writing it down also corrected it. Two branches were UNDER-DECLARED: a conflict", + "is observed from `applicable_rules` as well as `decision_detail.conflicting_rules`", + "- rules that applied and still produced a refusal disagreed - and an ambiguity", + "from `rules_without_a_unique_candidate` as well as `ambiguous_candidates`. The", + "prose had named one field each. `otherwise` names none, which is what makes it", + "the branch that always holds.", + "", + "`eligibility_refused` names `occurrence_id`, and that field is on the FINDING", + "rather than on a relation record - stage 0 answers before a relation record", + "exists, which is the whole of `eligibility.record_shape: input_local`." ] }, "required_kind_witnesses": { diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index aee1017..9e50f4a 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -302,7 +302,7 @@ Six, deliberately small and deliberately not exhaustive. | id | outcome | shape | requires | |---|---|---|---| | `R-CONT-SAME-SITE` | `continued` | 1:1 | `same_path`, `structural_context`, `anchored_content` | -| `R-CONT-DRIFT` | `continued` | 1:1 | `same_path`, `structural_context`, `line_drift` | +| `R-CONT-DRIFT` | `continued` | 1:1 | `same_path`, `structural_context`, `line_drift`, `same_pattern_id` | | `R-CONT-RENAME` | `continued` | 1:1 | `path_rename`, `structural_context`, `anchored_content` | | `R-CONT-COPY` | `continued` | 1:1 | `copy_record`, `structural_context`, `anchored_content` | | `R-BRANCH-COPY` | `branched` | 1:N, N>=2 | `copy_record` (group) + per successor: `same_rule_message`, `anchored_content` | diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 951725b..2d2c3b6 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -188,6 +188,20 @@ def rule_needs(rule) -> set: return needs | set(list_or_empty(profile.get("requires_all"))) +# WHICH RECORD FIELDS EACH SELECTOR BRANCH IS OBSERVED FROM. One table: the +# predicate in `mandated_reason` is built from it and the contract's declared +# `observed_from` is checked against it, so a branch cannot read one field while +# the frozen document names another. +SELECTOR_FIELDS = { + "conflicting_rules_present": ("applicable_rules", + "decision_detail.conflicting_rules"), + "ambiguous_candidates_present": ("decision_detail.rules_without_a_unique_candidate", + "decision_detail.ambiguous_candidates"), + "a_defeat_left_no_surviving_rule": ("signals_defeated",), + "otherwise": (), +} + + def mandated_reason(exp, mapping, floor, senior, selector=None): """The reason the policy REQUIRES for this refusal, or None if the contract has not settled the shape. @@ -233,12 +247,20 @@ def of(key, surviving_kinds=None): # branch. A cardinality filter says a rule does not govern this shape; it is # not a statement about the evidence and it selects no reason. It stays in # the record for the recall set - see `reason_selector.cardinality_selects_no_reason`. - holds = { - "conflicting_rules_present": bool(app) or bool(conflicting), - "ambiguous_candidates_present": bool(blunted) or bool(ambiguous), - "a_defeat_left_no_surviving_rule": bool(defeated), - "otherwise": True, - } + # THE FIELDS EACH BRANCH READS, from `SELECTOR_FIELDS` - the same table the + # contract's `observed_from` is checked against, so the declaration and the + # predicate cannot describe different fields. They could before: pointing the + # ambiguity branch's `observed_from` at `signals_defeated` left the suite green + # and the frozen selector telling a mapper to look in the wrong place. Both + # reviewers found it independently. + def reads(path: str): + node = exp + for seg in path.split("."): + node = mapping_or_empty(node).get(seg) if isinstance(node, dict) else None + return node + holds = {when_k: (True if not fields_k + else any(reads(f) for f in fields_k)) + for when_k, fields_k in SELECTOR_FIELDS.items()} says = { "conflicting_rules_present": ("conflicting_rules", "rules applied and disagreed"), "ambiguous_candidates_present": ("several_candidates", @@ -2177,6 +2199,17 @@ def arbitration_leaves(node, trail): f"branch it labels yields {want_!r}. The order is enforced and the " "reasons beside it were not, so this section could say one thing while " "the policy did another - which is the shape it was written to close.") + # ...and the SAME question about `observed_from`, which was the other + # unread field beside it. + saw_ = mapping_or_empty(step).get("observed_from") + want_f = (["occurrence_id"] if when_ == "eligibility_refused" + else sorted(SELECTOR_FIELDS.get(when_, ()))) + check(saw_ == want_f, + f"`reason_selector` step {when_!r} declares observed_from {saw_!r} and " + f"the predicate for that branch reads {want_f}. A frozen selector that " + "names the wrong record field tells a mapper to look somewhere the " + "decision does not come from, and nothing else in this document " + "corrects it.") vocab = mapping_or_empty(policy.get("record_binding_vocabulary")) # THE VOCABULARY ITSELF, BEFORE ANYTHING IS LOOKED UP IN IT. Membership - @@ -3569,6 +3602,13 @@ def prose_faults(value, trail=()): "nothing and cannot be compared against a citation.") defined |= {n_ for n_ in list_or_empty(policy.get("retired_names")) if isinstance(n_, str)} + # ...and the selector's condition tokens, which the contract defines as VALUES + # of a declared enumeration rather than as keys. Prose explaining why a branch + # reads what it reads has to be able to name the branch. + defined |= {mapping_or_empty(st).get("when") + for st in list_or_empty( + mapping_or_empty(policy.get("reason_selector")).get("order")) + if isinstance(mapping_or_empty(st).get("when"), str)} # WHOLE BACKTICKED SPANS, then segment by segment. Matching the identifier # pattern anywhere in the line found `excluded_by_cardinality` INSIDE # `decision_detail.rules_excluded_by_cardinality` - the gate inventing a @@ -3948,6 +3988,27 @@ def prose_faults(value, trail=()): # confident description of something else. with open(DOC, encoding="utf-8") as fh: doc = fh.read() + # THE DOC'S RULE TABLE IS WHAT A MAPPER IS BUILT FROM. Adding `same_pattern_id` + # to R-CONT-DRIFT left that row saying the old three requirements, so the + # frozen policy demanded a refusal while the documentation described the false + # continuity the new case exists to forbid. Anyone implementing from the table + # would have written the defect. Reported by review; the row is corrected and + # the class is closed here rather than the instance. + for rid_d in sorted(rules): + row = re.search(r"^\| `" + re.escape(rid_d) + r"` \|.*$", doc, re.M) + check(row is not None, + f"the doc's rule table has no row for {rid_d}. The table is the " + "human-readable statement of what each rule requires; a rule missing " + "from it is a rule nobody implementing from the document will write.") + if row is None: + continue + absent = [k for k in list_or_empty(mapping_or_empty(rules[rid_d]).get("requires_all")) + if isinstance(k, str) and f"`{k}`" not in row.group(0)] + check(not absent, + f"the doc's row for {rid_d} does not name {absent}, which its " + "`requires_all` does. The contract and the table would then describe " + "two different rules, and the table is the one a mapper is built from.") + check("finding-lineage-decision/v1" in doc, "the doc must name the contract it freezes") check("implementation not started" in doc, "the doc must keep saying no mapper exists, until one does") From fbcf8a2923bbb30b311c9ea4316966514f26295d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 11:17:33 +0000 Subject: [PATCH 76/84] audit(lineage): bounded semantic reader audit of the decision contract, and what it found A one-shot audit, not a new permanent system: for every machine-readable NORMATIVE declaration in finding-lineage-decision/v1, does a semantic reader consult it, and does one targeted mutation - the document saying something DIFFERENT but still well-formed - change a verdict? Out of scope by construction: prose, history, formatting, container/scalar smashing, and the crash-site debt. 391 fields in scope. Everything below was found by it, and every gate added is for a class it actually found. UNPINNED, NOW BOUND 1. A rule's `requires_all` could LOSE a member invisibly - demonstrated on R-CONT-SAME-SITE, R-CONT-DRIFT and R-CONT-COPY, the three whose first requirement is not a structural record. The doc rule-table gate added last round was one-directional, contract -> doc, so a dropped requirement left the table naming one more than the rule and nothing noticed. The relation between a finite set and its second declaration is EQUALITY, and it is equality now - over `rule_needs`, so a partner profile counts too. Also bound: the outcome and cardinality columns, which restated `rules..outcome` and `cardinality.shape` with no link to them. 2. `structural_signals.*.entry_shape` contradicted `matches` without complaint: `copy_record.entry_shape.to = "enclosing symbol"` passed while `matches` binds that key to `successor.path`. An unknown token was rejected and a WRONG KNOWN one was not - membership standing in for the correct member, in the catalog this time, and reachable by nothing but this audit since no fixture reads `entry_shape` at all. It is now an exact vocabulary chosen by the attribute `matches` names. 3. The doc's refusal table said "Five" over six rows - the sixth arrived with stage 0 and the sentence was never asked again - and nothing compared the table to the reasons the policy can actually emit. Both bound; the count is six. 4. `case_obligations.meanings` could define a duty no case carries. The contract already says a declared refusal with no case is a decision nothing pins; this is that rule asked one section over. MEASURED AND LEFT ALONE - A case's `obligations` list can lose a member silently. There is no second declaration of what duties a case owes, so pinning it would mean inventing one. Recorded, not fixed. - `eligibility.condition`, `eligibility.observable_from`, `unavailable_inputs.distinct_from`, `arbitration.conflict.licensed_by_on_refusal` and `reason_mapping.*.detail` are prose-valued statements of things the corpus pins mechanically elsewhere. Out of scope as prose. - The new `entry_shape` gate adds 14 crash sites on unhashable values, taking the contract census 377 -> 391. Left as declared: they are fail-closed, exit is non-zero, and the standing instruction is not to repay that debt. Verified: 16 probes, all biting - each rule dropping a requirement, a rule gaining one the doc lacks, a partner profile dropping one, outcome and shape changed, the wrong-known and not-a-shape-at-all catalog tokens, an unused obligation meaning, the count sentence reverted, and a refusal row deleted. The audit re-run finds no machine-readable normative field left unread. Fixture census 3800/0/55, eligibility 174/0/0. 20 suites green in normal and -O. No production mapper. Also in this commit, from the scoped round: the doc rule table's `requires` column is checked against `rule_needs` rather than `requires_all`, so a group rule's per-partner profile counts - CodeRabbit's finding on c6cfd63, confirmed by stripping the profile from the R-BRANCH-COPY row and watching the suite stay green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- docs/finding-lineage-decision.md | 2 +- identity/tests/test_lineage_decision.py | 106 ++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 9e50f4a..0580d55 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -371,7 +371,7 @@ completeness check found it on its first run. ### Every refusal this policy can emit -Five, and all five are drawn from step 0's vocabulary. **The decision layer +Six, and all six are drawn from step 0's vocabulary. **The decision layer invents no reason values.** When a situation is genuinely unsayable in the senior vocabulary the senior contract is amended in the open — which has now happened twice, both times because this rule fired on review rather than because anyone diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 2d2c3b6..ae18b9c 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2211,6 +2211,51 @@ def arbitration_leaves(node, trail): "decision does not come from, and nothing else in this document " "corrects it.") + # `entry_shape` AGREES WITH `matches`, WHICH IS THE NORMATIVE HALF. An unknown + # token was already rejected, but a WRONG KNOWN one was not: + # `copy_record.entry_shape.to = "enclosing symbol"` passed while the record's + # `to` is a path and `matches` says so. Membership standing in for the correct + # member, in the structural-signal catalog this time - found by the reader + # audit, which is the only place it could have been found, since no fixture + # reads `entry_shape` at all. + # AN EXACT VOCABULARY, not a substring. `want_word in said_n` accepted + # `"path-x"`, so the field could say something meaningless and still name the + # right attribute inside it. Half-checked is what this audit exists to remove. + SHAPE_TOKENS = {"path": {"path", "list of paths"}, + "enclosing_symbol": {"enclosing symbol", + "list of enclosing symbols"}} + for sig_n in sorted(policy["structural_signals"]): + spec_n = mapping_or_empty(policy["structural_signals"][sig_n]) + raw_shape = spec_n.get("entry_shape") + # A record whose entries are bare values declares one shape for the whole + # entry rather than a shape per key - `reformatted_paths` is a list of + # paths. Both forms describe the same thing and both are checked. + shape_n = mapping_or_empty(raw_shape) + for key_n, subject in sorted(mapping_or_empty(spec_n.get("matches")).items()): + attr = str(subject).partition(".")[2] + legal = SHAPE_TOKENS.get(attr, set()) + said_n = raw_shape if isinstance(raw_shape, str) else shape_n.get(key_n) + check(said_n in legal, + f"`structural_signals.{sig_n}.entry_shape.{key_n}` is {said_n!r} " + f"and `matches` binds that key to {subject!r}, so the shape has to " + f"be one of {sorted(legal)}. `matches` is the normative half; a " + "record whose declared shape contradicts what it is matched on " + "tells a mapper to read the wrong field, and one that is not a " + "shape at all tells it nothing.") + + # EVERY DECLARED OBLIGATION MEANING IS CARRIED BY SOME CASE. A meaning nothing + # declares is a duty nobody owes - the same rule this contract already applies + # to a declared refusal with no case, asked one section over. + declared_duties = {x for v in mapping_or_empty( + mapping_or_empty(policy.get("case_obligations")).get("obligations")).values() + for x in list_or_empty(v) if isinstance(x, str)} + for duty_n in sorted(mapping_or_empty( + mapping_or_empty(policy.get("case_obligations")).get("meanings"))): + check(duty_n in declared_duties, + f"`case_obligations.meanings` defines {duty_n!r} and no case declares " + "it. An obligation nobody carries is a duty nobody owes, and it reads " + "as coverage that is not there.") + vocab = mapping_or_empty(policy.get("record_binding_vocabulary")) # THE VOCABULARY ITSELF, BEFORE ANYTHING IS LOOKED UP IN IT. Membership - # `token in vocab["quantifier"]` - raises TypeError when that axis is a @@ -3994,6 +4039,24 @@ def prose_faults(value, trail=()): # continuity the new case exists to forbid. Anyone implementing from the table # would have written the defect. Reported by review; the row is corrected and # the class is closed here rather than the instance. + # THE REFUSAL TABLE IS THE OTHER FINITE SET THE DOC RESTATES. It listed six + # reasons under a sentence saying "Five" - the sixth arrived with stage 0 and + # the count was never updated - and nothing compared the table to the set the + # policy can actually emit. Same shape as the rule table, one section down. + emitted_short = {r.rsplit(":", 1)[-1] for r in emitted} + tabled = {m for m in re.findall(r"^\| `([a-z][a-z-]+)` \|", doc, re.M) + if m in {v.rsplit(":", 1)[-1] for v in senior["limitations"].values()}} + check(tabled == emitted_short, + f"the doc's refusal table names {sorted(tabled)} and this policy can emit " + f"{sorted(emitted_short)}. The table is where a reader learns which " + "refusals exist; a reason it omits is one nobody will handle, and one it " + "invents is a value the policy never produces.") + check(re.search(r"^Six, and all six are drawn from step 0", doc, re.M) is not None, + "the refusal section must open by counting the refusals it lists, and the " + "count must be six. It said `Five` while the table held six: the sentence " + "was written before stage 0 added `missing-occurrence-id` and was never " + "asked again.") + for rid_d in sorted(rules): row = re.search(r"^\| `" + re.escape(rid_d) + r"` \|.*$", doc, re.M) check(row is not None, @@ -4002,12 +4065,45 @@ def prose_faults(value, trail=()): "from it is a rule nobody implementing from the document will write.") if row is None: continue - absent = [k for k in list_or_empty(mapping_or_empty(rules[rid_d]).get("requires_all")) - if isinstance(k, str) and f"`{k}`" not in row.group(0)] + # `rule_needs`, NOT `requires_all`. A group rule's per-partner profile is a + # requirement of the same rule - `rule_needs` has said so since it was + # written, and this file already calls it in three other places - and the + # table presents both in one `requires` column. Reading only the direct + # list let the R-BRANCH-COPY row drop `same_rule_message` and + # `anchored_content` and stay green, describing a branch that needs only + # its structural record. A mapper built from that row would accept any + # successor the copy record named. + # BOTH DIRECTIONS. Checking only contract -> doc left the contract free to + # DROP a requirement: the row would then name one more than the rule, and + # nothing noticed. That is why the reader audit found `requires_all` + # unpinned on all three 1:1 rules whose first member is not a structural + # record - removing `same_path` from R-CONT-SAME-SITE changed no verdict. + # The table is a second declaration of the same finite set, so the honest + # relation is equality. + needs = {k for k in rule_needs(mapping_or_empty(rules[rid_d])) + if isinstance(k, str)} + named = set(re.findall(r"`([a-z_]+)`", row.group(0))) + absent = sorted(needs - named) + extra = sorted(k for k in named - needs if k in senior["evidence_kinds"] + or k in policy["structural_signals"]) check(not absent, - f"the doc's row for {rid_d} does not name {absent}, which its " - "`requires_all` does. The contract and the table would then describe " - "two different rules, and the table is the one a mapper is built from.") + f"the doc's row for {rid_d} does not name {absent}, which the rule " + "requires. The contract and the table would then describe two " + "different rules, and the table is the one a mapper is built from.") + check(not extra, + f"the doc's row for {rid_d} names {extra}, which the rule does NOT " + "require. Either the row is stale or the contract quietly dropped a " + "requirement - and a dropped requirement is a rule that licenses more " + "than it was frozen to license.") + rule_d = mapping_or_empty(rules[rid_d]) + check(f"`{rule_d.get('outcome')}`" in row.group(0), + f"the doc's row for {rid_d} does not name its outcome " + f"{rule_d.get('outcome')!r}. The outcome column is the same " + "declaration as `rules.{rid}.outcome` and had no link to it.") + shape_d = mapping_or_empty(rule_d.get("cardinality")).get("shape") + check(isinstance(shape_d, str) and shape_d in row.group(0), + f"the doc's row for {rid_d} does not name its cardinality shape " + f"{shape_d!r}.") check("finding-lineage-decision/v1" in doc, "the doc must name the contract it freezes") check("implementation not started" in doc, From 5974de1afd4b12022efec55473068f3504b75f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 11:43:32 +0000 Subject: [PATCH 77/84] test(lineage): bind every entry_shape key, and compare the doc's cardinality cell exactly Two blocking findings from the scoped round on fbcf8a2, fixed as the two instances they are. `entry_shape` was read THROUGH `matches`: the gate iterated the keys `matches` names and checked those, so a key no rule matches on was read by nothing. `renamed_symbol_record.entry_shape.to` could be rewritten from `enclosing symbol` to `path` and the suite stayed green - the same membership-for-member shape the gate was written to close, one level up. The unmatched key is BOUND, not stripped. `entry_shape` is the record's schema; `matches` says which of its fields take part in relation binding. Those are different questions, and deriving the schema's domain from `matches` would promote a binding declaration into a type definition it never was. So: - the key SET is bound to the corpus in both directions - a declared key missing from a record was already reported, a record key the schema never declares is now reported too; - every token, matched or not, must be in the vocabulary, since `entry_shape_failures` reads `list of` and `int` off these words; - a matched key's class must be the attribute it is matched on; - and the two ends of one move must be the same class. A copy goes path to path, a fold symbols to symbol, a symbol rename name to name. Cardinality may differ across the pair - a fold's `from` is a list - but the class may not, so `matches` binding either end binds both. That is what reaches `to`. It is asked of `from`/`to` wherever both exist rather than of a declared list, because a declaration the check depended on could be deleted to make it vacuous. `similarity` needed nothing further: `int` is a class the corpus refutes, since a score written as a path is not a number. The doc's cardinality column was a substring test over the whole row, so `1:N, N>=2` could become `1:N, N>=3` while the rule declares `min_successors: 2`, and `11:N` would have matched `1:N` too. The minimum was the half nothing read - enforced against fixtures, restated in the table unchecked. The cell is now parsed and compared against the rendering the contract implies: the shape, plus `, N>=` where a minimum is declared. Thirteen mutations verified: eight on the shape catalog, four on the doc cell, one on the contract minimum. All 20 suites green in both modes. No new audit pass, no census, no adjacent repairs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 28 +++++ identity/tests/test_lineage_decision.py | 114 ++++++++++++++++++--- 2 files changed, 127 insertions(+), 15 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index cba9277..836181a 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -801,6 +801,34 @@ "`signal_defeaters` is validated against this catalog, so a defeater cannot", "name a source that does not exist." ], + "entry_shape_rule": [ + "`entry_shape` IS THE RECORD'S SCHEMA. `matches` says which of its fields take", + "part in relation binding. Those are different questions, and the domain of", + "the first must never be read off the second: a key that no rule matches on is", + "still part of the record a mapper has to parse, and deriving the schema from", + "`matches` would quietly promote a binding declaration into a type definition.", + "", + "A token carries a SHAPE - one value or many, text or int - and, for the two", + "occurrence attributes, a CLASS. The shape half is read of every key against", + "the real records in the frozen corpus: a missing key, a scalar written as a", + "one-element list, a `similarity` written as `\"100\"` are all rejected there,", + "and the key set is read in BOTH directions, so the schema is bound to the data", + "rather than to the rules.", + "The class half was read only where `matches` named the key, which left", + "`renamed_symbol_record.entry_shape.to` free to say `path` with nothing", + "noticing - membership standing in for the correct member, one level up: the", + "keys of `entry_shape` were read THROUGH the keys of `matches`, which covers", + "the intersection and calls it the whole.", + "", + "It is bound now by what the record IS. These records relate two ends of one", + "move, and the two ends are the same kind of thing: a copy goes from a path to", + "a path, a fold from enclosing symbols to an enclosing symbol, a symbol rename", + "from a name to a name. Cardinality may differ across the pair - a fold's", + "`from` is a list - but the class may not. So `from` and `to` are checked", + "against each other, and `matches` binding either end binds both.", + "`similarity` needs nothing further: `int` is a class the corpus itself", + "refutes, since a score written as a path is not a number." + ], "what_the_subset_sweep_proves": [ "The subset sweep proves ARBITRATION ALGEBRA: given a set of rule ids, the", "policy yields one answer, always, regardless of order. It does NOT prove", diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index ae18b9c..a228c66 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -418,6 +418,18 @@ def entry_shape_failures(where: str, cat_spec: dict, entries) -> list: if not isinstance(entry, dict): out.append(f"{where}[{index}] is {entry!r}, not an object") continue + # BOTH DIRECTIONS OF THE KEY SET. Only the declared keys were looked for, + # so a record could carry a field the schema does not mention and the + # schema stayed silent about it. `entry_shape` is the record's SCHEMA - + # `matches` says which fields bind a relation, which is a different + # question - and a schema that describes part of a record is the same + # half-reading as a check that covers part of what it declares. The + # corpus is what binds the key set, so it has to bind it exactly. + for stray in sorted(set(entry) - set(shape)): + out.append(f"{where}[{index}] carries {stray!r}, which `entry_shape` " + f"declares nothing about. The catalog is the schema a mapper " + "parses the record with; a field outside it is a field two " + "readers will disagree over.") for key, declared in shape.items(): # EVERY DECLARED KEY, and its TYPE. This skipped a missing key and # compared only list-versus-scalar, so deleting `similarity` or @@ -2221,9 +2233,32 @@ def arbitration_leaves(node, trail): # AN EXACT VOCABULARY, not a substring. `want_word in said_n` accepted # `"path-x"`, so the field could say something meaningless and still name the # right attribute inside it. Half-checked is what this audit exists to remove. - SHAPE_TOKENS = {"path": {"path", "list of paths"}, - "enclosing_symbol": {"enclosing symbol", - "list of enclosing symbols"}} + # + # AND EVERY KEY, not only the ones `matches` names. This block ran over + # `matches` and read `entry_shape` through it, so a key `matches` does not + # name was read by nothing: `renamed_symbol_record.entry_shape.to` could be + # rewritten from `enclosing symbol` to `path` and the suite stayed green. That + # is the same defect one level up from the one this block closes - the keys of + # one object read THROUGH the keys of another, which covers the intersection + # and calls it the whole. + # + # THE FIX IS TO BIND THE UNMATCHED KEY, NOT TO STRIP IT. `entry_shape` is the + # record's schema; `matches` says which of its fields take part in relation + # binding. Reading the schema's domain off `matches` would make a binding + # declaration into a type definition, which it never was, so an unmatched key + # stays a full member of the record. What binds its class is what the record + # IS: these records relate two ends of one move and the two ends are the same + # kind of thing - a copy path to path, a fold symbols to symbol, a symbol + # rename name to name. Cardinality may differ across the pair; the class may + # not. `matches` binding either end therefore binds both. + # The key SET is bound to the corpus, in both directions, by + # `entry_shape_failures`: a declared key missing from a record and a record + # key the schema never declares are both reported there. + SHAPE_ATTRIBUTE = {"path": "path", + "list of paths": "path", + "enclosing symbol": "enclosing_symbol", + "list of enclosing symbols": "enclosing_symbol", + "int": None} for sig_n in sorted(policy["structural_signals"]): spec_n = mapping_or_empty(policy["structural_signals"][sig_n]) raw_shape = spec_n.get("entry_shape") @@ -2231,17 +2266,51 @@ def arbitration_leaves(node, trail): # entry rather than a shape per key - `reformatted_paths` is a list of # paths. Both forms describe the same thing and both are checked. shape_n = mapping_or_empty(raw_shape) - for key_n, subject in sorted(mapping_or_empty(spec_n.get("matches")).items()): + matches_n = mapping_or_empty(spec_n.get("matches")) + + # EVERY TOKEN IS A TOKEN, matched or not: `entry_shape_failures` reads + # `list of` and `int` off these words, so one outside the vocabulary is + # read as neither a list nor a number. + for key_n, said_n in ([(None, raw_shape)] if not isinstance(raw_shape, dict) + else sorted(shape_n.items())): + token = said_n if isinstance(said_n, str) else None + where_n = (f"`structural_signals.{sig_n}.entry_shape`" if key_n is None + else f"`structural_signals.{sig_n}.entry_shape.{key_n}`") + check(token in SHAPE_ATTRIBUTE, + f"{where_n} is {said_n!r}, which is not one of " + f"{sorted(SHAPE_ATTRIBUTE)}. `entry_shape` is read for whether a " + "field holds one value or many and whether it is text or a " + "number; a word outside the vocabulary is read as neither.") + + # A MATCHED KEY'S CLASS IS THE ATTRIBUTE IT IS MATCHED ON. + for key_n, subject in sorted(matches_n.items()): attr = str(subject).partition(".")[2] - legal = SHAPE_TOKENS.get(attr, set()) - said_n = raw_shape if isinstance(raw_shape, str) else shape_n.get(key_n) - check(said_n in legal, + said_n = raw_shape if not isinstance(raw_shape, dict) else shape_n.get(key_n) + token = said_n if isinstance(said_n, str) else None + check(attr and SHAPE_ATTRIBUTE.get(token) == attr, f"`structural_signals.{sig_n}.entry_shape.{key_n}` is {said_n!r} " f"and `matches` binds that key to {subject!r}, so the shape has to " - f"be one of {sorted(legal)}. `matches` is the normative half; a " - "record whose declared shape contradicts what it is matched on " - "tells a mapper to read the wrong field, and one that is not a " - "shape at all tells it nothing.") + f"name {attr!r}. `matches` is the normative half; a record whose " + "declared shape contradicts what it is matched on tells a mapper " + "to read the wrong field.") + + # AND THE TWO ENDS OF ONE MOVE ARE THE SAME CLASS. This is what reaches + # the keys `matches` does not name. It is asked of `from`/`to` wherever + # both exist rather than of a list the contract declares, because a + # declaration this check depended on could be deleted to make it vacuous - + # and an unread key is exactly what is being repaired here. + if isinstance(raw_shape, dict) and {"from", "to"} <= set(shape_n): + classes = [SHAPE_ATTRIBUTE.get(shape_n[k] + if isinstance(shape_n[k], str) else None) + for k in ("from", "to")] + check(classes[0] == classes[1] and classes[0] is not None, + f"`structural_signals.{sig_n}.entry_shape` says `from` is " + f"{shape_n['from']!r} and `to` is {shape_n['to']!r}. A record " + "relates the two ends of one move, and the two ends are the same " + "kind of thing - a copy goes path to path, a fold symbols to " + "symbol, a rename name to name. Cardinality may differ across the " + "pair; the class may not, and this is what binds the end no rule " + "matches on.") # EVERY DECLARED OBLIGATION MEANING IS CARRIED BY SOME CASE. A meaning nothing # declares is a duty nobody owes - the same rule this contract already applies @@ -4100,10 +4169,25 @@ def prose_faults(value, trail=()): f"the doc's row for {rid_d} does not name its outcome " f"{rule_d.get('outcome')!r}. The outcome column is the same " "declaration as `rules.{rid}.outcome` and had no link to it.") - shape_d = mapping_or_empty(rule_d.get("cardinality")).get("shape") - check(isinstance(shape_d, str) and shape_d in row.group(0), - f"the doc's row for {rid_d} does not name its cardinality shape " - f"{shape_d!r}.") + # THE CARDINALITY CELL, EXACTLY, AND THE MINIMUM WITH IT. `shape_d in + # row` was a substring test over the whole row, so the column was free to + # say anything containing `1:N` - `1:N, N>=3` passed while the rule + # declares `min_successors: 2`, and `11:N` would have passed too. The + # minimum was the half nothing read: it is enforced against fixtures, and + # the table restating it was restating it unchecked. The doc's rendering + # is the shape plus `, N>=` where a minimum is declared, and it is + # compared against that rendering rather than searched for inside a line. + card_d = mapping_or_empty(rule_d.get("cardinality")) + want_card = str(card_d.get("shape")) + "".join( + f", N>={card_d[k]}" for k in ("min_predecessors", "min_successors") + if k in card_d) + cells_d = [c.strip() for c in row.group(0).strip().strip("|").split("|")] + said_card = cells_d[2] if len(cells_d) > 2 else None + check(said_card == want_card, + f"the doc's row for {rid_d} gives cardinality {said_card!r} and the " + f"rule declares {want_card!r}. The column is a second statement of " + "`cardinality`, and a mapper built from a table that relaxes the " + "minimum would license a branch of one.") check("finding-lineage-decision/v1" in doc, "the doc must name the contract it freezes") check("implementation not started" in doc, From 212dd2725e1828cc242a1ce4074da3bfd67cad48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 11:49:16 +0000 Subject: [PATCH 78/84] test(lineage): a whole-entry `entry_shape` is a shape, and the corpus reads it too `entry_shape_failures` returned immediately unless `entry_shape` was an object, so the whole-entry form was read by nothing. `reformatted_paths` declares `path` and its entries are bare paths; rewriting the declaration to `list of paths` left the suite green while two fixtures went on supplying scalars. The class check cannot see it either - `path` and `list of paths` name the same class, which is exactly what makes the cardinality half a separate question. Found by review on 5974de1, and it falsifies a sentence that commit added: `entry_shape_rule` said the shape half is read of every key against the real records in the frozen corpus, while the check under it covered only the per-key form. A rule wider than the check beneath it is the defect this file keeps repairing, and it arrived once more inside the repair. The sentence now says which forms are covered, and says that too. The per-value test is lifted into `token_failures` and called from both branches, so there is one reading of a token rather than two. Ten mutations verified: three on the whole-entry form, four confirming the per-key form still reads list-vs-scalar, member type, and the key set in both directions, and three on the checks from the previous commit. All 20 suites green in both modes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 18 ++++-- identity/tests/test_lineage_decision.py | 64 +++++++++++++++------- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 836181a..dba6873 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -809,11 +809,19 @@ "`matches` would quietly promote a binding declaration into a type definition.", "", "A token carries a SHAPE - one value or many, text or int - and, for the two", - "occurrence attributes, a CLASS. The shape half is read of every key against", - "the real records in the frozen corpus: a missing key, a scalar written as a", - "one-element list, a `similarity` written as `\"100\"` are all rejected there,", - "and the key set is read in BOTH directions, so the schema is bound to the data", - "rather than to the rules.", + "occurrence attributes, a CLASS. The shape half is read of EVERY declaration", + "against the real records in the frozen corpus - per key for a record like", + "`copies`, and whole-entry where the entries are bare values, as", + "`reformatted_paths` are. A missing key, a scalar written as a one-element", + "list, a `similarity` written as `\"100\"` are all rejected there, and the key", + "set is read in BOTH directions, so the schema is bound to the data rather", + "than to the rules.", + "The whole-entry form was the half this sentence claimed and the check did not", + "cover: `reformatted_paths` could be redeclared `list of paths` while the", + "corpus went on supplying scalars, and the class test cannot see it, since", + "both tokens name the same class. A rule wider than the check under it is the", + "defect this document keeps having to repair, and it is worth saying that it", + "arrived once more in the sentence written to announce the repair.", "The class half was read only where `matches` named the key, which left", "`renamed_symbol_record.entry_shape.to` free to say `path` with nothing", "noticing - membership standing in for the correct member, one level up: the", diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index a228c66..72f8c95 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -400,6 +400,34 @@ def boundary_hits(spec: dict, frm, to, by_id: dict, rev_b: dict) -> tuple: return role, [oid for oid in subjects if by_id.get(oid, {}).get(attr) in observed] +def token_failures(where: str, declared, value) -> list: + """One `entry_shape` token against one value: list-or-scalar, then member type. + + Shared by the two forms a declaration takes - a shape per key for records + like `copies`, and one shape for the whole entry where the entries are bare + values like `reformatted_paths`. It was written for the first form only, and + the second was therefore not read at all.""" + out = [] + wants_list = isinstance(declared, str) and declared.startswith("list of") + if wants_list != isinstance(value, list): + out.append( + f"{where} is {value!r}, and `entry_shape` declares it {declared!r}. A " + "scalar written as a one-element list reads the same to a lenient parser " + "and differently to a literal one, so the corpus would sanction a record " + "the contract does not.") + return out + want_type = int if declared == "int" else str + members = value if wants_list else [value] + wrong = [m for m in members if not isinstance(m, want_type) + or isinstance(m, bool)] + if wrong: + out.append( + f"{where} holds {wrong!r}, and `entry_shape` declares {declared!r}. A " + "record whose types differ from the catalog is one a mapper parses " + "differently than the contract describes.") + return out + + def entry_shape_failures(where: str, cat_spec: dict, entries) -> list: """The catalog's `entry_shape` says whether a field is a scalar or a list. @@ -412,7 +440,21 @@ def entry_shape_failures(where: str, cat_spec: dict, entries) -> list: corpus, which is what `entry_shape` exists to prevent.""" out = [] shape = cat_spec.get("entry_shape") - if not isinstance(shape, dict) or not isinstance(entries, list): + if not isinstance(entries, list): + return out + # A WHOLE-ENTRY SHAPE IS A SHAPE. This returned immediately unless + # `entry_shape` was an object, so `reformatted_paths` - whose entries are + # bare paths rather than records - was read by nothing here, and rewriting + # its declaration from `path` to `list of paths` left the suite green while + # the corpus went on supplying scalars. The class check one section down + # cannot see it either: both tokens name the same class, which is exactly + # what makes the cardinality half a separate question. + # And it makes `entry_shape_rule` - "read of every key against the real + # records in the frozen corpus" - a claim wider than the check under it, + # which is the shape this file keeps having to repair. + if not isinstance(shape, dict): + for index, entry in enumerate(entries): + out.extend(token_failures(f"{where}[{index}]", shape, entry)) return out for index, entry in enumerate(entries): if not isinstance(entry, dict): @@ -441,24 +483,8 @@ def entry_shape_failures(where: str, cat_spec: dict, entries) -> list: f"declares as {declared!r}. A mapper reading the catalog " "expects the field to be there.") continue - wants_list = isinstance(declared, str) and declared.startswith("list of") - if wants_list == isinstance(entry[key], list): - want_type = int if declared == "int" else str - members = entry[key] if wants_list else [entry[key]] - wrong = [m for m in members if not isinstance(m, want_type) - or isinstance(m, bool)] - if wrong: - out.append( - f"{where}[{index}].{key} holds {wrong!r}, and `entry_shape` " - f"declares {declared!r}. A record whose types differ from the " - "catalog is one a mapper parses differently than the contract " - "describes.") - if wants_list != isinstance(entry[key], list): - out.append( - f"{where}[{index}].{key} is {entry[key]!r}, and `entry_shape` " - f"declares it {declared!r}. A scalar written as a one-element list " - "reads the same to a lenient parser and differently to a literal " - "one, so the corpus would sanction a record the contract does not.") + out.extend(token_failures(f"{where}[{index}].{key}", declared, + entry[key])) return out From e1d1523ea7556a8c09b54258a139f88fcc47d7df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 12:28:14 +0000 Subject: [PATCH 79/84] policy(lineage): a continuation must show the analyser said the same thing Two P1s from the scoped round, and they are one finding asked at three sites. R-CONT-SAME-SITE, R-CONT-RENAME and R-CONT-COPY required only `same_path` / `path_rename` / `copy_record` plus `structural_context` and `anchored_content`. Every one of those describes a PLACE and a TEXT. None says the finding is the same finding, so a diagnostic reclassified or reworded at a site that did not otherwise change satisfied all of them and inherited a lineage it had not earned. R-CONT-DRIFT was given `same_pattern_id` for exactly this reason and the three rules beside it were never asked - a claim applied where it was named and not at the adjacent site, which is this branch's signature defect. Which kind is forced, not stylistic. The same-path rule takes `same_pattern_id`: `finding-pattern/v1` hashes path, rule and message together, so with the path equal the id differs exactly when the rule or the message does. The two rules that survive a move cannot - the path is IN the hash, so requiring it would make them unsatisfiable on every real rename or copy - and they take `same_rule_message`, the path-independent kind the sixth senior amendment already defines. Three preregistered witnesses, not one, and not an assertion in the suite: 17 a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation 18 a-reclassified-defect-across-a-rename-is-not-a-continuation 19 a-reclassified-defect-across-a-copy-is-not-a-continuation 18 holds the rule id fixed and moves the message; 19 does the opposite. That split is load-bearing: `same_rule_message` is ONE comparison over both halves, and a corpus that always changed both at once would let the definition shrink to either half unnoticed. The copy is its own case rather than the rename's result by symmetry. The witness mechanism itself had the same hole one level down. `required_kind_withheld_a_rule` checked that the contract named a rule and a kind and that `not_applicable` said so - never that the kind actually FAILS in the case's own payload. The same-site witness could be rewritten to carry one pattern id on both sides while still declaring that R-CONT-SAME-SITE stood down because it did not. `pair_property_kinds` now says which kinds are readable from a pair of occurrences, every witness kind must be one, and the kind must genuinely fail for every predecessor-successor pair in the case. This needs no applicability and no mapper. Verified: three load-bearing probes (each requirement removed from contract AND doc together, so the doc gate cannot mask it - each fails from its own fixture), four fixture-truth probes, six vacuity probes on the new table including shrinking `same_rule_message` to either half, and the ten earlier entry-shape and cardinality probes re-run. All 20 suites green in both modes. Held back, not fixed: Codex's third P1 asks for producer/tool continuity before any rule relates two occurrences. It is a real gap - occurrence identity includes `producer_run_id` and `producer_name`, and the decision fixture schema carries neither - but closing it means new senior vocabulary, a new field on every fixture occurrence and an input-model change, which is a policy decision for the owner rather than a checker repair. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 85 +++++++++++++++- docs/finding-lineage-decision.md | 34 ++++++- ...t-across-a-copy-is-not-a-continuation.json | 99 +++++++++++++++++++ ...across-a-rename-is-not-a-continuation.json | 87 ++++++++++++++++ ...-unchanged-site-is-not-a-continuation.json | 79 +++++++++++++++ identity/tests/test_lineage_decision.py | 42 ++++++++ 6 files changed, 418 insertions(+), 8 deletions(-) create mode 100644 identity/fixtures/lineage-decision/a-reclassified-defect-across-a-copy-is-not-a-continuation.json create mode 100644 identity/fixtures/lineage-decision/a-reclassified-defect-across-a-rename-is-not-a-continuation.json create mode 100644 identity/fixtures/lineage-decision/a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation.json diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index dba6873..5897d0f 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -140,7 +140,8 @@ "requires_all": [ "same_path", "structural_context", - "anchored_content" + "anchored_content", + "same_pattern_id" ], "means": "the occurrence is where it was, in the symbol it was in, saying what it said" }, @@ -166,7 +167,8 @@ "requires_all": [ "path_rename", "structural_context", - "anchored_content" + "anchored_content", + "same_rule_message" ], "means": "the file moved and the occurrence moved with it, unchanged inside", "note": "path_rename alone can never do this. A rename relates FILES; the other two are what relate the occurrence." @@ -179,7 +181,8 @@ "requires_all": [ "copy_record", "structural_context", - "anchored_content" + "anchored_content", + "same_rule_message" ], "means": "the file was COPIED and this occurrence went with the copy, unchanged inside, in a symbol of the same name", "note": "the literal mirror of R-CONT-RENAME, differing only in where the path relation comes from: `copies` instead of `renames`. Its absence was a hole, not a simplification - finding-lineage/v1 freezes `deleted-source-survives-in-its-copy` as continued and forbids even unresolved there, and with no rule of this shape the only rule that fired on that edge was R-BRANCH-COPY, which names a different outcome. A policy whose single applicable rule contradicts a frozen case is not incomplete; it is wrong." @@ -524,7 +527,10 @@ "a-copy-into-two-new-files-is-a-branch", "a-fold-across-files-is-still-a-merge", "an-ambiguity-outranks-a-cardinality-rejection", - "a-different-defect-at-the-same-site-is-not-a-drift" + "a-different-defect-at-the-same-site-is-not-a-drift", + "a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation", + "a-reclassified-defect-across-a-rename-is-not-a-continuation", + "a-reclassified-defect-across-a-copy-is-not-a-continuation" ], "preregistration_rule": [ "Fixed before the mapper, same as slice 2 step 0. These are about the POLICY", @@ -999,6 +1005,16 @@ "a-fold-across-files-is-still-a-merge": [ "rule_licensed_alone" ], + "a-reclassified-defect-across-a-copy-is-not-a-continuation": [ + "required_kind_withheld_a_rule", + "cardinality_excluded_a_rule" + ], + "a-reclassified-defect-across-a-rename-is-not-a-continuation": [ + "required_kind_withheld_a_rule" + ], + "a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation": [ + "required_kind_withheld_a_rule" + ], "a-recorded-rename-is-not-an-unresolved": [ "rule_licensed_alone" ], @@ -1243,6 +1259,7 @@ "path_rename": "rename_record", "same_path": "no_structural_record", "same_pattern_id": "no_structural_record", + "same_rule_message": "no_structural_record", "structural_context": "no_structural_record" }, "why": [ @@ -1416,6 +1433,18 @@ "rule": "R-CONT-DRIFT", "kind": "same_pattern_id" }, + "a-reclassified-defect-across-a-copy-is-not-a-continuation": { + "rule": "R-CONT-COPY", + "kind": "same_rule_message" + }, + "a-reclassified-defect-across-a-rename-is-not-a-continuation": { + "rule": "R-CONT-RENAME", + "kind": "same_rule_message" + }, + "a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation": { + "rule": "R-CONT-SAME-SITE", + "kind": "same_pattern_id" + }, "rule": [ "WHICH RULE STOOD DOWN FOR WHICH KIND, NAMED BY THE CONTRACT RATHER THAN BY THE", "SUITE. A case carrying `required_kind_withheld_a_rule` declares its witness here,", @@ -1432,7 +1461,53 @@ "", "Hard-coding the pair in the checker was the other option and it is the one this", "project keeps refusing: the value that decides a case belongs in the frozen", - "document, where a reader can disagree with it." + "document, where a reader can disagree with it.", + "", + "Three more witnesses arrived with the same finding one rule over: review asked", + "why R-CONT-DRIFT had to prove the defect was the same defect while", + "R-CONT-SAME-SITE, R-CONT-RENAME and R-CONT-COPY did not. They had no answer.", + "`anchored_content` is source text - evidence the STATEMENT survived, never that", + "the finding is the same finding - so a reclassified diagnostic at an unchanged", + "site, across a rename, and across a copy each inherited a lineage it had not", + "earned. Each rule now names a kind and each has its own case: the copy did not", + "get the rename's result by symmetry, which is the assumption this branch keeps", + "having to unmake." + ] + }, + "pair_property_kinds": { + "map": { + "same_pattern_id": [ + "pattern_id" + ], + "same_rule_message": [ + "rule", + "message" + ] + }, + "why": [ + "A WITNESS HAS TO BE FALSIFIABLE BY THE PAYLOAD IT SITS IN.", + "", + "`required_kind_witnesses` named the rule and the kind, and the obligation", + "checked that `not_applicable` said so and that the rule really requires the", + "kind. What nothing checked is whether the kind FAILS in the case's own", + "occurrences. So a witness could declare that R-CONT-SAME-SITE stood down for", + "`same_pattern_id` while both occurrences carried the same pattern id - the", + "claim the fixture exists to make, contradicted by the fixture, accepted.", + "Membership standing in for the correct member yet again, one level below", + "where it was last repaired.", + "", + "These kinds are properties of a PAIR of occurrences and nothing else: two", + "strings the producer already emitted, compared. Evaluating them needs no", + "applicability and no mapper, which is what makes them checkable here at all", + "and keeps this from becoming the second decision engine step 1 refuses to", + "grow.", + "", + "EVERY witness kind must appear in `map`. That is what stops this from being", + "emptied into a vacuum: drop an entry and the witnesses naming it fail", + "immediately, rather than passing unevaluated. A kind that genuinely cannot be", + "read from a pair - a structural record, a boundary - would need this rule", + "amended in the open before a witness could name it, and that is the intended", + "cost." ] } } diff --git a/docs/finding-lineage-decision.md b/docs/finding-lineage-decision.md index 0580d55..528f79f 100644 --- a/docs/finding-lineage-decision.md +++ b/docs/finding-lineage-decision.md @@ -301,13 +301,38 @@ Six, deliberately small and deliberately not exhaustive. | id | outcome | shape | requires | |---|---|---|---| -| `R-CONT-SAME-SITE` | `continued` | 1:1 | `same_path`, `structural_context`, `anchored_content` | +| `R-CONT-SAME-SITE` | `continued` | 1:1 | `same_path`, `structural_context`, `anchored_content`, `same_pattern_id` | | `R-CONT-DRIFT` | `continued` | 1:1 | `same_path`, `structural_context`, `line_drift`, `same_pattern_id` | -| `R-CONT-RENAME` | `continued` | 1:1 | `path_rename`, `structural_context`, `anchored_content` | -| `R-CONT-COPY` | `continued` | 1:1 | `copy_record`, `structural_context`, `anchored_content` | +| `R-CONT-RENAME` | `continued` | 1:1 | `path_rename`, `structural_context`, `anchored_content`, `same_rule_message` | +| `R-CONT-COPY` | `continued` | 1:1 | `copy_record`, `structural_context`, `anchored_content`, `same_rule_message` | | `R-BRANCH-COPY` | `branched` | 1:N, N>=2 | `copy_record` (group) + per successor: `same_rule_message`, `anchored_content` | | `R-MERGE-FOLD` | `merged` | N:1, N>=2 | `merge_record` (group) + per predecessor: `same_rule_message`, `anchored_content` | +**Every continuation rule asks what the analyser SAID, not only where it was.** +`same_path`, `structural_context`, `anchored_content`, `path_rename` and +`copy_record` all describe a *place* and a *text*. None of them says the finding +is the same finding, so a diagnostic reclassified or reworded at a site that did +not otherwise change satisfied every one of them and inherited a lineage it had +not earned. R-CONT-DRIFT was given `same_pattern_id` for exactly this reason and +the three rules beside it were never asked — a claim applied where it was named +and not at the adjacent site, which is this branch's signature defect. + +Which kind depends on whether the path moved, and the choice is forced rather +than stylistic. The same-path rules take `same_pattern_id`: `finding-pattern/v1` +hashes path, rule and message together, so with the path held equal the id +differs exactly when the rule or the message differs. The rules that survive a +move cannot: the path is *in* the hash, so a renamed or copied occurrence can +never share a pattern id with its predecessor, and requiring one would make both +rules unsatisfiable on real data. They take `same_rule_message`, the +path-independent kind the sixth senior amendment added for the group profiles — +one comparison over the rule and the message together, never two halves that +would reach the evidence floor between them. + +Cases 17, 18 and 19 are where those requirements are either doing work or are +decoration, and they are three cases rather than one on purpose: 18 holds the +rule id fixed and moves the message, 19 does the opposite, and the copy does not +get the rename's result by symmetry. + **No rule licenses `ended` or `new`.** Those are earned by boundary evidence, which step 0 already governs; a rule for them here would create a second, competing route to a death or a birth — the exact defect that contract was @@ -524,6 +549,9 @@ one not. | 14 | `a-fold-across-files-is-still-a-merge` | `merged` — the folded predecessor lives in another file | | 15 | `an-ambiguity-outranks-a-cardinality-rejection` | `unresolved` / `ambiguous-candidates` — both rejecting stages fire, and only one of them is an answer | | 16 | `a-different-defect-at-the-same-site-is-not-a-drift` | `unresolved` / `no-mapping-evidence` — the site is shared and the diagnostic is not | +| 17 | `a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation` | `unresolved` / `no-mapping-evidence` — nothing about the site changed, and the analyser changed its mind | +| 18 | `a-reclassified-defect-across-a-rename-is-not-a-continuation` | `unresolved` / `no-mapping-evidence` — the file moved, the text came with it, and the message was reworded | +| 19 | `a-reclassified-defect-across-a-copy-is-not-a-continuation` | `unresolved` / `no-mapping-evidence` — the copy's mirror, and the half where the rule id moves instead | Cases 1 and 2 carry an extra burden, because a fixture can be right for the wrong reason: move the losing rule out of `applicable_rules` and the answer is diff --git a/identity/fixtures/lineage-decision/a-reclassified-defect-across-a-copy-is-not-a-continuation.json b/identity/fixtures/lineage-decision/a-reclassified-defect-across-a-copy-is-not-a-continuation.json new file mode 100644 index 0000000..3063493 --- /dev/null +++ b/identity/fixtures/lineage-decision/a-reclassified-defect-across-a-copy-is-not-a-continuation.json @@ -0,0 +1,99 @@ +{ + "case": "a-reclassified-defect-across-a-copy-is-not-a-continuation", + "title": "The same move as `copy-at-one-to-one-is-not-a-branch`, with the finding reclassified", + "why": "THE THIRD WITNESS, AND IT IS A SEPARATE FIXTURE ON PURPOSE. R-CONT-COPY is the mirror of R-CONT-RENAME - a different record, the same shape of requirement - and fixing the rename and assuming the copy behaves the same way is how a claim gets applied where it was named and never asked one rule over. That is the defect this whole branch keeps finding, so the copy gets its own case rather than the benefit of the doubt.\n\nTHE MESSAGE IS UNCHANGED ON PURPOSE, and this is the half the rename witness cannot carry. There the rule id is held fixed and the message moves; here the message is held fixed and the analyser reports the statement under a different rule. `same_rule_message` is ONE comparison over both halves, and a corpus that always changes both at once would let that definition shrink to either half with nothing to object.\n\nThe move is `copy-at-one-to-one-is-not-a-branch` unchanged: Broker/Doc.cs is deleted, its contents live on at Broker/Moved.cs, the copy record explains the move, and it defeats the deletion boundary because one deletion appearing as a copy source is one story told twice. So `copy_record`, `structural_context` and `anchored_content` all hold and the rule used to license `continued`. As with the rename it must be `same_rule_message` and not `same_pattern_id`: the copy changes the path, and the path is hashed into the id.\n\nThe boundary half is what makes this case slower than a refusal. `ended` is still not available for occ-a1 - the copy record defeats the deletion exactly as it does in the twin - so the honest outcome is `unresolved` on both sides, not a death certificate issued because a rule stopped applying.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "deleted_paths": [ + "Broker/Doc.cs" + ], + "copies": [ + { + "from": "Broker/Doc.cs", + "to": "Broker/Moved.cs", + "similarity": 100 + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Moved.cs", + "rule": "DOTNET_EVENT_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "d526e5e428273013", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a1" + ], + "to": [], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "decision_detail": { + "rules_excluded_by_cardinality": [ + "R-BRANCH-COPY" + ] + }, + "boundary_defeated": { + "boundary:containing-file-deleted": "revision_b.copies[].from" + }, + "not_applicable": { + "R-CONT-COPY": "same_rule_message does not hold: the message is the same and the rule id is not, and the kind is one comparison over both", + "R-BRANCH-COPY": "cardinality 1:N with min_successors 2, and there is exactly one successor. This is a PRECONDITION, and it is why the rule is recorded as cardinality-excluded rather than out-argued.", + "R-CONT-SAME-SITE": "the path changed", + "R-CONT-DRIFT": "the path changed, so same_path did not fire", + "R-CONT-RENAME": "the record is a copy, not a rename; path_rename did not fire", + "R-MERGE-FOLD": "no merge record" + }, + "note": "the copy record still defeats the deletion, so this is not an `ended`. A rule ceasing to apply removes a link; it does not manufacture a boundary." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "note": "the mirror side, reading what is observable HERE: the copy record says this file arrived from another, and nothing says this finding descends from one reported there." + } + ], + "forbid": [ + "continued from occ-a1 to occ-b1 - the contents were copied and the finding was reclassified", + "same_rule_message treated as holding because the messages agree - the rule id is half of the comparison", + "ended for occ-a1 on the strength of deleted_paths - the copy record defeats it here exactly as in the twin", + "new for occ-b1 - Broker/Moved.cs is a copy target, not an added path", + "same_pattern_id required of this rule - the path is hashed into the id, so a copy can never carry one", + "R-BRANCH-COPY absent from decision_detail - a rule excluded by cardinality stays in the recall set" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/a-reclassified-defect-across-a-rename-is-not-a-continuation.json b/identity/fixtures/lineage-decision/a-reclassified-defect-across-a-rename-is-not-a-continuation.json new file mode 100644 index 0000000..59f56d2 --- /dev/null +++ b/identity/fixtures/lineage-decision/a-reclassified-defect-across-a-rename-is-not-a-continuation.json @@ -0,0 +1,87 @@ +{ + "case": "a-reclassified-defect-across-a-rename-is-not-a-continuation", + "title": "The same move as `a-recorded-rename-is-not-an-unresolved`, with the message reworded", + "why": "THE TWIN OF `a-recorded-rename-is-not-an-unresolved`, varying one thing. The physical move is identical - Broker/Old.cs to Broker/New.cs, same enclosing symbol, byte-identical statement, rename record present and readable - and the only difference is that the analyser now words its message differently. There the answer is `continued`; here it must not be.\n\nTHE RULE ID IS UNCHANGED ON PURPOSE. `same_rule_message` is ONE kind and not `same_rule` beside `same_message`, and a corpus in which every witness changes both halves at once cannot tell the difference: the definition could quietly shrink to the rule alone and every case would still pass. So this witness holds the rule fixed and moves the message, and `a-reclassified-defect-across-a-copy-is-not-a-continuation` does the opposite. Between them the two halves of the comparison are each load-bearing on real data rather than on an assertion.\n\nR-CONT-RENAME asked for `path_rename`, `structural_context` and `anchored_content`, every one of which holds across a rewording, so the rule licensed a `continued` on the strength of the FILE having moved and the TEXT having survived. Neither is a statement about the defect.\n\n`same_pattern_id` cannot be the discriminator here and this is the whole reason `same_rule_message` exists: `finding-pattern/v1` hashes the path into the id, so two occurrences of one defect at two paths have different pattern ids BY CONSTRUCTION - requiring it would make the rule unsatisfiable on every real rename. The sixth senior amendment added the path-independent kind for the group rules; this is the same question asked at 1:1.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Old.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "a50f4f69ffb5c777", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "renames": [ + { + "from": "Broker/Old.cs", + "to": "Broker/New.cs", + "similarity": 96 + } + ], + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/New.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event subscription is never unsubscribed", + "start_line": 42, + "start_column": null, + "pattern_id": "04ac7bffc28a23ce", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a1" + ], + "to": [], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "not_applicable": { + "R-CONT-RENAME": "same_rule_message does not hold: the rule id is the same and the message is not, and the kind is one comparison over both", + "R-CONT-SAME-SITE": "the paths differ, so same_path did not fire", + "R-CONT-DRIFT": "the paths differ, so same_path did not fire", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record", + "R-MERGE-FOLD": "no merge record" + }, + "note": "path_rename, structural_context and anchored_content all hold. They say the file moved and the text came with it; the analyser is the only witness to what the finding IS, and it worded it differently." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "note": "the mirror side, reading what is observable HERE: a rename record says this file arrived from another, and nothing says this finding descends from one that was reported there." + } + ], + "forbid": [ + "continued from occ-a1 to occ-b1 - the file moved and the message changed", + "same_rule_message treated as holding because the rule ids agree - the message is half of the comparison", + "same_pattern_id required of this rule - the path is hashed into the id, so a rename can never carry one", + "ended for occ-a1 - the file was renamed, not deleted", + "new for occ-b1 - no boundary evidence names its path as added" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/fixtures/lineage-decision/a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation.json b/identity/fixtures/lineage-decision/a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation.json new file mode 100644 index 0000000..c1ce663 --- /dev/null +++ b/identity/fixtures/lineage-decision/a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation.json @@ -0,0 +1,79 @@ +{ + "case": "a-reclassified-defect-at-an-unchanged-site-is-not-a-continuation", + "title": "The statement did not move and did not change; the analyser changed its mind about it", + "why": "THE CASE R-CONT-SAME-SITE NEEDED BEFORE IT COULD REQUIRE A PATTERN, and the reason it is a separate fixture from `a-different-defect-at-the-same-site-is-not-a-drift`. That one escapes the rule because the CONTENT changed, so `anchored_content` fails and nothing has to be decided. Here nothing about the site changed at all: same file, same enclosing symbol, same line, byte-identical statement. What changed is what the analyser SAID about it - `DOTNET_RESOURCE_LEAK` / 'event handler subscribed and never removed' became `DOTNET_EVENT_LEAK` / 'event subscription is never unsubscribed'.\n\nUnder the old requirements `same_path`, `structural_context` and `anchored_content` all held, so the rule licensed `continued` and a new diagnostic inherited an old defect's lineage. `anchored_content` is source text; it is evidence that the STATEMENT survived, never that the finding is the same finding. R-CONT-DRIFT was given `same_pattern_id` for exactly this reason and R-CONT-SAME-SITE was never asked - a claim applied where it was named and not at the adjacent site.\n\n`same_pattern_id` is the right discriminator here and not `same_rule_message`, precisely because this is a SAME-PATH rule: `finding-pattern/v1` hashes path, rule and message together, so with the path held equal the pattern id differs exactly when the rule or the message differs. The two same-path continuation rules now ask the same question, which is what they always meant.", + "revision_a": { + "revision": "r1", + "occurrences": [ + { + "occurrence_id": "occ-a1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_RESOURCE_LEAK", + "message": "event handler subscribed and never removed", + "start_line": 42, + "start_column": null, + "pattern_id": "f964274604b5c187", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "revision_b": { + "revision": "r2", + "occurrences": [ + { + "occurrence_id": "occ-b1", + "path": "Broker/Doc.cs", + "rule": "DOTNET_EVENT_LEAK", + "message": "event subscription is never unsubscribed", + "start_line": 42, + "start_column": null, + "pattern_id": "57a86149a33cc9e5", + "enclosing_symbol": "DocView.Wire", + "anchored_content": "_store.Changed += OnChanged;" + } + ] + }, + "expect": [ + { + "outcome": "unresolved", + "side": "a", + "frm": [ + "occ-a1" + ], + "to": [], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "not_applicable": { + "R-CONT-SAME-SITE": "same_pattern_id does not hold: the path is unchanged, so the differing id is the rule and the message differing", + "R-CONT-DRIFT": "same_pattern_id does not hold either, and the line did not move", + "R-CONT-RENAME": "no rename record, and the path did not change", + "R-CONT-COPY": "no copy record", + "R-BRANCH-COPY": "no copy record, and the shape is 1:1", + "R-MERGE-FOLD": "no merge record, and the shape is 1:1" + }, + "note": "same_path, structural_context and anchored_content all hold, and they say the STATEMENT survived. Three surviving kinds that describe a place are still not a rule, which is why combinations are named rather than counted." + }, + { + "outcome": "unresolved", + "side": "b", + "frm": null, + "to": [ + "occ-b1" + ], + "reason": "lineage-id-unavailable:no-mapping-evidence", + "applicable_rules": [], + "licensed_by": [], + "note": "the mirror side, and its reason comes from what is observable HERE: nothing links this occurrence back and nothing says it is new. No signal is reported as defeated, because a defeat is a statement about a relation and this record names no predecessor." + } + ], + "forbid": [ + "continued from occ-a1 to occ-b1 - the statement survived and the diagnostic did not", + "anchored_content treated as evidence that the finding is the same finding", + "ended for occ-a1 or new for occ-b1 - neither carries boundary evidence", + "insufficient-evidence-kind or insufficient-evidence-combination - nothing was defeated here" + ], + "contract": "finding-lineage-decision/v1", + "status": "preregistered-unimplemented" +} diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 72f8c95..43a40f8 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -3441,6 +3441,48 @@ def shown(ids_): f"{name}: the witness names {w_kind!r}, which " f"{w_rule} does not require. A rule cannot stand down " "over a requirement it does not have.") + # AND THE KIND HAS TO FAIL IN THIS CASE'S OWN PAYLOAD. + # Naming the rule and the kind pinned the DECLARATION and + # nothing else, so the same-site witness could be rewritten + # to carry one pattern id on both sides - `same_pattern_id` + # holding - while still declaring that R-CONT-SAME-SITE + # stood down because it did not. The claim the case exists + # to make, contradicted by the case, accepted. The two + # cross-path witnesses survived only because recomputing + # `pattern_id` caught the edit, which is luck, not a check. + # `pair_property_kinds` says which kinds are readable from + # a pair of occurrences - two strings the producer emitted, + # compared - and every witness kind must be one, so the + # table cannot be emptied into a vacuum. + fields_w = mapping_or_empty(mapping_or_empty( + policy.get("pair_property_kinds")).get("map")).get(w_kind) + check(isinstance(fields_w, list) and fields_w + and all(isinstance(f, str) for f in fields_w), + f"{name}: the witness names {w_kind!r}, and " + "`pair_property_kinds.map` gives it " + f"{fields_w!r}. A witness whose kind cannot be read " + "off the pair is a claim this corpus cannot falsify.") + if isinstance(fields_w, list) and all( + isinstance(f, str) for f in fields_w) and fields_w: + preds = exp.get("frm") + preds = ([preds] if isinstance(preds, str) + else list(preds or [])) + held = sorted( + (a_id, b_occ.get("occurrence_id")) + for a_id in preds + for b_occ in (case.get("revision_b") or {}).get( + "occurrences") or [] + if isinstance(b_occ, dict) + and by_id.get(a_id) is not None + and all(by_id[a_id].get(f) == b_occ.get(f) + for f in fields_w)) + check(not held, + f"{name}: the witness says {w_rule} stood down " + f"because {w_kind!r} did not hold, and it HOLDS for " + f"{held} - the pair agrees on {fields_w}. A case " + "that contradicts the claim it was preregistered to " + "make proves the opposite of what it was written " + "for.") said = mapping_or_empty(exp.get("not_applicable")).get(w_rule) if isinstance(said, str) and w_kind in said: met = True From 93bb253e73875b75b1f02e3f790f2fdbeb16b337 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 12:51:49 +0000 Subject: [PATCH 80/84] contract(lineage): what a pair property compares belongs to the contract that owns the kind Both reviewers found the same residue in the previous commit, and they are right. `pair_property_kinds` lived in the JUNIOR contract and its field list was validated only as a non-empty list of strings, so it could be WIDENED: add `path` to `same_rule_message`, make the rename witness agree on rule and message, and the differing paths still read as the kind failing. The fixture written to prove the requirement could lie again, one level below where that was last repaired - and the amended kind would have made R-CONT-RENAME and R-CONT-COPY unsatisfiable on every real move. My probes on that table tested shrinking it and never widening it. That is the instrument blindness this branch has now hit twice. The table moves to `finding-lineage/v1` as `pair_property_equality_fields`, for exactly the reason `evidence_kind_observation` beside it is frozen there: a junior contract that defines what a senior kind compares has redefined the kind. The junior copy is deleted rather than checked against the senior one - one source, not a projection to keep in sync. What now holds it: - TOTAL and PARTITIONED over the pair properties. Every kind `evidence_kind_observation` calls a `pair_property` is in `map` or in `not_an_equality`, never both, never neither. `line_drift` is why the second table exists: the line MOVED, which is not an equality of any field. - Every field named must be one the occurrences carry. - ENTAILMENT. A kind whose fields contain another's requires that kind too, so the frozen exclusive pairs are closed under containment before they are applied: `same_rule_message` widened with `path` entails `same_path`, which is frozen exclusive with `path_rename`, which R-CONT-RENAME requires. The rule is dead and the existing law says so. - INDEPENDENCE. No kind's fields may contain another's at all. The floor for a `continued` counts KINDS, so a kind that is another kind and more lets one observation meet the floor by itself - the argument the sixth amendment already made for keeping `same_rule_message` one kind instead of two halves. This is the half the reports left open: widening with `path` was caught by exclusivity, but widening `same_rule_message` with `anchored_content`, or `same_pattern_id` with `path`, was not. Both were inert on the present rules and neither let a witness lie; both were still a kind quietly becoming stronger than the one the senior contract defines. Thirteen mutations verified, including the reported exploit end to end, both widenings, both shrinkings, an emptied map, a kind moved to `not_an_equality` to dodge the comparison, a field the occurrences do not carry, and decision E's own witness made to lie. The seventeen earlier probes re-run. All 20 suites green in both modes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 48 +++------ contracts/finding-lineage-v1.json | 62 ++++++++++++ identity/tests/test_lineage_decision.py | 112 +++++++++++++++++++-- 3 files changed, 179 insertions(+), 43 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 5897d0f..8f559b5 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -1471,43 +1471,21 @@ "site, across a rename, and across a copy each inherited a lineage it had not", "earned. Each rule now names a kind and each has its own case: the copy did not", "get the rename's result by symmetry, which is the assumption this branch keeps", - "having to unmake." - ] - }, - "pair_property_kinds": { - "map": { - "same_pattern_id": [ - "pattern_id" - ], - "same_rule_message": [ - "rule", - "message" - ] - }, - "why": [ - "A WITNESS HAS TO BE FALSIFIABLE BY THE PAYLOAD IT SITS IN.", - "", - "`required_kind_witnesses` named the rule and the kind, and the obligation", - "checked that `not_applicable` said so and that the rule really requires the", - "kind. What nothing checked is whether the kind FAILS in the case's own", - "occurrences. So a witness could declare that R-CONT-SAME-SITE stood down for", - "`same_pattern_id` while both occurrences carried the same pattern id - the", - "claim the fixture exists to make, contradicted by the fixture, accepted.", - "Membership standing in for the correct member yet again, one level below", - "where it was last repaired.", + "having to unmake.", "", - "These kinds are properties of a PAIR of occurrences and nothing else: two", - "strings the producer already emitted, compared. Evaluating them needs no", - "applicability and no mapper, which is what makes them checkable here at all", - "and keeps this from becoming the second decision engine step 1 refuses to", - "grow.", + "A witness also has to be FALSIFIABLE BY THE PAYLOAD IT SITS IN. Naming the rule", + "and the kind pinned the DECLARATION and nothing else, so the same-site witness", + "could be rewritten to carry one pattern id on both sides while still declaring", + "that R-CONT-SAME-SITE stood down because it did not. The kind must therefore", + "genuinely FAIL between the predecessor and every successor of the case.", "", - "EVERY witness kind must appear in `map`. That is what stops this from being", - "emptied into a vacuum: drop an entry and the witnesses naming it fail", - "immediately, rather than passing unevaluated. A kind that genuinely cannot be", - "read from a pair - a structural record, a boundary - would need this rule", - "amended in the open before a witness could name it, and that is the intended", - "cost." + "What it compares is read from `finding-lineage/v1`.", + "`pair_property_equality_fields` lives there and not here: this contract carrying", + "its own copy is a junior redefining a senior kind, and the first version of that", + "table could be widened with `path` until a witness whose rule and message AGREED", + "still read as the kind failing. Every witness kind must be one the senior map", + "names, so the table cannot be emptied into a vacuum either - drop an entry and", + "the witnesses naming it fail at once rather than passing unevaluated." ] } } diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index 2a45f72..ba1dd88 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -421,5 +421,67 @@ "rename rule while every check on the mapping still passed. That mutation was", "green until this section existed." ] + }, + "pair_property_equality_fields": { + "map": { + "anchored_content": [ + "anchored_content" + ], + "same_path": [ + "path" + ], + "same_pattern_id": [ + "pattern_id" + ], + "same_rule_message": [ + "rule", + "message" + ], + "structural_context": [ + "enclosing_symbol" + ] + }, + "not_an_equality": { + "line_drift": "the line MOVED. It is a pair property and it is not an equality of any field - comparing `start_line` for equality would assert the opposite of what the kind means - so it has no entry above and says so here instead of being missing." + }, + "why": [ + "WHICH OCCURRENCE FIELDS A PAIR PROPERTY COMPARES, frozen HERE for exactly", + "the reason the section above it is frozen here: a junior contract that", + "defines what a senior kind compares has redefined the kind.", + "", + "The decision policy carried this table for one commit and review took it", + "apart in the obvious way. The list was validated as a non-empty list of", + "strings, so `same_rule_message` could be widened to `[rule, message, path]`", + "- and then a witness whose rule and message AGREE still reads as the kind", + "failing, because the paths differ. The fixture written to prove the", + "requirement went back to being able to lie, one level below where that was", + "last repaired, and the amended kind would have made R-CONT-RENAME and", + "R-CONT-COPY unsatisfiable on every real move.", + "", + "`same_rule_message` compares the rule and the message and NOTHING ELSE. It", + "is the path-independent kind - that is its whole purpose, since", + "`finding-pattern/v1` hashes the path into `same_pattern_id` and no occurrence", + "that moved can carry one.", + "", + "TOTAL over the pair properties: every kind `evidence_kind_observation` calls", + "a `pair_property` appears in `map` or in `not_an_equality`, and nowhere else.", + "A kind missing from both is a kind whose comparison nobody has written down.", + "", + "WIDENING IS A CONTRADICTION, NOT A PREFERENCE, and it is checkable without", + "any of this being asserted: a kind whose fields CONTAIN another kind's", + "entails that kind, so widening `same_rule_message` with `path` makes it entail", + "`same_path` - and `same_path` is frozen mutually exclusive with", + "`path_rename`, which R-CONT-RENAME requires. The rule becomes dead, and the", + "existing law against requiring an impossible combination says so.", + "", + "INDEPENDENT, NOT MERELY DISTINCT. No kind's fields may contain another's. The", + "floor for a `continued` counts KINDS, so a kind that is another kind and more", + "would let one observation satisfy two and meet the floor by itself - the same", + "argument the sixth amendment made for keeping `same_rule_message` one kind", + "rather than `same_rule` beside `same_message`. Widening with `path` is caught", + "by the exclusivity law above; widening `same_rule_message` with", + "`anchored_content`, or `same_pattern_id` with `path`, is caught only here, and", + "both were green until this said so." + ] } } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 43a40f8..4bfcd57 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -1860,13 +1860,37 @@ def arbitration_leaves(node, trail): # suite cannot do without becoming a second mapper - see the note on # `rule_coverage_rule`. needs = rule_needs(rules[rid]) + # AND THE COMBINATION CAN BE IMPOSSIBLE WITHOUT BEING WRITTEN DOWN. A kind + # whose equality fields CONTAIN another kind's entails that kind: comparing + # `[rule, message, path]` is comparing `[path]` and more. So the exclusive + # pairs are closed under that entailment before they are applied. + # This is what makes widening a senior kind a contradiction rather than a + # preference: `same_rule_message` widened with `path` entails `same_path`, + # `same_path` is frozen exclusive with `path_rename`, and R-CONT-RENAME + # requires both - the rule is dead and the existing law says so. Review + # found the widening by hand; nothing here could object to it, because the + # field list was checked for being a non-empty list of strings and nothing + # else, one level below where the same defect was last repaired. + eq_fields = {k: set(v) for k, v in mapping_or_empty(mapping_or_empty( + senior.get("pair_property_equality_fields")).get("map")).items() + if isinstance(v, list) and all(isinstance(f, str) for f in v) and v} + entailed = set(needs) + for kind_n, fields_n in eq_fields.items(): + if kind_n in needs: + entailed |= {other for other, f_o in eq_fields.items() + if f_o and f_o <= fields_n} for excl in list_or_empty(senior.get("mutually_exclusive_evidence_kinds")): pair = set(excl.get("between") or []) - check(not pair <= needs, + check(not pair <= entailed, f"{rid} requires {sorted(pair)} together, which " "`finding-lineage/v1` freezes as mutually exclusive. No evidence " "can satisfy the rule, so it licenses nothing and its outcome " - "silently degrades to `unresolved`.") + "silently degrades to `unresolved`." + + ("" if pair <= needs else + f" It reaches them through the equality fields the senior " + f"contract gives {sorted(needs & set(eq_fields))}: a kind that " + "compares everything another kind compares requires that kind " + "too.")) # The abandoned wording must not creep back in under its old names. for dead in ("requires_per_successor", "requires_per_predecessor"): @@ -2133,6 +2157,72 @@ def arbitration_leaves(node, trail): "`partner_profile` is outside that map by design, so classifying one " "this way demands evidence the contract gives no way to read.") + # EVERY PAIR PROPERTY SAYS WHAT IT COMPARES, or says it is not a comparison. + # `pair_property_equality_fields` is what the witness obligation reads to + # decide whether a kind really failed in a case, and it decides entailment + # between kinds. It lives in the senior contract because a junior defining + # what a senior kind compares has redefined the kind - the junior copy this + # replaces could be widened with `path`, and then a witness whose rule and + # message AGREED still read as `same_rule_message` failing. + # + # TOTAL over the pair properties and PARTITIONED: a kind in neither table is + # a comparison nobody wrote down, and one in both says two things at once. + # `line_drift` is the reason the second table exists - the line MOVED, which + # is a pair property and not an equality of any field. + eq_decl = mapping_or_empty(senior.get("pair_property_equality_fields")) + eq_map = mapping_or_empty(eq_decl.get("map")) + eq_not = mapping_or_empty(eq_decl.get("not_an_equality")) + pair_kinds = {k for k, how in observation.items() if how == "pair_property"} + check(pair_kinds == set(eq_map) | set(eq_not), + "`finding-lineage/v1.pair_property_equality_fields` must cover every kind " + f"`evidence_kind_observation` calls a pair property. Missing " + f"{sorted(pair_kinds - set(eq_map) - set(eq_not))}, unexpected " + f"{sorted((set(eq_map) | set(eq_not)) - pair_kinds)}. A kind in neither " + "table is a comparison nobody has written down, and the witness obligation " + "reads this to decide whether a kind really failed.") + check(not (set(eq_map) & set(eq_not)), + f"`pair_property_equality_fields` puts {sorted(set(eq_map) & set(eq_not))} " + "in both tables. A kind is an equality of some fields or it is not one.") + for kind_e in sorted(eq_map): + fields_e = eq_map[kind_e] + check(isinstance(fields_e, list) and fields_e + and all(isinstance(f, str) and f in OCCURRENCE_KINDS for f in fields_e), + f"`pair_property_equality_fields.map[{kind_e!r}]` is {fields_e!r}. It has " + f"to name occurrence fields out of {sorted(OCCURRENCE_KINDS)} - a kind " + "compared on a field the occurrences do not carry is compared on nothing.") + # AND NO KIND IS ANOTHER KIND AND MORE. The floor counts KINDS: two are + # required for a `continued`. If one kind's fields contained another's, a + # single observation would satisfy both and meet the floor by itself - which + # is the argument the sixth senior amendment already made for making + # `same_rule_message` ONE kind rather than `same_rule` beside `same_message`. + # Containment is also what the entailment above turns into a contradiction, + # so requiring the sets to be independent says the same thing at the source. + # + # This is the half review's report left open. Widening `same_rule_message` + # with `path` is caught by the exclusivity law; widening it with + # `anchored_content`, or `same_pattern_id` with `path`, was not - both are + # inert on the present rules and neither lets a witness lie, and both are + # still a kind quietly becoming a stronger kind than the one the senior + # contract defines. + for kind_a in sorted(eq_map): + for kind_b in sorted(eq_map): + fa, fb = eq_map.get(kind_a), eq_map.get(kind_b) + if kind_a == kind_b or not isinstance(fa, list) or not isinstance(fb, list): + continue + check(not (fb and set(fb) < set(fa)), + f"`pair_property_equality_fields.map` gives {kind_a!r} {fa!r} and " + f"{kind_b!r} {fb!r}, so anything satisfying {kind_a!r} satisfies " + f"{kind_b!r}. The floor for a `continued` counts KINDS, and two " + "kinds one observation cannot separate meet it by themselves - " + "which is why the sixth senior amendment made `same_rule_message` " + "one kind instead of two halves.") + + for kind_e in sorted(eq_not): + check(isinstance(eq_not[kind_e], str) and eq_not[kind_e].strip(), + f"`pair_property_equality_fields.not_an_equality[{kind_e!r}]` is " + f"{eq_not[kind_e]!r}. Excusing a kind from having a comparison is exactly " + "where a reason is owed.") + direct = {k for r in rules.values() for k in (r.get("requires_all") or [])} want_domain = {k for k in direct if k in senior_kinds} check(set(kind_records) == want_domain, @@ -3450,16 +3540,22 @@ def shown(ids_): # to make, contradicted by the case, accepted. The two # cross-path witnesses survived only because recomputing # `pattern_id` caught the edit, which is luck, not a check. - # `pair_property_kinds` says which kinds are readable from - # a pair of occurrences - two strings the producer emitted, - # compared - and every witness kind must be one, so the - # table cannot be emptied into a vacuum. + # `pair_property_equality_fields` says which kinds are + # readable from a pair of occurrences - two strings the + # producer emitted, compared - and every witness kind must + # be one, so the table cannot be emptied into a vacuum. It + # lives in the SENIOR contract: a junior defining what a + # senior kind compares has redefined the kind, and the + # junior copy could be widened with `path` until a witness + # whose rule and message AGREED still read as the kind + # failing. fields_w = mapping_or_empty(mapping_or_empty( - policy.get("pair_property_kinds")).get("map")).get(w_kind) + senior.get("pair_property_equality_fields") + ).get("map")).get(w_kind) check(isinstance(fields_w, list) and fields_w and all(isinstance(f, str) for f in fields_w), f"{name}: the witness names {w_kind!r}, and " - "`pair_property_kinds.map` gives it " + "`finding-lineage/v1.pair_property_equality_fields.map` gives it " f"{fields_w!r}. A witness whose kind cannot be read " "off the pair is a claim this corpus cannot falsify.") if isinstance(fields_w, list) and all( From 047be49da773cacb24530b2e4305fb5fdf79f21f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 14:50:00 +0000 Subject: [PATCH 81/84] contract(lineage): pin each pair property by vectors, and make a licensed rule prove its kinds hold Both reviewers found the same remaining class in `93bb253`, and both were right. Membership in the occurrence vocabulary, totality and independence between kinds are all properties a WRONG field set can have: same_rule_message: ["rule","message"] -> ["rule","message","start_line"] anchored_content: ["anchored_content"] -> ["start_line"] Both green. The first extends the kind with an allowed field that contains no other kind's set; the second replaces the comparison outright. The corpus could not object, because every negative witness already differs on rule or message, and the positive rename and copy cases both sat at line 42 on each side. Two things close it, and they close it from opposite directions. VECTORS, which is what both reports asked for and what `finding-occurrence/v1` and `finding-pattern/v1` already use. A vector says what the definition says: two occurrences differing in exactly these fields and agreeing on every other one - does the kind hold? One per occurrence field per kind, and the subsets consistent with them must number EXACTLY ONE and be the declared list. Thin the vectors and several remain consistent; edit the list and it disagrees with them. Neither half moves alone. A POSITIVE READER. Everything so far pinned a kind by making it FAIL. Now a rule the case says licensed a relation must have every equality kind it requires actually HOLD between the occurrences named. It decides no applicability - it takes the case's word for which rules applied and asks whether the payload agrees, exactly as the negative half does. To give it something to see, `copy-at-one-to-one-is-not-a-branch` moves its successor to line 47: a copy target has its own line numbering, and `same_rule_message` compares what the analyser said, not where. MEASURED, NOT ASSUMED. Sweeping every one-field redefinition of every kind with the vectors edited to match - 82 in all - the cases now reject 65 and accept 17. The 17 are two shapes and neither is a missing fixture: - `start_column` is null in all 79 occurrences, by the design `finding-occurrence/v1` states. A kind extended with it compares a field nothing carries. - `rule` and `message` agree across all 12 licensed relations, and necessarily so: every continuation rule now requires `same_rule_message` or `same_pattern_id`, so a licensed relation where they differed is a case this policy forbids. Redefining `same_path` as `pattern_id` is the same shape. No valid corpus can separate a kind from a strictly stronger one when every case exercising the kind satisfies the stronger condition too. The vectors are what pin those 17, by being written where a reader can disagree with them. The limit is stated in the contract rather than left to be found. Twelve mutations on the vectors, both reported ones included, plus the 27 earlier probes re-run. All 20 suites green in both modes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-v1.json | 331 +++++++++++++++++- .../copy-at-one-to-one-is-not-a-branch.json | 6 +- identity/tests/test_lineage_decision.py | 97 +++++ 3 files changed, 430 insertions(+), 4 deletions(-) diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index ba1dd88..b88fd1b 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -441,6 +441,288 @@ "enclosing_symbol" ] }, + "vectors": { + "anchored_content": [ + { + "differs_in": [ + "anchored_content" + ], + "holds": false + }, + { + "differs_in": [ + "enclosing_symbol" + ], + "holds": true + }, + { + "differs_in": [ + "message" + ], + "holds": true + }, + { + "differs_in": [ + "occurrence_id" + ], + "holds": true + }, + { + "differs_in": [ + "path" + ], + "holds": true + }, + { + "differs_in": [ + "pattern_id" + ], + "holds": true + }, + { + "differs_in": [ + "rule" + ], + "holds": true + }, + { + "differs_in": [ + "start_column" + ], + "holds": true + }, + { + "differs_in": [ + "start_line" + ], + "holds": true + } + ], + "same_path": [ + { + "differs_in": [ + "anchored_content" + ], + "holds": true + }, + { + "differs_in": [ + "enclosing_symbol" + ], + "holds": true + }, + { + "differs_in": [ + "message" + ], + "holds": true + }, + { + "differs_in": [ + "occurrence_id" + ], + "holds": true + }, + { + "differs_in": [ + "path" + ], + "holds": false + }, + { + "differs_in": [ + "pattern_id" + ], + "holds": true + }, + { + "differs_in": [ + "rule" + ], + "holds": true + }, + { + "differs_in": [ + "start_column" + ], + "holds": true + }, + { + "differs_in": [ + "start_line" + ], + "holds": true + } + ], + "same_pattern_id": [ + { + "differs_in": [ + "anchored_content" + ], + "holds": true + }, + { + "differs_in": [ + "enclosing_symbol" + ], + "holds": true + }, + { + "differs_in": [ + "message" + ], + "holds": true + }, + { + "differs_in": [ + "occurrence_id" + ], + "holds": true + }, + { + "differs_in": [ + "path" + ], + "holds": true + }, + { + "differs_in": [ + "pattern_id" + ], + "holds": false + }, + { + "differs_in": [ + "rule" + ], + "holds": true + }, + { + "differs_in": [ + "start_column" + ], + "holds": true + }, + { + "differs_in": [ + "start_line" + ], + "holds": true + } + ], + "same_rule_message": [ + { + "differs_in": [ + "anchored_content" + ], + "holds": true + }, + { + "differs_in": [ + "enclosing_symbol" + ], + "holds": true + }, + { + "differs_in": [ + "message" + ], + "holds": false + }, + { + "differs_in": [ + "occurrence_id" + ], + "holds": true + }, + { + "differs_in": [ + "path" + ], + "holds": true + }, + { + "differs_in": [ + "pattern_id" + ], + "holds": true + }, + { + "differs_in": [ + "rule" + ], + "holds": false + }, + { + "differs_in": [ + "start_column" + ], + "holds": true + }, + { + "differs_in": [ + "start_line" + ], + "holds": true + } + ], + "structural_context": [ + { + "differs_in": [ + "anchored_content" + ], + "holds": true + }, + { + "differs_in": [ + "enclosing_symbol" + ], + "holds": false + }, + { + "differs_in": [ + "message" + ], + "holds": true + }, + { + "differs_in": [ + "occurrence_id" + ], + "holds": true + }, + { + "differs_in": [ + "path" + ], + "holds": true + }, + { + "differs_in": [ + "pattern_id" + ], + "holds": true + }, + { + "differs_in": [ + "rule" + ], + "holds": true + }, + { + "differs_in": [ + "start_column" + ], + "holds": true + }, + { + "differs_in": [ + "start_line" + ], + "holds": true + } + ] + }, "not_an_equality": { "line_drift": "the line MOVED. It is a pair property and it is not an equality of any field - comparing `start_line` for equality would assert the opposite of what the kind means - so it has no entry above and says so here instead of being missing." }, @@ -481,7 +763,54 @@ "rather than `same_rule` beside `same_message`. Widening with `path` is caught", "by the exclusivity law above; widening `same_rule_message` with", "`anchored_content`, or `same_pattern_id` with `path`, is caught only here, and", - "both were green until this said so." + "both were green until this said so.", + "", + "AND THE FIELD SET IS PINNED BY VECTORS, not by properties of the list.", + "Membership in the occurrence vocabulary, totality, and independence between", + "kinds are all properties a WRONG set can have. Review demonstrated two:", + "`same_rule_message` extended with `start_line`, which is an occurrence field and", + "contains no other kind's set, and `anchored_content` REPLACED by `start_line`,", + "which is a different comparison entirely. Both were green. The corpus could not", + "object because every negative witness already differs on rule or message, and", + "the positive rename and copy cases happen to sit at line 42 on both sides.", + "", + "A vector says: two occurrences that differ in exactly these fields and agree on", + "every other one - does the kind hold? That is the definition itself, written", + "down in the form that can be checked. One vector per occurrence field per kind,", + "and the set of subsets consistent with them must contain EXACTLY ONE member,", + "which must be the declared list. So the vectors cannot be thinned into ambiguity", + "and the list cannot drift from them: change either and they disagree.", + "", + "This is the same instrument `finding-occurrence/v1` and `finding-pattern/v1`", + "already use, arriving here for the same reason - a definition stated only as", + "prose beside a list is a definition nothing reads.", + "", + "WHAT THE CORPUS CAN AND CANNOT SEPARATE, measured rather than assumed. A", + "coordinated edit - the list AND its vectors changed together - is an amendment", + "made in the open, and the frozen cases are the second reader of it: a rule the", + "case says licensed a relation must have every equality kind it requires actually", + "HOLD between the occurrences named. Sweeping every one-field redefinition of", + "every kind, 82 in all, the cases reject 65 and accept 17.", + "", + "The 17 are of two shapes and neither is a missing fixture.", + "", + "`start_column` is null in all 79 occurrences of the corpus, by the design", + "`finding-occurrence/v1` states: the column is the one anchor field allowed to be", + "absent. A kind extended with it compares a field nothing carries, so nothing can", + "differ on it.", + "", + "`rule` and `message` agree across all 12 licensed relations, and NECESSARILY:", + "every continuation rule now requires `same_rule_message` or `same_pattern_id`,", + "so a case where they differed and a relation was still licensed is a case this", + "policy forbids. The same holds for redefining `same_path` as `pattern_id` - a", + "same-path rule already requires the id to agree. No valid corpus can separate a", + "kind from a strictly stronger one when every case exercising the kind satisfies", + "the stronger condition too.", + "", + "So the vectors are what pin those 17, and they pin them the only way a", + "definition can be pinned when no example distinguishes it: by being written", + "down where a reader can disagree with it. That is a real limit and it is stated", + "here rather than left to be discovered." ] } } diff --git a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json index 184626b..767d733 100644 --- a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json +++ b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json @@ -1,7 +1,7 @@ { "case": "copy-at-one-to-one-is-not-a-branch", "title": "The same copy record, at one successor", - "why": "One record, two readings, and cardinality is what chooses between them. Here Broker/Doc.cs is deleted and its contents live on at Broker/Moved.cs: the copy record explains a MOVE, not a multiplicity, so R-BRANCH-COPY does not apply at all rather than applying and being overturned later. The second half is the boundary: deleted_paths would earn `ended` for occ-a1 on its own, and the copy record defeats it, because the same deletion appearing as a copy source is one story told twice. finding-lineage/v1 freezes this scenario as continued and forbids even unresolved.", + "why": "One record, two readings, and cardinality is what chooses between them. Here Broker/Doc.cs is deleted and its contents live on at Broker/Moved.cs: the copy record explains a MOVE, not a multiplicity, so R-BRANCH-COPY does not apply at all rather than applying and being overturned later. The second half is the boundary: deleted_paths would earn `ended` for occ-a1 on its own, and the copy record defeats it, because the same deletion appearing as a copy source is one story told twice. finding-lineage/v1 freezes this scenario as continued and forbids even unresolved.\n\nTHE SUCCESSOR SITS AT A DIFFERENT LINE ON PURPOSE. R-CONT-COPY requires `same_rule_message`, which `finding-lineage/v1` compares on the rule and the message and nothing else - it is the path-independent kind, and it is location-independent for the same reason. Every case that made the kind FAIL already differed on the rule or the message, so the definition could be extended with `start_line` and nothing objected; a positive continuation whose line moved is what objects. The copy did not move the defect down the file; the copy target simply has its own line numbering, which is the ordinary case a mapper meets.", "revision_a": { "revision": "r1", "occurrences": [ @@ -36,7 +36,7 @@ "path": "Broker/Moved.cs", "rule": "DOTNET_RESOURCE_LEAK", "message": "event handler subscribed and never removed", - "start_line": 42, + "start_line": 47, "start_column": null, "pattern_id": "c55059814baef619", "enclosing_symbol": "DocView.Wire", @@ -72,7 +72,7 @@ "R-CONT-RENAME": "the record is a copy, not a rename; path_rename did not fire", "R-MERGE-FOLD": "no merge record" }, - "note": "R-CONT-COPY is the rule whose absence made this scenario a contradiction rather than a gap: before it existed the only rule that fired here was R-BRANCH-COPY, naming an outcome the senior contract had already ruled out." + "note": "R-CONT-COPY is the rule whose absence made this scenario a contradiction rather than a gap: before it existed the only rule that fired here was R-BRANCH-COPY, naming an outcome the senior contract had already ruled out. The successor sits at line 47 rather than 42: a copy target has its own line numbering, and `same_rule_message` compares what the analyser said, not where it said it." } ], "forbid": [ diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 4bfcd57..7070ebb 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2217,12 +2217,75 @@ def arbitration_leaves(node, trail): "which is why the sixth senior amendment made `same_rule_message` " "one kind instead of two halves.") + # AND THE FIELD SET IS PINNED BY VECTORS. Membership in the occurrence + # vocabulary, totality and independence are all properties a WRONG set can + # have, and review demonstrated two of them: `same_rule_message` extended + # with `start_line` - an occurrence field containing no other kind's set - + # and `anchored_content` REPLACED by `start_line`, a different comparison + # entirely. Both green. The corpus cannot object: every negative witness + # already differs on rule or message, and the positive rename and copy cases + # happen to sit at line 42 on both sides. + # + # A vector says what the definition says: two occurrences differing in + # exactly these fields and agreeing on every other one - does the kind hold? + # The subsets consistent with a kind's vectors must number EXACTLY ONE, and + # be the declared list. Thinning the vectors leaves several consistent and + # fails; editing the list makes it disagree with them and fails. Neither the + # list nor its evidence can move alone. + eq_vectors = mapping_or_empty(eq_decl.get("vectors")) + check(set(eq_vectors) == set(eq_map), + "`pair_property_equality_fields.vectors` must pin every equality kind and " + f"only those. Missing {sorted(set(eq_map) - set(eq_vectors))}, unexpected " + f"{sorted(set(eq_vectors) - set(eq_map))}. A kind whose fields no vector " + "constrains is a definition nothing reads, which is what these replace.") + universe = sorted(OCCURRENCE_KINDS) + for kind_v in sorted(set(eq_vectors) & set(eq_map)): + rows = eq_vectors[kind_v] + rows = rows if isinstance(rows, list) else [] + ok_rows = [] + for index_v, row in enumerate(rows): + row = mapping_or_empty(row) + differs, holds = row.get("differs_in"), row.get("holds") + good = (isinstance(differs, list) and differs + and all(isinstance(f, str) and f in OCCURRENCE_KINDS + for f in differs) + and isinstance(holds, bool)) + check(good, + f"`pair_property_equality_fields.vectors[{kind_v!r}][{index_v}]` is " + f"{row or None!r}. A vector names a non-empty set of occurrence " + "fields the two occurrences differ in, and says whether the kind " + "holds - anything else constrains nothing.") + if good: + ok_rows.append((set(differs), holds)) + consistent = [frozenset(c) for n in range(len(universe) + 1) + for c in itertools.combinations(universe, n) + if all((not (set(c) & d)) == h for d, h in ok_rows)] + declared_v = eq_map.get(kind_v) + declared_v = (frozenset(declared_v) if isinstance(declared_v, list) + and all(isinstance(f, str) for f in declared_v) else None) + check(consistent == [declared_v], + f"`pair_property_equality_fields` declares {kind_v!r} as " + f"{sorted(declared_v) if declared_v else declared_v!r}, and its vectors " + f"admit {[sorted(c) for c in consistent[:4]]}" + f"{' and more' if len(consistent) > 4 else ''}. The vectors have to " + "leave exactly one field set standing, and it has to be the one " + "declared: several means they were thinned until they constrain " + "nothing, and a different one means the list drifted from the " + "definition beside it.") + for kind_e in sorted(eq_not): check(isinstance(eq_not[kind_e], str) and eq_not[kind_e].strip(), f"`pair_property_equality_fields.not_an_equality[{kind_e!r}]` is " f"{eq_not[kind_e]!r}. Excusing a kind from having a comparison is exactly " "where a reason is owed.") + # The validated map, under the name the case loop reads it by. Bound after + # the checks above so a malformed declaration is reported there rather than + # silently shaping what a fixture is measured against. + eq_fields_map = {k: list(v) for k, v in eq_map.items() + if isinstance(v, list) and v + and all(isinstance(f, str) and f in OCCURRENCE_KINDS for f in v)} + direct = {k for r in rules.values() for k in (r.get("requires_all") or [])} want_domain = {k for k in direct if k in senior_kinds} check(set(kind_records) == want_domain, @@ -2717,6 +2780,40 @@ def arbitration_leaves(node, trail): check(not bad, bad or "") claimed_a.update(frm) claimed_b.update(to) + # A LICENSED RULE'S PAIR PROPERTIES MUST ACTUALLY HOLD. The negative + # witnesses pin a kind by making it FAIL; nothing made one hold, so + # the senior definition could be rewritten in ways no case objected + # to - `same_rule_message` extended with `start_line`, or + # `anchored_content` replaced by it outright. Both were reported and + # both were green, because every negative witness already differs on + # rule or message and the positive cross-path cases happened to sit + # at the same line on both sides. + # + # This reads the other direction: for a rule the case says licensed + # the relation, every equality kind it requires - directly or through + # its partner profile - has to hold between each predecessor and each + # successor named. It decides no applicability; it takes the case's + # own word for which rules applied and asks whether the payload + # agrees, which is what the negative half already does. + for rid_p in sorted(set(exp.get("licensed_by") or [])): + if rid_p not in rules: + continue + for kind_p in sorted(rule_needs(mapping_or_empty(rules[rid_p])) + & set(eq_fields_map)): + for a_p in frm: + for b_p in to: + oa_p = mapping_or_empty(by_id.get(a_p)) + ob_p = mapping_or_empty(by_id.get(b_p)) + differ = sorted(f for f in eq_fields_map[kind_p] + if oa_p.get(f) != ob_p.get(f)) + check(not differ, + f"{where}: {rid_p} licensed this relation and " + f"requires {kind_p!r}, which `finding-lineage/v1` " + f"compares on {sorted(eq_fields_map[kind_p])} - and " + f"{a_p} and {b_p} differ on {differ}. Either the " + "case licenses a rule its own occurrences do not " + "satisfy, or the kind has been redefined into " + "something this relation was never meant to carry.") for o in frm: check(o in occ_a, f"{where}: predecessor {o!r} is not in revision A") for o in to: From 992c32c0b56878a440ed0fb3157f620e78d64c43 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 14:56:29 +0000 Subject: [PATCH 82/84] test(lineage): every equality kind needs a case that makes it fail Review found that the vectors and the field list only agree with EACH OTHER. Agreement between two declarations is not either of them being bound to data, and three kinds had no failing case at all - so `anchored_content` could be amended to compare `start_column`, the vectors edited to match, and every licensed relation still passed, because the column is null on both sides everywhere in the corpus. That instance was one of the seventeen this branch had already measured and declared as a limit. Declaring a limit is not the same as being unable to shrink it, and the report came with the way: bind each kind to a case where its own field differs. No new instrument and no new fixtures. The corpus already held the witnesses and only the declarations were missing: anchored_content an-edit-elsewhere-is-still-the-same-defect structural_context a-fold-across-files-is-still-a-merge same_path a-recorded-rename-is-not-an-unresolved each with R-CONT-SAME-SITE standing down, and each already saying in `not_applicable` exactly which kind withheld it. Every equality kind must now have such a witness, which is the biconditional that would have caught this: a kind nothing can falsify is a definition nothing reads. Five more of the seventeen were `start_column`. `finding-occurrence/v1` lets the column be absent and all 79 occurrences took that option, so a field nothing anywhere differs on could be added to any kind's definition unchallenged. Two pairs now carry real columns that moved - a reindented statement in `blunt-rule-loses-to-uniqueness`, and a copy target with its own numbering in `copy-at-one-to-one-is-not-a-branch`. Reindentation changes no evidence, which is precisely why those are the pairs to carry it. Re-measured the same way it was measured before: 82 one-field redefinitions with the vectors edited to match, the cases now reject 73 and accept 9, down from 17. The 9 are one shape and none is a missing fixture: `rule` and `message` agree across every licensed relation NECESSARILY, since every continuation rule requires `same_rule_message` or `same_pattern_id`, so a licensed relation where they differ is a case this policy forbids. Redefining `same_path` as `pattern_id` is the same argument. Five mutations verified, the reported one included, plus the 39 earlier probes re-run. All 20 suites green in both modes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 37 +++++++++++++-- contracts/finding-lineage-v1.json | 46 ++++++++++--------- .../blunt-rule-loses-to-uniqueness.json | 8 ++-- .../copy-at-one-to-one-is-not-a-branch.json | 6 +-- identity/tests/test_lineage_decision.py | 21 +++++++++ 5 files changed, 86 insertions(+), 32 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 8f559b5..1908b42 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -1003,7 +1003,8 @@ "required_kind_withheld_a_rule" ], "a-fold-across-files-is-still-a-merge": [ - "rule_licensed_alone" + "rule_licensed_alone", + "required_kind_withheld_a_rule" ], "a-reclassified-defect-across-a-copy-is-not-a-continuation": [ "required_kind_withheld_a_rule", @@ -1016,14 +1017,16 @@ "required_kind_withheld_a_rule" ], "a-recorded-rename-is-not-an-unresolved": [ - "rule_licensed_alone" + "rule_licensed_alone", + "required_kind_withheld_a_rule" ], "an-ambiguity-outranks-a-cardinality-rejection": [ "blunt_rule_recorded", "cardinality_excluded_a_rule" ], "an-edit-elsewhere-is-still-the-same-defect": [ - "rule_licensed_alone" + "rule_licensed_alone", + "required_kind_withheld_a_rule" ], "blunt-rule-loses-to-uniqueness": [ "blunt_rule_recorded" @@ -1433,6 +1436,10 @@ "rule": "R-CONT-DRIFT", "kind": "same_pattern_id" }, + "a-fold-across-files-is-still-a-merge": { + "rule": "R-CONT-SAME-SITE", + "kind": "structural_context" + }, "a-reclassified-defect-across-a-copy-is-not-a-continuation": { "rule": "R-CONT-COPY", "kind": "same_rule_message" @@ -1445,6 +1452,14 @@ "rule": "R-CONT-SAME-SITE", "kind": "same_pattern_id" }, + "a-recorded-rename-is-not-an-unresolved": { + "rule": "R-CONT-SAME-SITE", + "kind": "same_path" + }, + "an-edit-elsewhere-is-still-the-same-defect": { + "rule": "R-CONT-SAME-SITE", + "kind": "anchored_content" + }, "rule": [ "WHICH RULE STOOD DOWN FOR WHICH KIND, NAMED BY THE CONTRACT RATHER THAN BY THE", "SUITE. A case carrying `required_kind_withheld_a_rule` declares its witness here,", @@ -1485,7 +1500,21 @@ "table could be widened with `path` until a witness whose rule and message AGREED", "still read as the kind failing. Every witness kind must be one the senior map", "names, so the table cannot be emptied into a vacuum either - drop an entry and", - "the witnesses naming it fail at once rather than passing unevaluated." + "the witnesses naming it fail at once rather than passing unevaluated.", + "", + "EVERY EQUALITY KIND NEEDS ONE. Three did not have one, and review showed what", + "that costs: with nothing making `anchored_content` fail on real occurrences, the", + "senior contract could be amended to say the kind compares `start_column` - the", + "vectors edited to agree, and every licensed relation still passing, because the", + "column is null on both sides everywhere. Two declarations agreeing with each", + "other is not the same as either of them being bound to data.", + "", + "The corpus already held the witnesses; only the declarations were missing.", + "`an-edit-elsewhere-is-still-the-same-defect` has R-CONT-SAME-SITE standing down", + "because the content changed, `a-fold-across-files-is-still-a-merge` because the", + "fold replaced the enclosing symbols, and `a-recorded-rename-is-not-an-unresolved`", + "because the paths differ. Each now says so, and each therefore pins the field", + "its kind is really compared on." ] } } diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index b88fd1b..5ddbd20 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -787,30 +787,34 @@ "", "WHAT THE CORPUS CAN AND CANNOT SEPARATE, measured rather than assumed. A", "coordinated edit - the list AND its vectors changed together - is an amendment", - "made in the open, and the frozen cases are the second reader of it: a rule the", - "case says licensed a relation must have every equality kind it requires actually", - "HOLD between the occurrences named. Sweeping every one-field redefinition of", - "every kind, 82 in all, the cases reject 65 and accept 17.", + "made in the open, and the frozen cases are the second reader of it. Two", + "obligations do that reading: a rule a case says LICENSED a relation must have", + "every equality kind it requires actually hold between the occurrences named, and", + "every equality kind must have a case where it FAILS, declared in", + "`required_kind_witnesses`. Sweeping every one-field redefinition of every kind,", + "82 in all, the cases reject 73 and accept 9.", "", - "The 17 are of two shapes and neither is a missing fixture.", + "It was 17 when this section was first written, and the difference is worth", + "recording, because none of it was a new instrument. Three of the eight were kinds", + "with no failing witness at all - the corpus already held the cases, only the", + "declarations were missing. Five more were `start_column`, which was null in all", + "79 occurrences: `finding-occurrence/v1` allows the column to be absent and every", + "fixture took that option, so a field nothing anywhere differs on could be added", + "to any kind's definition unchallenged. Two pairs now carry real columns that", + "moved - a reindented statement and a copy target with its own numbering - and", + "that closed all five.", "", - "`start_column` is null in all 79 occurrences of the corpus, by the design", - "`finding-occurrence/v1` states: the column is the one anchor field allowed to be", - "absent. A kind extended with it compares a field nothing carries, so nothing can", - "differ on it.", + "The 9 that remain are one shape. `rule` and `message` agree across every licensed", + "relation, and NECESSARILY: every continuation rule requires `same_rule_message`", + "or `same_pattern_id`, so a case where they differed and a relation was still", + "licensed is a case this policy forbids. Redefining `same_path` as `pattern_id` is", + "the same argument - a same-path rule already requires the id to agree.", "", - "`rule` and `message` agree across all 12 licensed relations, and NECESSARILY:", - "every continuation rule now requires `same_rule_message` or `same_pattern_id`,", - "so a case where they differed and a relation was still licensed is a case this", - "policy forbids. The same holds for redefining `same_path` as `pattern_id` - a", - "same-path rule already requires the id to agree. No valid corpus can separate a", - "kind from a strictly stronger one when every case exercising the kind satisfies", - "the stronger condition too.", - "", - "So the vectors are what pin those 17, and they pin them the only way a", - "definition can be pinned when no example distinguishes it: by being written", - "down where a reader can disagree with it. That is a real limit and it is stated", - "here rather than left to be discovered." + "No valid corpus can separate a kind from a strictly stronger one when every case", + "exercising the kind satisfies the stronger condition too. The vectors are what", + "pin those 9, and they pin them the only way a definition can be pinned when no", + "example distinguishes it: by being written down where a reader can disagree with", + "it. That is a real limit and it is stated here rather than left to be discovered." ] } } diff --git a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json index 120581b..94833c2 100644 --- a/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json +++ b/identity/fixtures/lineage-decision/blunt-rule-loses-to-uniqueness.json @@ -1,7 +1,7 @@ { "case": "blunt-rule-loses-to-uniqueness", "title": "Two candidates for a blunt rule, one for a discriminating one", - "why": "ADVERSARIAL about uniqueness being per RULE rather than per outcome. Both occurrences in revision B sit in the same file and the same symbol as the predecessor, so R-CONT-DRIFT - which does not ask about content - is satisfied by BOTH and singles out nobody. R-CONT-SAME-SITE does ask, and only occ-b1 answers. If uniqueness were checked per outcome the pair would collapse into ambiguity and a real continuation would be thrown away; checked per rule, the discriminating rule licenses and the blunt one is recorded as having failed to choose. That record is the point: a blunt rule that simply vanishes leaves the next reader unable to see that anything was in tension.", + "why": "ADVERSARIAL about uniqueness being per RULE rather than per outcome. Both occurrences in revision B sit in the same file and the same symbol as the predecessor, so R-CONT-DRIFT - which does not ask about content - is satisfied by BOTH and singles out nobody. R-CONT-SAME-SITE does ask, and only occ-b1 answers. If uniqueness were checked per outcome the pair would collapse into ambiguity and a real continuation would be thrown away; checked per rule, the discriminating rule licenses and the blunt one is recorded as having failed to choose. That record is the point: a blunt rule that simply vanishes leaves the next reader unable to see that anything was in tension.\n\nTHE COLUMNS ARE REAL HERE, and they differ across the continuation: the block was reindented, so the statement sits at column 13 where it sat at 9. `finding-occurrence/v1` lets the column be absent and the whole corpus took that option, which made `start_column` a field nothing anywhere differs on - and a field nothing differs on can be added to ANY evidence kind's definition without a single case objecting. One occurrence pair carrying a real, moved column is what stops that. Reindentation changes no evidence: `same_path`, `structural_context`, `anchored_content` and `same_pattern_id` all still hold, which is exactly why this is the pair to carry it.", "revision_a": { "revision": "r1", "occurrences": [ @@ -11,7 +11,7 @@ "rule": "DOTNET_RESOURCE_LEAK", "message": "event handler subscribed and never removed", "start_line": 42, - "start_column": null, + "start_column": 9, "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" @@ -27,7 +27,7 @@ "rule": "DOTNET_RESOURCE_LEAK", "message": "event handler subscribed and never removed", "start_line": 44, - "start_column": null, + "start_column": 13, "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" @@ -38,7 +38,7 @@ "rule": "DOTNET_RESOURCE_LEAK", "message": "event handler subscribed and never removed", "start_line": 51, - "start_column": null, + "start_column": 13, "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnReset;" diff --git a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json index 767d733..f2fdea3 100644 --- a/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json +++ b/identity/fixtures/lineage-decision/copy-at-one-to-one-is-not-a-branch.json @@ -1,7 +1,7 @@ { "case": "copy-at-one-to-one-is-not-a-branch", "title": "The same copy record, at one successor", - "why": "One record, two readings, and cardinality is what chooses between them. Here Broker/Doc.cs is deleted and its contents live on at Broker/Moved.cs: the copy record explains a MOVE, not a multiplicity, so R-BRANCH-COPY does not apply at all rather than applying and being overturned later. The second half is the boundary: deleted_paths would earn `ended` for occ-a1 on its own, and the copy record defeats it, because the same deletion appearing as a copy source is one story told twice. finding-lineage/v1 freezes this scenario as continued and forbids even unresolved.\n\nTHE SUCCESSOR SITS AT A DIFFERENT LINE ON PURPOSE. R-CONT-COPY requires `same_rule_message`, which `finding-lineage/v1` compares on the rule and the message and nothing else - it is the path-independent kind, and it is location-independent for the same reason. Every case that made the kind FAIL already differed on the rule or the message, so the definition could be extended with `start_line` and nothing objected; a positive continuation whose line moved is what objects. The copy did not move the defect down the file; the copy target simply has its own line numbering, which is the ordinary case a mapper meets.", + "why": "One record, two readings, and cardinality is what chooses between them. Here Broker/Doc.cs is deleted and its contents live on at Broker/Moved.cs: the copy record explains a MOVE, not a multiplicity, so R-BRANCH-COPY does not apply at all rather than applying and being overturned later. The second half is the boundary: deleted_paths would earn `ended` for occ-a1 on its own, and the copy record defeats it, because the same deletion appearing as a copy source is one story told twice. finding-lineage/v1 freezes this scenario as continued and forbids even unresolved.\n\nTHE SUCCESSOR SITS AT A DIFFERENT LINE ON PURPOSE. R-CONT-COPY requires `same_rule_message`, which `finding-lineage/v1` compares on the rule and the message and nothing else - it is the path-independent kind, and it is location-independent for the same reason. Every case that made the kind FAIL already differed on the rule or the message, so the definition could be extended with `start_line` and nothing objected; a positive continuation whose line moved is what objects. The copy did not move the defect down the file; the copy target simply has its own line numbering - and its own column numbering, which is why the columns differ here too. `same_rule_message` compares what the analyser said, not where it said it, and the physical anchor moving in both axes is what proves that of the kind rather than of one axis of it.", "revision_a": { "revision": "r1", "occurrences": [ @@ -11,7 +11,7 @@ "rule": "DOTNET_RESOURCE_LEAK", "message": "event handler subscribed and never removed", "start_line": 42, - "start_column": null, + "start_column": 9, "pattern_id": "f964274604b5c187", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" @@ -37,7 +37,7 @@ "rule": "DOTNET_RESOURCE_LEAK", "message": "event handler subscribed and never removed", "start_line": 47, - "start_column": null, + "start_column": 17, "pattern_id": "c55059814baef619", "enclosing_symbol": "DocView.Wire", "anchored_content": "_store.Changed += OnChanged;" diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 7070ebb..61c1f27 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2279,6 +2279,27 @@ def arbitration_leaves(node, trail): f"{eq_not[kind_e]!r}. Excusing a kind from having a comparison is exactly " "where a reason is owed.") + # AND EVERY EQUALITY KIND HAS A CASE THAT MAKES IT FAIL. This is the gap + # review found: the vectors and the field list agree with each other, and + # agreement between two declarations is not either of them being bound to + # data. Three kinds had no witness, so `anchored_content` could be amended + # to compare `start_column` - vectors edited to match, every licensed + # relation still passing, because the column is null on both sides + # everywhere in the corpus. A kind nothing can falsify is a definition + # nothing reads, which is the rule this file applies to every other + # declaration. + witnessed = {mapping_or_empty(v).get("kind") + for k, v in mapping_or_empty( + policy.get("required_kind_witnesses")).items() + if k != "rule"} + unwitnessed = sorted(set(eq_map) - witnessed) + check(not unwitnessed, + f"no case makes {unwitnessed} fail: `required_kind_witnesses` names no " + "witness for them, so nothing in the corpus is compared on the fields " + "`pair_property_equality_fields` gives them. The vectors would then agree " + "with the list and both could move together - which is how a kind gets " + "redefined into a field that never differs anywhere.") + # The validated map, under the name the case loop reads it by. Bound after # the checks above so a malformed declaration is reported there rather than # silently shaping what a fixture is measured against. From 3f3bf7f677edf2c406403c067670bc1d4184bd26 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:04:03 +0000 Subject: [PATCH 83/84] contract(lineage): a pair property that is not an equality still needs a reader Codex found that `line_drift` was checked by nothing in either direction. Changing the successor's `start_line` from 57 to 42 in `an-edit-elsewhere-is-still-the-same-defect` - so no line moved at all - still licensed R-CONT-DRIFT and the suite reported OK. The cause is mine and it is this branch's defect wearing a different hat. `not_an_equality` was a one-line note saying `line_drift` holds when the line MOVED, and every reader intersected with the equality map, so the note became a BLIND SPOT: an exclusion list read as a filter and never as a thing needing its own reader. The one kind that is not an equality was the one kind nothing compared. `pair_property_equality_fields` becomes `pair_property_fields` with two tables, `equality` and `difference`, and everything that read one now reads both: - TOTAL and PARTITIONED over the pair properties - a kind in neither table is a comparison nobody wrote down, one in both says two things at once; - the vectors cover both, with the consistency test that belongs to each: an equality holds when NONE of its fields is among those that differ, a difference when ALL of them are. Both still have to leave exactly one field set standing; - the witness obligation reads "the kind FAILS" per table, so a difference fails when its fields do not all differ; - the positive reader requires a licensed rule's kinds to hold - fields agreeing for an equality, differing for a difference. `line_drift` also had no failing witness, because it was never in the table the witness rule ranged over. `copy-record-dominates-the-single-match` already recorded R-CONT-DRIFT standing down because occ-b1 is at the same line; it now says so where that can be read. Re-measured, both tables this time: 98 one-field redefinitions with the vectors edited to match, the cases reject 88 and accept 10, against 9 of 82 before. The new one is `line_drift` redefined as "the content differs", and it is the same argument from the other side: wherever a drift is licensed ALONE the content must have changed, or R-CONT-SAME-SITE would have applied too, so the two conditions coincide everywhere the corpus can look. Manufacturing a case whose only purpose is to discriminate a hypothetical amendment would be building corpus for the checker rather than for the policy, which is what the preregistration rule exists to prevent. Eight mutations on `line_drift`, the reported one included, and the load- bearing vector row identified: for a difference kind the single holds=true row is what pins the set, and dropping it leaves 503 candidates. The other 42 probes re-run. All 20 suites green in both modes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- contracts/finding-lineage-decision-v1.json | 18 +- contracts/finding-lineage-v1.json | 136 +++++++++++--- identity/tests/test_lineage_decision.py | 209 +++++++++++---------- 3 files changed, 231 insertions(+), 132 deletions(-) diff --git a/contracts/finding-lineage-decision-v1.json b/contracts/finding-lineage-decision-v1.json index 1908b42..610654a 100644 --- a/contracts/finding-lineage-decision-v1.json +++ b/contracts/finding-lineage-decision-v1.json @@ -1035,7 +1035,8 @@ "cardinality_excluded_a_rule" ], "copy-record-dominates-the-single-match": [ - "dominance_did_work" + "dominance_did_work", + "required_kind_withheld_a_rule" ], "copy-source-that-is-also-a-fold-refuses": [ "refusal_was_recorded" @@ -1460,6 +1461,10 @@ "rule": "R-CONT-SAME-SITE", "kind": "anchored_content" }, + "copy-record-dominates-the-single-match": { + "rule": "R-CONT-DRIFT", + "kind": "line_drift" + }, "rule": [ "WHICH RULE STOOD DOWN FOR WHICH KIND, NAMED BY THE CONTRACT RATHER THAN BY THE", "SUITE. A case carrying `required_kind_withheld_a_rule` declares its witness here,", @@ -1495,7 +1500,7 @@ "genuinely FAIL between the predecessor and every successor of the case.", "", "What it compares is read from `finding-lineage/v1`.", - "`pair_property_equality_fields` lives there and not here: this contract carrying", + "`pair_property_fields` lives there and not here: this contract carrying", "its own copy is a junior redefining a senior kind, and the first version of that", "table could be widened with `path` until a witness whose rule and message AGREED", "still read as the kind failing. Every witness kind must be one the senior map", @@ -1514,7 +1519,14 @@ "because the content changed, `a-fold-across-files-is-still-a-merge` because the", "fold replaced the enclosing symbols, and `a-recorded-rename-is-not-an-unresolved`", "because the paths differ. Each now says so, and each therefore pins the field", - "its kind is really compared on." + "its kind is really compared on.", + "", + "`line_drift` is the sixth, and it is a DIFFERENCE rather than an equality: it", + "holds when `start_line` differs. It had no witness because it was not in the", + "equality table at all, and every reader intersected with that table - so the", + "kind that says the line MOVED was checked by nothing, in either direction.", + "`copy-record-dominates-the-single-match` already recorded R-CONT-DRIFT standing", + "down because occ-b1 is at the same line; it now says so where that can be read." ] } } diff --git a/contracts/finding-lineage-v1.json b/contracts/finding-lineage-v1.json index 5ddbd20..211c73e 100644 --- a/contracts/finding-lineage-v1.json +++ b/contracts/finding-lineage-v1.json @@ -422,8 +422,8 @@ "green until this section existed." ] }, - "pair_property_equality_fields": { - "map": { + "pair_property_fields": { + "equality": { "anchored_content": [ "anchored_content" ], @@ -441,6 +441,11 @@ "enclosing_symbol" ] }, + "difference": { + "line_drift": [ + "start_line" + ] + }, "vectors": { "anchored_content": [ { @@ -498,6 +503,62 @@ "holds": true } ], + "line_drift": [ + { + "differs_in": [ + "anchored_content" + ], + "holds": false + }, + { + "differs_in": [ + "enclosing_symbol" + ], + "holds": false + }, + { + "differs_in": [ + "message" + ], + "holds": false + }, + { + "differs_in": [ + "occurrence_id" + ], + "holds": false + }, + { + "differs_in": [ + "path" + ], + "holds": false + }, + { + "differs_in": [ + "pattern_id" + ], + "holds": false + }, + { + "differs_in": [ + "rule" + ], + "holds": false + }, + { + "differs_in": [ + "start_column" + ], + "holds": false + }, + { + "differs_in": [ + "start_line" + ], + "holds": true + } + ], "same_path": [ { "differs_in": [ @@ -723,9 +784,6 @@ } ] }, - "not_an_equality": { - "line_drift": "the line MOVED. It is a pair property and it is not an equality of any field - comparing `start_line` for equality would assert the opposite of what the kind means - so it has no entry above and says so here instead of being missing." - }, "why": [ "WHICH OCCURRENCE FIELDS A PAIR PROPERTY COMPARES, frozen HERE for exactly", "the reason the section above it is frozen here: a junior contract that", @@ -745,9 +803,21 @@ "`finding-pattern/v1` hashes the path into `same_pattern_id` and no occurrence", "that moved can carry one.", "", - "TOTAL over the pair properties: every kind `evidence_kind_observation` calls", - "a `pair_property` appears in `map` or in `not_an_equality`, and nowhere else.", - "A kind missing from both is a kind whose comparison nobody has written down.", + "TOTAL over the pair properties and PARTITIONED. Every kind", + "`evidence_kind_observation` calls a `pair_property` is an `equality` or a", + "`difference`, never both and never neither. A kind in neither is a comparison", + "nobody has written down.", + "", + "`line_drift` is why `difference` exists, and why it is a TABLE rather than an", + "excuse. It was a one-line note saying the kind is not an equality, and the", + "positive reader intersected with the equality map - so the note became a", + "BLIND SPOT: a case could license R-CONT-DRIFT with the successor on the", + "predecessor's own line, drift required and absent, and nothing objected.", + "An exclusion list read as a filter and never as a thing needing its own", + "reader is the same defect as a claim checked where it was named and not at", + "the adjacent site. `line_drift` holds when `start_line` DIFFERS; that is now", + "written where it can be read, vectored like the others, and required to hold", + "of any relation a rule requiring it licenses.", "", "WIDENING IS A CONTRADICTION, NOT A PREFERENCE, and it is checkable without", "any of this being asserted: a kind whose fields CONTAIN another kind's", @@ -789,32 +859,38 @@ "coordinated edit - the list AND its vectors changed together - is an amendment", "made in the open, and the frozen cases are the second reader of it. Two", "obligations do that reading: a rule a case says LICENSED a relation must have", - "every equality kind it requires actually hold between the occurrences named, and", - "every equality kind must have a case where it FAILS, declared in", - "`required_kind_witnesses`. Sweeping every one-field redefinition of every kind,", - "82 in all, the cases reject 73 and accept 9.", + "every pair property it requires actually hold between the occurrences named -", + "fields agreeing for an equality, differing for a difference - and every pair", + "property must have a case where it FAILS, declared in `required_kind_witnesses`.", + "Sweeping every one-field redefinition of every kind in both tables, 98 in all,", + "the cases reject 88 and accept 10.", "", - "It was 17 when this section was first written, and the difference is worth", - "recording, because none of it was a new instrument. Three of the eight were kinds", - "with no failing witness at all - the corpus already held the cases, only the", - "declarations were missing. Five more were `start_column`, which was null in all", - "79 occurrences: `finding-occurrence/v1` allows the column to be absent and every", - "fixture took that option, so a field nothing anywhere differs on could be added", - "to any kind's definition unchallenged. Two pairs now carry real columns that", - "moved - a reindented statement and a copy target with its own numbering - and", - "that closed all five.", + "It was 17 of 82 when this section was first written, and the difference is worth", + "recording, because none of it was a new instrument. Three of the eight closed", + "were kinds with no failing witness at all - the corpus already held the cases,", + "only the declarations were missing. Five were `start_column`, null in all 79", + "occurrences because `finding-occurrence/v1` allows the column to be absent and", + "every fixture took that option; two pairs now carry real columns that moved, a", + "reindented statement and a copy target with its own numbering.", "", - "The 9 that remain are one shape. `rule` and `message` agree across every licensed", - "relation, and NECESSARILY: every continuation rule requires `same_rule_message`", - "or `same_pattern_id`, so a case where they differed and a relation was still", - "licensed is a case this policy forbids. Redefining `same_path` as `pattern_id` is", - "the same argument - a same-path rule already requires the id to agree.", + "The 10 that remain are one argument wearing three faces, and none is a missing", + "fixture. `rule` and `message` agree across every licensed relation NECESSARILY:", + "every continuation rule requires `same_rule_message` or `same_pattern_id`, so a", + "case where they differed and a relation was still licensed is a case this policy", + "forbids. Redefining `same_path` as `pattern_id` is the same - a same-path rule", + "already requires the id to agree. And redefining `line_drift` as 'the content", + "differs' survives for the same reason from the other side: wherever a drift is", + "licensed ALONE the content must have changed, or R-CONT-SAME-SITE would have", + "applied too, so the two conditions coincide everywhere the corpus can look.", "", "No valid corpus can separate a kind from a strictly stronger one when every case", - "exercising the kind satisfies the stronger condition too. The vectors are what", - "pin those 9, and they pin them the only way a definition can be pinned when no", - "example distinguishes it: by being written down where a reader can disagree with", - "it. That is a real limit and it is stated here rather than left to be discovered." + "exercising the kind satisfies the stronger condition too. Manufacturing a case", + "whose only purpose is to discriminate a hypothetical amendment would be building", + "corpus for the checker rather than for the policy, which is the one thing the", + "preregistration rule exists to prevent. The vectors are what pin those 10, and", + "they pin them the only way a definition can be pinned when no example", + "distinguishes it: by being written down where a reader can disagree with it.", + "That is a real limit and it is stated here rather than left to be discovered." ] } } diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 61c1f27..442c99b 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2157,60 +2157,65 @@ def arbitration_leaves(node, trail): "`partner_profile` is outside that map by design, so classifying one " "this way demands evidence the contract gives no way to read.") - # EVERY PAIR PROPERTY SAYS WHAT IT COMPARES, or says it is not a comparison. - # `pair_property_equality_fields` is what the witness obligation reads to - # decide whether a kind really failed in a case, and it decides entailment - # between kinds. It lives in the senior contract because a junior defining - # what a senior kind compares has redefined the kind - the junior copy this - # replaces could be widened with `path`, and then a witness whose rule and - # message AGREED still read as `same_rule_message` failing. + # EVERY PAIR PROPERTY SAYS WHAT IT COMPARES, in one of two tables, and both + # are READ. `pair_property_fields` is what the witness obligation reads to + # decide whether a kind really failed in a case, what the positive reader + # checks of a licensed relation, and what decides entailment between kinds. + # It lives in the senior contract because a junior defining what a senior + # kind compares has redefined the kind - the junior copy this replaces could + # be widened with `path`, and then a witness whose rule and message AGREED + # still read as `same_rule_message` failing. # - # TOTAL over the pair properties and PARTITIONED: a kind in neither table is - # a comparison nobody wrote down, and one in both says two things at once. - # `line_drift` is the reason the second table exists - the line MOVED, which - # is a pair property and not an equality of any field. - eq_decl = mapping_or_empty(senior.get("pair_property_equality_fields")) - eq_map = mapping_or_empty(eq_decl.get("map")) - eq_not = mapping_or_empty(eq_decl.get("not_an_equality")) + # TWO TABLES, because not every pair property is an equality. `line_drift` + # holds when `start_line` DIFFERS. It used to be a one-line note saying so, + # and every reader here intersected with the equality map - so the note was a + # BLIND SPOT: a case could license R-CONT-DRIFT with the successor sitting on + # the predecessor's own line, drift required and absent, and nothing + # objected. An exclusion list read as a filter and never as a thing needing + # its own reader is this branch's defect wearing a different hat. + # + # TOTAL and PARTITIONED: a kind in neither table is a comparison nobody wrote + # down, and one in both says two things at once. + eq_decl = mapping_or_empty(senior.get("pair_property_fields")) + eq_map = mapping_or_empty(eq_decl.get("equality")) + df_map = mapping_or_empty(eq_decl.get("difference")) pair_kinds = {k for k, how in observation.items() if how == "pair_property"} - check(pair_kinds == set(eq_map) | set(eq_not), - "`finding-lineage/v1.pair_property_equality_fields` must cover every kind " + check(pair_kinds == set(eq_map) | set(df_map), + "`finding-lineage/v1.pair_property_fields` must cover every kind " f"`evidence_kind_observation` calls a pair property. Missing " - f"{sorted(pair_kinds - set(eq_map) - set(eq_not))}, unexpected " - f"{sorted((set(eq_map) | set(eq_not)) - pair_kinds)}. A kind in neither " - "table is a comparison nobody has written down, and the witness obligation " - "reads this to decide whether a kind really failed.") - check(not (set(eq_map) & set(eq_not)), - f"`pair_property_equality_fields` puts {sorted(set(eq_map) & set(eq_not))} " - "in both tables. A kind is an equality of some fields or it is not one.") - for kind_e in sorted(eq_map): - fields_e = eq_map[kind_e] - check(isinstance(fields_e, list) and fields_e - and all(isinstance(f, str) and f in OCCURRENCE_KINDS for f in fields_e), - f"`pair_property_equality_fields.map[{kind_e!r}]` is {fields_e!r}. It has " - f"to name occurrence fields out of {sorted(OCCURRENCE_KINDS)} - a kind " - "compared on a field the occurrences do not carry is compared on nothing.") + f"{sorted(pair_kinds - set(eq_map) - set(df_map))}, unexpected " + f"{sorted((set(eq_map) | set(df_map)) - pair_kinds)}. A kind in neither " + "table is a comparison nobody has written down, and both tables are read " + "to decide whether a kind held in a case.") + check(not (set(eq_map) & set(df_map)), + f"`pair_property_fields` puts {sorted(set(eq_map) & set(df_map))} in both " + "tables. A kind is satisfied by fields AGREEING or by their DIFFERING, " + "and it cannot be both.") + for table_n, kinds_n in (("equality", eq_map), ("difference", df_map)): + for kind_e in sorted(kinds_n): + fields_e = kinds_n[kind_e] + check(isinstance(fields_e, list) and fields_e + and all(isinstance(f, str) and f in OCCURRENCE_KINDS + for f in fields_e), + f"`pair_property_fields.{table_n}[{kind_e!r}]` is {fields_e!r}. It " + f"has to name occurrence fields out of {sorted(OCCURRENCE_KINDS)} - " + "a kind compared on a field the occurrences do not carry is " + "compared on nothing.") + # AND NO KIND IS ANOTHER KIND AND MORE. The floor counts KINDS: two are - # required for a `continued`. If one kind's fields contained another's, a + # required for a `continued`. If one equality's fields contained another's, a # single observation would satisfy both and meet the floor by itself - which # is the argument the sixth senior amendment already made for making # `same_rule_message` ONE kind rather than `same_rule` beside `same_message`. - # Containment is also what the entailment above turns into a contradiction, + # Containment is also what the entailment below turns into a contradiction, # so requiring the sets to be independent says the same thing at the source. - # - # This is the half review's report left open. Widening `same_rule_message` - # with `path` is caught by the exclusivity law; widening it with - # `anchored_content`, or `same_pattern_id` with `path`, was not - both are - # inert on the present rules and neither lets a witness lie, and both are - # still a kind quietly becoming a stronger kind than the one the senior - # contract defines. for kind_a in sorted(eq_map): for kind_b in sorted(eq_map): fa, fb = eq_map.get(kind_a), eq_map.get(kind_b) if kind_a == kind_b or not isinstance(fa, list) or not isinstance(fb, list): continue check(not (fb and set(fb) < set(fa)), - f"`pair_property_equality_fields.map` gives {kind_a!r} {fa!r} and " + f"`pair_property_fields.equality` gives {kind_a!r} {fa!r} and " f"{kind_b!r} {fb!r}, so anything satisfying {kind_a!r} satisfies " f"{kind_b!r}. The floor for a `continued` counts KINDS, and two " "kinds one observation cannot separate meet it by themselves - " @@ -2219,29 +2224,29 @@ def arbitration_leaves(node, trail): # AND THE FIELD SET IS PINNED BY VECTORS. Membership in the occurrence # vocabulary, totality and independence are all properties a WRONG set can - # have, and review demonstrated two of them: `same_rule_message` extended - # with `start_line` - an occurrence field containing no other kind's set - - # and `anchored_content` REPLACED by `start_line`, a different comparison - # entirely. Both green. The corpus cannot object: every negative witness - # already differs on rule or message, and the positive rename and copy cases - # happen to sit at line 42 on both sides. + # have, and review demonstrated several: `same_rule_message` extended with + # `start_line`, `anchored_content` REPLACED by `start_column`. A vector says + # what the definition says - two occurrences differing in exactly these + # fields and agreeing on every other one, does the kind hold? - and the + # subsets consistent with a kind's vectors must number EXACTLY ONE and be the + # declared list. Thinning them leaves several consistent; editing the list + # makes it disagree with them. Neither half moves alone. # - # A vector says what the definition says: two occurrences differing in - # exactly these fields and agreeing on every other one - does the kind hold? - # The subsets consistent with a kind's vectors must number EXACTLY ONE, and - # be the declared list. Thinning the vectors leaves several consistent and - # fails; editing the list makes it disagree with them and fails. Neither the - # list nor its evidence can move alone. + # The consistency test differs by table and that is the whole distinction: an + # equality holds when NONE of its fields is among the ones that differ, a + # difference holds when ALL of them are. eq_vectors = mapping_or_empty(eq_decl.get("vectors")) - check(set(eq_vectors) == set(eq_map), - "`pair_property_equality_fields.vectors` must pin every equality kind and " - f"only those. Missing {sorted(set(eq_map) - set(eq_vectors))}, unexpected " - f"{sorted(set(eq_vectors) - set(eq_map))}. A kind whose fields no vector " - "constrains is a definition nothing reads, which is what these replace.") + check(set(eq_vectors) == set(eq_map) | set(df_map), + "`pair_property_fields.vectors` must pin every pair property and only " + f"those. Missing {sorted((set(eq_map) | set(df_map)) - set(eq_vectors))}, " + f"unexpected {sorted(set(eq_vectors) - set(eq_map) - set(df_map))}. A kind " + "whose fields no vector constrains is a definition nothing reads, which is " + "what these replace.") universe = sorted(OCCURRENCE_KINDS) - for kind_v in sorted(set(eq_vectors) & set(eq_map)): + for kind_v in sorted(set(eq_vectors) & (set(eq_map) | set(df_map))): rows = eq_vectors[kind_v] rows = rows if isinstance(rows, list) else [] + is_diff = kind_v in df_map ok_rows = [] for index_v, row in enumerate(rows): row = mapping_or_empty(row) @@ -2251,7 +2256,7 @@ def arbitration_leaves(node, trail): for f in differs) and isinstance(holds, bool)) check(good, - f"`pair_property_equality_fields.vectors[{kind_v!r}][{index_v}]` is " + f"`pair_property_fields.vectors[{kind_v!r}][{index_v}]` is " f"{row or None!r}. A vector names a non-empty set of occurrence " "fields the two occurrences differ in, and says whether the kind " "holds - anything else constrains nothing.") @@ -2259,12 +2264,13 @@ def arbitration_leaves(node, trail): ok_rows.append((set(differs), holds)) consistent = [frozenset(c) for n in range(len(universe) + 1) for c in itertools.combinations(universe, n) - if all((not (set(c) & d)) == h for d, h in ok_rows)] - declared_v = eq_map.get(kind_v) + if c and all((set(c) <= d if is_diff else not (set(c) & d)) == h + for d, h in ok_rows)] + declared_v = (df_map if is_diff else eq_map).get(kind_v) declared_v = (frozenset(declared_v) if isinstance(declared_v, list) and all(isinstance(f, str) for f in declared_v) else None) check(consistent == [declared_v], - f"`pair_property_equality_fields` declares {kind_v!r} as " + f"`pair_property_fields` declares {kind_v!r} as " f"{sorted(declared_v) if declared_v else declared_v!r}, and its vectors " f"admit {[sorted(c) for c in consistent[:4]]}" f"{' and more' if len(consistent) > 4 else ''}. The vectors have to " @@ -2273,39 +2279,33 @@ def arbitration_leaves(node, trail): "nothing, and a different one means the list drifted from the " "definition beside it.") - for kind_e in sorted(eq_not): - check(isinstance(eq_not[kind_e], str) and eq_not[kind_e].strip(), - f"`pair_property_equality_fields.not_an_equality[{kind_e!r}]` is " - f"{eq_not[kind_e]!r}. Excusing a kind from having a comparison is exactly " - "where a reason is owed.") - - # AND EVERY EQUALITY KIND HAS A CASE THAT MAKES IT FAIL. This is the gap - # review found: the vectors and the field list agree with each other, and - # agreement between two declarations is not either of them being bound to - # data. Three kinds had no witness, so `anchored_content` could be amended - # to compare `start_column` - vectors edited to match, every licensed - # relation still passing, because the column is null on both sides - # everywhere in the corpus. A kind nothing can falsify is a definition - # nothing reads, which is the rule this file applies to every other - # declaration. + # AND EVERY PAIR PROPERTY HAS A CASE THAT MAKES IT FAIL. Vectors and the + # field list agree with each other, and agreement between two declarations is + # not either of them being bound to data. Three kinds had no failing case, so + # `anchored_content` could be amended to compare `start_column` - vectors + # edited to match, every licensed relation still passing, because the column + # was null on both sides everywhere. A kind nothing can falsify is a + # definition nothing reads. witnessed = {mapping_or_empty(v).get("kind") for k, v in mapping_or_empty( policy.get("required_kind_witnesses")).items() if k != "rule"} - unwitnessed = sorted(set(eq_map) - witnessed) + unwitnessed = sorted((set(eq_map) | set(df_map)) - witnessed) check(not unwitnessed, f"no case makes {unwitnessed} fail: `required_kind_witnesses` names no " "witness for them, so nothing in the corpus is compared on the fields " - "`pair_property_equality_fields` gives them. The vectors would then agree " - "with the list and both could move together - which is how a kind gets " - "redefined into a field that never differs anywhere.") - - # The validated map, under the name the case loop reads it by. Bound after - # the checks above so a malformed declaration is reported there rather than - # silently shaping what a fixture is measured against. - eq_fields_map = {k: list(v) for k, v in eq_map.items() - if isinstance(v, list) and v - and all(isinstance(f, str) and f in OCCURRENCE_KINDS for f in v)} + "`pair_property_fields` gives them. The vectors would then agree with the " + "list and both could move together - which is how a kind gets redefined " + "into a field that never differs anywhere.") + + # The validated tables, under the names the case loop reads them by. Bound + # after the checks above so a malformed declaration is reported there rather + # than silently shaping what a fixture is measured against. + def _clean(table): + return {k: list(v) for k, v in table.items() + if isinstance(v, list) and v + and all(isinstance(f, str) and f in OCCURRENCE_KINDS for f in v)} + eq_fields_map, df_fields_map = _clean(eq_map), _clean(df_map) direct = {k for r in rules.values() for k in (r.get("requires_all") or [])} want_domain = {k for k in direct if k in senior_kinds} @@ -2819,19 +2819,27 @@ def arbitration_leaves(node, trail): for rid_p in sorted(set(exp.get("licensed_by") or [])): if rid_p not in rules: continue + # BOTH TABLES. Intersecting with the equality map alone let + # `line_drift` out: R-CONT-DRIFT could be licensed with the + # successor on the predecessor's own line, the drift it requires + # simply absent, and nothing here objected. An exclusion list + # read as a filter and never as a thing needing its own reader. for kind_p in sorted(rule_needs(mapping_or_empty(rules[rid_p])) - & set(eq_fields_map)): + & (set(eq_fields_map) | set(df_fields_map))): + want_diff = kind_p in df_fields_map + fields_p = (df_fields_map if want_diff else eq_fields_map)[kind_p] for a_p in frm: for b_p in to: oa_p = mapping_or_empty(by_id.get(a_p)) ob_p = mapping_or_empty(by_id.get(b_p)) - differ = sorted(f for f in eq_fields_map[kind_p] - if oa_p.get(f) != ob_p.get(f)) - check(not differ, + wrong = sorted(f for f in fields_p + if (oa_p.get(f) != ob_p.get(f)) != want_diff) + check(not wrong, f"{where}: {rid_p} licensed this relation and " f"requires {kind_p!r}, which `finding-lineage/v1` " - f"compares on {sorted(eq_fields_map[kind_p])} - and " - f"{a_p} and {b_p} differ on {differ}. Either the " + f"says holds when {sorted(fields_p)} " + f"{'differ' if want_diff else 'agree'} - and " + f"{a_p} and {b_p} do not, on {wrong}. Either the " "case licenses a rule its own occurrences do not " "satisfy, or the kind has been redefined into " "something this relation was never meant to carry.") @@ -3667,13 +3675,12 @@ def shown(ids_): # junior copy could be widened with `path` until a witness # whose rule and message AGREED still read as the kind # failing. - fields_w = mapping_or_empty(mapping_or_empty( - senior.get("pair_property_equality_fields") - ).get("map")).get(w_kind) + fields_w = (eq_fields_map.get(w_kind) + or df_fields_map.get(w_kind)) check(isinstance(fields_w, list) and fields_w and all(isinstance(f, str) for f in fields_w), f"{name}: the witness names {w_kind!r}, and " - "`finding-lineage/v1.pair_property_equality_fields.map` gives it " + "`finding-lineage/v1.pair_property_fields` gives it " f"{fields_w!r}. A witness whose kind cannot be read " "off the pair is a claim this corpus cannot falsify.") if isinstance(fields_w, list) and all( @@ -3681,6 +3688,10 @@ def shown(ids_): preds = exp.get("frm") preds = ([preds] if isinstance(preds, str) else list(preds or [])) + # An equality holds when its fields AGREE; a + # difference holds when they all DIFFER. Reading only + # the first is what left `line_drift` unread. + diff_w = w_kind in df_fields_map held = sorted( (a_id, b_occ.get("occurrence_id")) for a_id in preds @@ -3688,7 +3699,7 @@ def shown(ids_): "occurrences") or [] if isinstance(b_occ, dict) and by_id.get(a_id) is not None - and all(by_id[a_id].get(f) == b_occ.get(f) + and all((by_id[a_id].get(f) != b_occ.get(f)) == diff_w for f in fields_w)) check(not held, f"{name}: the witness says {w_rule} stood down " From 6dcc02f7d82bebeb5db9be83f77ab8c5455f5692 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:07:14 +0000 Subject: [PATCH 84/84] test(lineage): a witness that is declared and a witness that runs are different things CodeRabbit found that `witnessed` counted every entry in `required_kind_witnesses`, while the payload comparison that gives a witness meaning runs only for a case carrying `required_kind_withheld_a_rule`. Nothing tied the two sets together, so the obligation could be dropped from `an-edit-elsewhere-is-still-the-same-defect` and its witness entry moved onto `a-copy-into-two-new-files-is-a-branch`: `anchored_content` still counted as witnessed, and the comparison never ran. Reproduced before fixing. Membership standing in for the correct member, once more, one level up from where it was last repaired: the DECLARATION counted and the EXECUTION was not required. The claim "every pair property has a case that makes it fail" was then satisfied by a check that does not happen. The two sets are now the same set, both directions. An entry without the obligation is a claim nothing carries out; the obligation without an entry already failed one section down, and now says so from both ends. Five mutations verified - the reported one, a witness added to a case with no obligation, an obligation kept with its entry removed, an obligation dropped with its entry kept, and every witness obligation stripped at once. The other 50 probes re-run. All 20 suites green in both modes, and the measured residue is unchanged at 10 of 98. Not fixed, and deliberately: Codex reports that deleting `rules..outcome` raises `KeyError` before the integrity suite can report the malformed declaration. It is real and it is the recorded hardening-debt class - the run still exits non-zero, so the contract is refused rather than accepted. Under this round's acceptance rule a fail-closed traceback does not move HEAD. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m --- identity/tests/test_lineage_decision.py | 30 ++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/identity/tests/test_lineage_decision.py b/identity/tests/test_lineage_decision.py index 442c99b..10662f2 100644 --- a/identity/tests/test_lineage_decision.py +++ b/identity/tests/test_lineage_decision.py @@ -2286,10 +2286,34 @@ def arbitration_leaves(node, trail): # edited to match, every licensed relation still passing, because the column # was null on both sides everywhere. A kind nothing can falsify is a # definition nothing reads. + # A DECLARED WITNESS AND AN EXECUTED ONE ARE DIFFERENT THINGS. This counted + # every entry in `required_kind_witnesses` as a failing witness for its kind, + # while the payload comparison runs only for a case that carries + # `required_kind_withheld_a_rule`. Nothing tied the two sets together, so a + # witness could be moved onto a case with no such obligation: the kind still + # looked witnessed here and the comparison never ran. Membership standing in + # for the correct member again - the DECLARATION counted and the EXECUTION + # was not required. + # + # The two sets are the same set. An entry without the obligation is a claim + # nothing carries out; the obligation without an entry already fails one + # section down, and now says so from both ends. + witness_map = {k: v for k, v in mapping_or_empty( + policy.get("required_kind_witnesses")).items() if k != "rule"} + bearing = {c for c, duties_c in mapping_or_empty(mapping_or_empty( + policy.get("case_obligations")).get("obligations")).items() + if isinstance(duties_c, list) + and "required_kind_withheld_a_rule" in duties_c} + check(set(witness_map) == bearing, + "`required_kind_witnesses` names " + f"{sorted(set(witness_map) - bearing)} which do not carry " + "`required_kind_withheld_a_rule`, and " + f"{sorted(bearing - set(witness_map))} carry it with no witness named. " + "The payload comparison that makes a witness mean anything runs only for " + "a case with that obligation, so an entry outside the set is a kind " + "counted as witnessed by a check that never runs.") witnessed = {mapping_or_empty(v).get("kind") - for k, v in mapping_or_empty( - policy.get("required_kind_witnesses")).items() - if k != "rule"} + for k, v in witness_map.items() if k in bearing} unwitnessed = sorted((set(eq_map) | set(df_map)) - witnessed) check(not unwitnessed, f"no case makes {unwitnessed} fail: `required_kind_witnesses` names no "