diff --git a/docs/design/dsh5-10-replay-preference-rows.md b/docs/design/dsh5-10-replay-preference-rows.md index 99589665d..1e1116551 100644 --- a/docs/design/dsh5-10-replay-preference-rows.md +++ b/docs/design/dsh5-10-replay-preference-rows.md @@ -1,6 +1,6 @@ # DSH5-10: replay-grounded preference rows from undo/redo history (SLM-418) -**Status:** partial slice, in progress (fourth increment). +**Status:** partial slice, in progress (fifth increment). **Claim class:** `wiring`. **Honest verdict:** not yet dispositioned -- this PR extends a scoped subset, not the full issue. @@ -13,11 +13,13 @@ SFT/preference training against four context-view baselines, and (4) held-out measurement of action/operator/argument/reference/branch accuracy, calibration, and CAP0/CAP1/CAP2 retention. -This slice adds the sixth pattern, **merge-success**, plus an explicit, -tested disposition for **merge-conflict** (the pattern's other named half): -conflict is honestly *not* modeled as a preference row (see "Fourth slice -(v5)" below for why). Only **pronoun/focus follow-ups** remain fully -unattempted after this PR. +This slice adds the seventh and final named pattern, **pronoun-focus- +followup** (see "Fifth slice (v6)" below), bringing extraction coverage to +7 of 7. All seven named patterns from the issue's own list now extract and +replay-verify. What remains is the issue's separate, still fully unattempted +training/measurement scope: SFT/preference training against the +DSH3-selected policy/control heads, the four-baseline comparison, held-out +benefit measurement, and turn-depth/context-view ablations. ## What this PR delivers @@ -99,12 +101,6 @@ unattempted after this PR. Per the issue's own scope, not attempted here: -* **Pronoun/focus follow-ups** -- the one remaining named pattern. No - representation for ambiguous natural-language reference resolution (e.g. - "it"/"that one") exists anywhere in `ConversationTraceV1`, - `ReferenceTableV1`, or the legal-set machinery this module builds on; this - is new machinery, not an extraction path over an existing primitive, and - is left for follow-on work. * **Merge conflict as a preference row.** Deliberately not attempted -- see "Fourth slice (v5)" for the honesty argument. This is a considered scope decision, not an oversight: modeling it would require inventing a @@ -129,11 +125,10 @@ evidence for the row-extraction primitive only. Unlike SLM-336 (AP-035) or SLM-419 (DSH5-11), SLM-418's own prerequisites (DSH3 policy/control heads, the conversation/collapse/legal-set substrate) are already merged and available -- there is no unmet upstream gate here. -The remaining scope is genuinely large (a new pronoun/focus representation, -plus training + held-out evaluation across a five-baseline, multi-metric -matrix) and is left for follow-on work rather than rushed to a false "Done." -The issue should stay open against the pattern and training/evaluation work -enumerated above. +The remaining scope is genuinely large (training + held-out evaluation +across a five-baseline, multi-metric matrix) and is left for follow-on work +rather than rushed to a false "Done." The issue should stay open against the +training/evaluation work enumerated above. ## Review fixes (v2) @@ -245,12 +240,58 @@ enumerated above. snapshot is untouched, staying immutable point-in-time evidence from before this slice landed. +## Fifth slice (v6) + +* Added `pronoun_focus_followup` to `ReplayPreferenceRelation` -- the last + of the issue's seven named patterns. Unlike merge-success, this **is** + another branch inside `extract_replay_preference_rows`'s existing + turn-pair scan loop: a second consecutive `AST_EDIT` turn. +* **Focus**, the module's only concept for it, is never a transcript + pronoun or a semantic descriptor: it is `_touched_refs`, the exact + `OperatorRef` values the *immediately preceding* `AST_EDIT` turn's own + verified `OperatorApplicationV1.arguments` bound. A pair of consecutive + edits is classified `PRONOUN_FOCUS_FOLLOWUP` only when (1) that focus set + is non-empty (a zero-argument operator, like the base fixture every other + pattern in this module uses, never establishes one), (2) the following + edit's own bound arguments intersect it (the user kept operating on a ref + they had just touched), and (3) the exact legal set at the shared decision + state (`enumerate_operator_legal_set`, matched to the following turn's + recorded application by `operator_fingerprint` and bound `arguments`) + contains a **sibling**: another legal action for the *same operator* whose + own bound refs do **not** overlap the focus set -- a genuinely available, + equally legal "switch to something else" the user did not take. Without a + real sibling candidate, no row is emitted, matching every other pattern's + convention that undo/redo/checkout/continued-focus is never asserted + preferred by default. +* This directly answers the issue's own "ambiguous sibling" and "pronoun + focus" matrix rows: the pattern only ever fires when a second, disjoint + legal target genuinely existed at that state, and the row records that + the user's implicit "it" continuation was chosen over it. +* Deliberately does **not** attempt: switching to an explicit, different, + legal reference (the issue's "exact named reference" matrix case) is + honestly left unrowed rather than asserted a correction -- there is no + "user was wrong" signal to record when they simply named something else. + Multi-argument operators, transaction-commit turns, and any true + natural-language pronoun/reference-resolution machinery over + `ReferenceTableV1` remain out of scope; this slice is DAG-argument-set + overlap only, exactly as adversarial control requires ("text history + cannot reconstruct a different state than the DAG"). +* `dsl.operators.replay_preference` bumped v5 -> v6 in + `src/slm_training/resources/versions.json`. +* Corrected the hardcoded SLM-418 evidence string in + `src/slm_training/evals/advanced_operator_disposition.py` (previously "6 + of 7"; now "7 of 7", with the remaining-gap claim narrowed from "1 of 7 + (pronoun/focus)" to the issue's training/measurement scope only) via a + `no-bump:` history note on `evals.advanced_operator_disposition` -- no + disposition logic or schema changed, and the already-published + `docs/design/dsh5-12-advanced-operator-disposition-20260727-local/` + snapshot is untouched, staying immutable point-in-time evidence from + before this slice landed. + ## Reproducibility ```bash NODE_OPTIONS= pytest -q tests/test_dsl/test_replay_preference.py tests/test_dsl/test_operator_merge.py tests/test_dsl/test_operator_conversation.py tests/test_evals/test_advanced_operator_disposition.py tests/test_scripts/test_validate_advanced_operator_disposition.py ``` -Result (this PR, sandboxed run with `NODE_OPTIONS` cleared -- the ambient -`--import tsx` flag is rejected by this Node 22 build, unrelated to this -change): `57 passed`. +Result (this PR, real run in a fresh `.venv` -- Python 3.12, `pip install -e ".[dev,grammar]"`, plus `NODE_OPTIONS= npm ci` in `src/apps/openui_bridge` for the G2/G8 schema-oracle gates the pack authority requires; the ambient `--import tsx` `NODE_OPTIONS` is rejected by this Node 22 build both for `npm ci` and for `pytest`, unrelated to this change): `61 passed`. Also verified: `ruff check` clean on every changed file; `python -m scripts.verify_version_stamps --check --base origin/claude/great-dirac-v82ph9` -- `ok (2 component(s) touched)`; `python -m scripts.repo_policy` -- `ok`; `python -m scripts.verify_decode_invariants` -- clean. diff --git a/src/slm_training/dsl/operators/replay_preference.py b/src/slm_training/dsl/operators/replay_preference.py index 8c8e1dda9..ee34ce433 100644 --- a/src/slm_training/dsl/operators/replay_preference.py +++ b/src/slm_training/dsl/operators/replay_preference.py @@ -1,18 +1,19 @@ """SLM-418 (DSH5-10): replay-grounded preference rows from undo/redo history. -Builds versioned preference rows over one exact input state from six +Builds versioned preference rows over one exact input state from all seven verified conversation patterns -- edit-then-undo, undo-then-redo, partial rollback (a second or later consecutive undo, chosen over redo/checkout/edit -alternatives), checkout-another-state, fork-then-choose-one-branch, and -merge-success -- where the chosen and rejected control-or-operator actions -are checked against the exact legal set available at that state -(``enumerate_operator_legal_set``), never against transcript text. - -This is an honestly partial slice of SLM-418. It does not implement: -pronoun/focus follow-up patterns; it does not train an SFT/preference -variant, measure held-out benefit, or produce turn-depth / context-view -ablations. See ``docs/design/dsh5-10-replay-preference-rows.md`` for the -full disposition and the remaining scope. +alternatives), checkout-another-state, fork-then-choose-one-branch, +merge-success, and pronoun-focus-followup -- where the chosen and rejected +control-or-operator actions are checked against the exact legal set +available at that state (``enumerate_operator_legal_set``), never against +transcript text. + +This is an honestly partial slice of SLM-418. All seven named extraction +patterns now exist, but it does not train an SFT/preference variant, +measure held-out benefit, or produce turn-depth / context-view ablations. +See ``docs/design/dsh5-10-replay-preference-rows.md`` for the full +disposition and the remaining scope. Merge conflict (the other half of the issue's "merge success/conflict" pattern) is deliberately *not* modeled as a preference row here: unlike @@ -45,8 +46,12 @@ ConversationOperation, ConversationTraceV1, ) -from slm_training.dsl.operators.contracts import ApplicationProvenanceV1 +from slm_training.dsl.operators.contracts import ( + ApplicationProvenanceV1, + OperatorApplicationV1, +) from slm_training.dsl.operators.legal_set import ( + LegalOperatorActionV1, OperatorLegalSetV1, enumerate_operator_legal_set, ) @@ -69,6 +74,7 @@ class ReplayPreferenceRelation(str, Enum): CHECKOUT_ANOTHER_STATE = "checkout_another_state" FORK_THEN_CHOOSE_ONE_BRANCH = "fork_then_choose_one_branch" MERGE_SUCCESS = "merge_success" + PRONOUN_FOCUS_FOLLOWUP = "pronoun_focus_followup" @dataclass(frozen=True) @@ -203,6 +209,23 @@ def _pick_rejected(legal_set: OperatorLegalSetV1, chosen: str) -> str | None: return candidates[0] if candidates else None +def _touched_refs(application: OperatorApplicationV1) -> frozenset: + """The opaque refs one ``AST_EDIT`` application actually bound as arguments. + + This is the module's only notion of "focus": never a transcript pronoun, + never a semantic descriptor, just the exact ``OperatorRef`` values the + prior turn's own verified application used -- so pronoun-focus + classification stays grounded in the DAG, per the issue's own adversarial + control that text history cannot reconstruct a different state than the + DAG. + """ + return frozenset(argument.value for argument in application.arguments) + + +def _action_refs(action: LegalOperatorActionV1) -> frozenset: + return frozenset(argument.value for argument in action.arguments) + + def extract_replay_preference_rows( trace: ConversationTraceV1, *, @@ -210,15 +233,18 @@ def extract_replay_preference_rows( library: OperatorLibraryV1, provenance_for: ProvenanceFactory, ) -> OperatorEventMemoryReportV1: - """Scan ``trace.turns`` for five replay-grounded preference patterns. + """Scan ``trace.turns`` for six replay-grounded preference patterns. edit-then-undo, undo-then-redo, partial-rollback (a second or later - consecutive undo), checkout-another-state, and fork-then-choose-one- - branch (a checkout that crosses a branch boundary a prior ``FORK`` turn - opened). Each match produces one row whose chosen and rejected actions - are both verified members of the exact legal set at the shared input - state. A row is only emitted when an unchosen alternative actually - exists in that legal set -- undo/redo/checkout is never asserted + consecutive undo), checkout-another-state, fork-then-choose-one-branch + (a checkout that crosses a branch boundary a prior ``FORK`` turn opened), + and pronoun-focus-followup (a second ``AST_EDIT`` that continues + operating on the same ref its immediate predecessor touched, over an + equally legal same-operator action targeting a different, untouched + ref). Each match produces one row whose chosen and rejected actions are + both verified members of the exact legal set at the shared input state. + A row is only emitted when an unchosen alternative actually exists in + that legal set -- undo/redo/checkout/continued-focus is never asserted preferred by default. """ rows: list[OperatorReplayPreferenceRowV1] = [] @@ -308,6 +334,74 @@ def extract_replay_preference_rows( ) ) + if ( + current.operation is ConversationOperation.AST_EDIT + and following.operation is ConversationOperation.AST_EDIT + and following.input_state_id == current.output_state_id + ): + assert current.application is not None + assert following.application is not None + focus_refs = _touched_refs(current.application) + chosen_refs = _touched_refs(following.application) + # No focus was ever established (e.g. a zero-argument operator), + # or the follow-up shares nothing with it: not this pattern. + if focus_refs and (focus_refs & chosen_refs): + decision_state_id = following.input_state_id + legal_set = _legal_set_at( + trace, + pack=pack, + library=library, + state_id=decision_state_id, + provenance_for=provenance_for, + ) + entry = next( + ( + candidate + for candidate in legal_set.entries + if candidate.operator_fingerprint + == following.application.operator_fingerprint + ), + None, + ) + chosen_match = ( + next( + ( + action + for action in entry.legal_actions + if action.arguments == following.application.arguments + ), + None, + ) + if entry is not None + else None + ) + if entry is not None and chosen_match is not None: + sibling_candidates = sorted( + ( + action + for action in entry.legal_actions + if action.serialized != chosen_match.serialized + and not (_action_refs(action) & focus_refs) + ), + key=lambda action: action.serialized, + ) + if sibling_candidates: + rows.append( + OperatorReplayPreferenceRowV1( + input_state_id=decision_state_id, + chosen_action=chosen_match.serialized, + rejected_action=sibling_candidates[0].serialized, + chosen_output_state_id=following.output_state_id, + semantic_relation=( + ReplayPreferenceRelation.PRONOUN_FOCUS_FOLLOWUP + ), + correction_reason=( + "user_continued_implicit_focus_over_sibling_target" + ), + legal_set_fingerprint=legal_set.fingerprint, + ) + ) + fork_branch_digests = { trace.node(turn.output_state_id).branch_digest for turn in turns diff --git a/src/slm_training/evals/advanced_operator_disposition.py b/src/slm_training/evals/advanced_operator_disposition.py index 5ba873b8c..5f6c68831 100644 --- a/src/slm_training/evals/advanced_operator_disposition.py +++ b/src/slm_training/evals/advanced_operator_disposition.py @@ -552,7 +552,7 @@ def build_advanced_operator_disposition( component_id="dsl.operators.replay_preference", version_stamp=component_version_stamps["SLM-418"], suite="tests/test_dsl/test_replay_preference.py", - result="6 of 7 named patterns (edit-then-undo, undo-then-redo, partial-rollback, checkout-another-state, fork-then-choose-one-branch, merge-success) extract and replay-verify; partial slice", + result="7 of 7 named patterns (edit-then-undo, undo-then-redo, partial-rollback, checkout-another-state, fork-then-choose-one-branch, merge-success, pronoun-focus-followup) extract and replay-verify; partial slice", ), _fixture_evidence( evidence_id="SLM-419.dsh5-11", @@ -780,7 +780,7 @@ def dims( dv, dr = dims( (S, "The delivered slice (edit-then-undo, undo-then-redo) extracts rows whose chosen/rejected actions are verified legal-set members and whose chosen_output_state matches independent replay."), - (R, "1 of 7 named patterns (pronoun/focus follow-ups) is not attempted; merge conflict is honestly scoped out as a non-row legality constraint rather than a row. No SFT/preference training, no four-baseline comparison, no held-out benefit measurement exists."), + (R, "All 7 of 7 named extraction patterns now extract and replay-verify; merge conflict is honestly scoped out as a non-row legality constraint rather than a row. No SFT/preference training, no four-baseline comparison, no held-out benefit measurement exists."), (R, "No CAP0/CAP1/CAP2 retention or calibration measurement exists yet for rows sourced from this module."), (R, "No turn-depth or context-view ablation exists; OperatorEventMemoryReportV1 is row counts only."), ) diff --git a/src/slm_training/resources/versions.json b/src/slm_training/resources/versions.json index 6caedc2ec..ff2383431 100644 --- a/src/slm_training/resources/versions.json +++ b/src/slm_training/resources/versions.json @@ -1091,13 +1091,18 @@ ] }, "dsl.operators.replay_preference": { - "version": "v5", + "version": "v6", "kind": "harness", "paths": [ "src/slm_training/dsl/operators/replay_preference.py", "tests/test_dsl/test_replay_preference.py" ], "history": [ + { + "version": "v6", + "date": "2026-07-27", + "note": "SLM-418 (DSH5-10) fifth slice: add pronoun_focus_followup, the last of the issue's seven named patterns, as a new branch inside extract_replay_preference_rows's existing turn-pair scan (a second consecutive AST_EDIT turn). Focus is never a transcript pronoun or semantic descriptor -- it is _touched_refs, the exact OperatorRef values the immediately preceding AST_EDIT turn's own verified OperatorApplicationV1.arguments bound. The pair is classified pronoun_focus_followup only when that focus set is non-empty, the following edit's own bound arguments intersect it, and the exact legal set at the shared decision state contains a sibling -- another legal action for the same operator whose bound refs do not overlap the focus set, i.e. a genuinely available equally-legal switch the user did not take. Switching to a different, legal, explicit reference (no focus overlap) is honestly left unrowed rather than asserted a correction. Coverage now 7 of 7 named extraction patterns; only the issue's SFT/preference training, four-baseline comparison, held-out benefit measurement, and turn-depth/context-view ablations remain" + }, { "version": "v5", "date": "2026-07-27", @@ -10885,6 +10890,11 @@ "docs/design/dsh5-12-advanced-operator-disposition-20260727-local/" ], "history": [ + { + "version": "v2", + "date": "2026-07-27", + "note": "no-bump: correct the hardcoded SLM-418/DSH5-10 evidence string again (dsl.operators.replay_preference is now on its own v6, with pronoun-focus-followup completing coverage to 7 of 7 named patterns; only the issue's SFT/preference training, four-baseline comparison, held-out benefit measurement, and turn-depth/context-view ablations remain) so a freshly-generated disposition would not understate already-landed replay-preference-row coverage; no change to disposition logic, schema, or the already-published dsh5-12-*-20260727-local snapshot, which stays immutable point-in-time evidence" + }, { "version": "v2", "date": "2026-07-27", diff --git a/tests/test_dsl/test_replay_preference.py b/tests/test_dsl/test_replay_preference.py index a3cecd609..7d6b5227b 100644 --- a/tests/test_dsl/test_replay_preference.py +++ b/tests/test_dsl/test_replay_preference.py @@ -3,18 +3,37 @@ from __future__ import annotations import hashlib +from dataclasses import replace from slm_training.dsl.operators import ( + ActionEffectV1, + AstOperatorV1, + BindingPhase, + BoundArgumentV1, + CompilerCoverage, + OperatorArgumentSlotV1, OperatorEventMemoryReportV1, + OperatorLibraryV1, + OperatorMutationV1, + OperatorStateV1, + RefKind, + ReferenceDescriptorV1, + RegisteredOperatorV1, ReplayPreferenceRelation, + append_operator_turn, + branch_fingerprint, + build_reference_table, checkout_conversation_state, + create_conversation_trace, extract_merge_preference_row, extract_replay_preference_rows, fork_conversation, merge_conversation_branches, redo_conversation, + serialize_operator_action, undo_conversation, ) +from slm_training.dsl.pack import get_pack from tests.test_dsl.test_operator_conversation import _append, _fixture, _provenance from tests.test_dsl.test_operator_merge import _Fixture as _MergeFixture from tests.test_dsl.test_operator_merge import _provenance as _merge_provenance @@ -24,6 +43,110 @@ def _sha(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() +_PRONOUN_SOURCE = 'root = TextContent(":hero.title")' +_PRONOUN_OPERATOR_ID = "openui.fixture_pronoun_focus" +_PRONOUN_TABLE_SEED = 9 +_PRONOUN_TARGETS = {0: ":hero.target_a", 1: ":hero.target_b"} + + +def _pronoun_descriptor(index: int) -> ReferenceDescriptorV1: + return ReferenceDescriptorV1( + ref_kind=RefKind.VALUE, + semantic_fingerprint=_sha(f"pronoun-target-{index}"), + value_type="openui.string", + ) + + +def _pronoun_focus_fixture(): + """Two always-legal ``VALUE`` refs for one operator: a genuine sibling pair. + + Unlike the zero-argument ``_fixture()`` every other pattern in this + module reuses, pronoun-focus classification needs an operator whose + argument choice is itself ambiguous -- two distinct, simultaneously + legal refs the user could target -- so "continued focus" has a real + "switched to the untouched sibling" alternative to be preferred over. + Both refs stay legal (and keep the same opaque IDs) at every state this + fixture reaches: they are allocated from a fixed seed + descriptor pair, + which ``build_reference_table`` derives independently of any state + digest, so focus overlap can be checked by direct ref equality. + """ + base_pack = get_pack("openui") + root_state = OperatorStateV1.from_source(base_pack, _PRONOUN_SOURCE) + descriptors = tuple(_pronoun_descriptor(index) for index in (0, 1)) + branch = branch_fingerprint(root_state.state_digest, _sha("pronoun-branch")) + + def table_for(state): + return build_reference_table( + request_id="request-1", + state_digest=state.state_digest, + branch_digest=branch, + descriptors=descriptors, + seed=_PRONOUN_TABLE_SEED, + ) + + root_table = table_for(root_state) + semantic_by_ref = { + entry.ref: entry.descriptor.semantic_fingerprint for entry in root_table.entries + } + index_by_semantic = { + descriptor.semantic_fingerprint: index + for index, descriptor in enumerate(descriptors) + } + refs_by_index = { + index_by_semantic[entry.descriptor.semantic_fingerprint]: entry.ref + for entry in root_table.entries + } + + def execute(_state, arguments): + index = index_by_semantic[semantic_by_ref[arguments[0].value]] + return OperatorMutationV1( + source=f'root = TextContent("{_PRONOUN_TARGETS[index]}")', + effect=ActionEffectV1(compiler_coverage=CompilerCoverage.EXACT), + ) + + declaration = AstOperatorV1( + operator_id=_PRONOUN_OPERATOR_ID, + version="v1", + domain="openui.ast", + codomain="openui.ast", + argument_slots=( + OperatorArgumentSlotV1("value", RefKind.VALUE, BindingPhase.APPLICATION), + ), + preconditions=(), + effect_signature=(), + locality="node", + cost=1.0, + ) + library = OperatorLibraryV1((RegisteredOperatorV1(declaration, execute),)) + pack = replace(base_pack, operator_library=library) + trace = create_conversation_trace( + pack=pack, + root_state=root_state, + root_reference_table=root_table, + provenance=_provenance(root_state), + ) + return pack, library, trace, table_for, refs_by_index + + +def _append_pronoun(pack, library, trace, ref, *, table_for): + result = library.apply( + pack, + trace.current.state, + _PRONOUN_OPERATOR_ID, + (BoundArgumentV1("value", ref),), + _provenance(trace.current.state), + ) + assert result.succeeded + assert result.state is not None + return append_operator_turn( + trace, + pack=pack, + library=library, + application=result.application, + output_reference_table=table_for(result.state), + ) + + def test_edit_then_undo_yields_one_row_grounded_in_the_legal_set() -> None: pack, library, root = _fixture() edited, _application = _append(pack, library, root) @@ -439,3 +562,97 @@ def test_merge_conflict_never_yields_a_preference_row() -> None: assert row is None + +def test_pronoun_focus_followup_prefers_continuing_the_touched_ref() -> None: + """Two edits, both targeting the ref the first one touched, is the pattern. + + root -> edit(ref0) -> edit(ref0 again): at the second edit's decision + state, ref1 was an equally legal target for the same operator (a genuine + sibling), but the user's follow-up kept operating on the ref they had + just touched -- exactly the "it" continuation the pattern models, + without ever consulting any transcript text. + """ + pack, library, trace, table_for, refs_by_index = _pronoun_focus_fixture() + edited_once = _append_pronoun(pack, library, trace, refs_by_index[0], table_for=table_for) + edited_twice = _append_pronoun( + pack, library, edited_once, refs_by_index[0], table_for=table_for + ) + + report = extract_replay_preference_rows( + edited_twice, pack=pack, library=library, provenance_for=_provenance + ) + + assert report.counts_by_relation == { + ReplayPreferenceRelation.PRONOUN_FOCUS_FOLLOWUP.value: 1 + } + row = report.rows[0] + assert row.input_state_id == edited_once.current_state_id + assert row.chosen_action == serialize_operator_action( + _PRONOUN_OPERATOR_ID, (BoundArgumentV1("value", refs_by_index[0]),) + ) + assert row.rejected_action == serialize_operator_action( + _PRONOUN_OPERATOR_ID, (BoundArgumentV1("value", refs_by_index[1]),) + ) + assert row.chosen_output_state_id == edited_twice.current_state_id + assert row.correction_reason == ( + "user_continued_implicit_focus_over_sibling_target" + ) + + +def test_pronoun_focus_row_replays_independently_to_its_recorded_output_state() -> None: + pack, library, trace, table_for, refs_by_index = _pronoun_focus_fixture() + edited_once = _append_pronoun(pack, library, trace, refs_by_index[0], table_for=table_for) + edited_twice = _append_pronoun( + pack, library, edited_once, refs_by_index[0], table_for=table_for + ) + + report = extract_replay_preference_rows( + edited_twice, pack=pack, library=library, provenance_for=_provenance + ) + row = report.rows[0] + + replayed = _append_pronoun( + pack, library, edited_once, refs_by_index[0], table_for=table_for + ) + assert replayed.current_state_id == row.chosen_output_state_id + + +def test_switching_to_the_untouched_sibling_does_not_yield_a_pronoun_focus_row() -> None: + """Switching targets is honestly out of scope: it is not asserted a mistake. + + root -> edit(ref0) -> edit(ref1): the follow-up explicitly moved to the + sibling ref instead of continuing focus. This is the "exact named + reference" case the issue's own matrix lists as distinct from "pronoun + focus" -- no row is fabricated for it here. + """ + pack, library, trace, table_for, refs_by_index = _pronoun_focus_fixture() + edited_once = _append_pronoun(pack, library, trace, refs_by_index[0], table_for=table_for) + edited_twice = _append_pronoun( + pack, library, edited_once, refs_by_index[1], table_for=table_for + ) + + report = extract_replay_preference_rows( + edited_twice, pack=pack, library=library, provenance_for=_provenance + ) + + assert report.rows == () + assert report.counts_by_relation == {} + + +def test_pronoun_focus_followup_needs_an_established_focus() -> None: + """A first edit from the trace root has no predecessor to continue focus from. + + Only one ``AST_EDIT`` exists in this trace, so the turn-pair scan never + fires -- there is nothing to compare the (nonexistent) follow-up + against, confirming the pattern never fabricates a focus out of a single + edit. + """ + pack, library, trace, table_for, refs_by_index = _pronoun_focus_fixture() + edited_once = _append_pronoun(pack, library, trace, refs_by_index[0], table_for=table_for) + + report = extract_replay_preference_rows( + edited_once, pack=pack, library=library, provenance_for=_provenance + ) + + assert report.rows == () +