From b5eb814add727d858c0ac582d6bf6ceb09d6a182 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 05:00:51 +0000 Subject: [PATCH 1/2] SLM-418: add merge-success replay preference pattern (DSH5-10) Fourth slice of SLM-418's replay-grounded preference extraction. Adds extract_merge_preference_row, a standalone extraction path (a merge attempt is never a recorded ConversationTraceV1 turn, so this is not a trace-turn scan like extract_replay_preference_rows): one MERGE_SUCCESS row per successful merge_conversation_branches attempt, grounded at the left branch tip with merge: offered alongside checkout/undo in the legal set, and replaying to the real BranchMergeContinuationV1 state. Merge conflict is intentionally not modeled as a row: a conflicting merge has no successor state to replay to and no recorded "chosen instead" action, so fabricating one would violate the pattern's own exact-context-replay requirement. This is honored by construction -- merge: is only ever offered as a legal candidate once merge_conversation_branches has already confirmed success -- and proven by test_merge_conflict_never_yields_a_preference_row. Brings pattern coverage to 6 of 7; only pronoun/focus follow-ups remain, as does all SFT/preference training and held-out measurement. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LrpUNRDWqyatkAec3MzxLe --- docs/design/dsh5-10-replay-preference-rows.md | 97 +++++++++++++-- src/slm_training/dsl/operators/__init__.py | 2 + .../dsl/operators/replay_preference.py | 116 ++++++++++++++++-- .../evals/advanced_operator_disposition.py | 4 +- src/slm_training/resources/versions.json | 17 ++- tests/test_dsl/test_replay_preference.py | 113 +++++++++++++++++ 6 files changed, 326 insertions(+), 23 deletions(-) diff --git a/docs/design/dsh5-10-replay-preference-rows.md b/docs/design/dsh5-10-replay-preference-rows.md index a8bfde89f..99589665d 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 (third increment). +**Status:** partial slice, in progress (fourth increment). **Claim class:** `wiring`. **Honest verdict:** not yet dispositioned -- this PR extends a scoped subset, not the full issue. @@ -13,6 +13,12 @@ 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. + ## What this PR delivers * `src/slm_training/dsl/operators/replay_preference.py`: @@ -93,9 +99,16 @@ calibration, and CAP0/CAP1/CAP2 retention. Per the issue's own scope, not attempted here: -* **Two of seven patterns**: merge success/conflict and pronoun/focus - follow-ups. Merge-conflict detection in particular lives in `merge.py`, - not `conversation.py`/`collapse.py`, and needs its own extraction path. +* **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 + "what the user did instead" action the trace never recorded. * **SFT/preference training** against the DSH3-selected policy/control heads (`TypedOperatorPolicyScorer`, `src/slm_training/harnesses/experiments/typed_operator_policy.py:316`) or @@ -116,11 +129,11 @@ 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 (two more patterns, 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 patterns and training/evaluation work enumerated -above. +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. ## Review fixes (v2) @@ -170,12 +183,74 @@ above. snapshot is untouched, staying immutable point-in-time evidence from when SLM-420 ran (before this and the prior SLM-418 slice landed). +## Fourth slice (v5) + +* Added `extract_merge_preference_row`, a **standalone extraction function** + -- not another branch inside `extract_replay_preference_rows`'s turn-scan + loop -- because a merge attempt is never a recorded `ConversationTraceV1` + turn. `merge_conversation_branches` (`merge.py`) operates directly on a + shared `base` `ConversationStateNodeV1` and two independently verified + `BranchEditV1` edges, and a successful merge starts a **fresh** + continuation trace (`BranchMergeContinuationV1`, a new trace root) rather + than appending to either input trace. This confirms the first slice's own + prediction that merge-conflict detection "needs its own extraction path." +* `ReplayPreferenceRelation.MERGE_SUCCESS` (new). One row per successful + merge attempt, grounded at the **left branch tip** + (`left.output_node.state_id`): the legal set there is enumerated with + `merge:` (a new, order-independent canonical action + name -- sorted so it serializes identically regardless of which edge is + passed as `left` vs `right`, matching `merge_conversation_branches`'s own + order-invariant `decision_id`) and `checkout:` (plus `undo`, + when a parent exists) offered alongside it, via the same + `ordinary_nonoperator_actions` mechanism every other pattern in this + module uses. `chosen_output_state_id` is the real + `decision.continuation.merged_node.state_id` -- re-running + `merge_conversation_branches` on the same `base`/`left`/`right` + independently reproduces the identical merged state, satisfying the + issue's replay-independence acceptance criterion exactly like every + other relation. +* **Merge conflict is deliberately *not* modeled as a row.** The issue's + own acceptance criterion requires every row to independently replay to + its recorded `chosen_output_state_id`; a conflicting merge produces no + successor state at all, so a row would have to invent a "what the user + did instead" action the trace never recorded -- violating the issue's + own adversarial control that chosen/rejected rows share exact, evidenced + context. The issue's instruction to "mark rejected candidates as typed + illegal/conflict controls outside the ranking denominator" is honored by + **construction** instead: `extract_merge_preference_row` only ever adds + `merge:` as a legal candidate action after + `merge_conversation_branches` has already confirmed `decision.succeeded`, + so a conflicting merge can never leak into any ranking denominator in the + first place. `test_merge_conflict_never_yields_a_preference_row` proves + this directly: a same-target-field conflict (`SAME_NODE_INCOMPATIBLE_ + EDIT`) yields `None`, not a fabricated row. +* `authority_resolver` (the same `BranchAuthorityResolver` type + `merge_conversation_branches` itself takes) is resolved from + `left.input_node` -- the same node the merge module's own internals + resolve authority from -- since a `BranchEditV1` is one verified single- + application edge and its input-state authority governs the actions legal + at its output tip too. +* `dsl.operators.replay_preference` bumped v4 -> v5 in + `src/slm_training/resources/versions.json`; `dsl.operators.contracts` + gets a `no-bump:` history entry for the new `extract_merge_preference_row` + re-export from `operators/__init__.py`. +* Corrected the hardcoded SLM-418 evidence string in + `src/slm_training/evals/advanced_operator_disposition.py` (previously "5 + of 7"; now "6 of 7", with the remaining-gap claim narrowed from "5 of 7 + patterns not attempted" to "1 of 7 (pronoun/focus)" plus an explicit note + that merge conflict is an honest non-row scope decision) 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 -pytest -q tests/test_dsl/test_replay_preference.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 +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): `40 passed`. +change): `57 passed`. diff --git a/src/slm_training/dsl/operators/__init__.py b/src/slm_training/dsl/operators/__init__.py index 25f6f3d53..b25741cee 100644 --- a/src/slm_training/dsl/operators/__init__.py +++ b/src/slm_training/dsl/operators/__init__.py @@ -163,6 +163,7 @@ OperatorEventMemoryReportV1, OperatorReplayPreferenceRowV1, ReplayPreferenceRelation, + extract_merge_preference_row, extract_replay_preference_rows, ) from slm_training.dsl.operators.topology import ( @@ -437,6 +438,7 @@ "derive_turn_disposition", "deserialize_operator_action", "enumerate_operator_legal_set", + "extract_merge_preference_row", "extract_replay_preference_rows", "fork_conversation", "iter_operator_argument_tuples", diff --git a/src/slm_training/dsl/operators/replay_preference.py b/src/slm_training/dsl/operators/replay_preference.py index dc4e91a49..8c8e1dda9 100644 --- a/src/slm_training/dsl/operators/replay_preference.py +++ b/src/slm_training/dsl/operators/replay_preference.py @@ -1,19 +1,38 @@ """SLM-418 (DSH5-10): replay-grounded preference rows from undo/redo history. -Builds versioned preference rows over one exact input state from five +Builds versioned preference rows over one exact input state from six 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, and fork-then-choose-one-branch -- -where the chosen and rejected control-or-operator actions are checked -against the exact legal set available at that state +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: -merge success/conflict or 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. +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. + +Merge conflict (the other half of the issue's "merge success/conflict" +pattern) is deliberately *not* modeled as a preference row here: unlike +undo/redo/checkout/fork, a merge attempt is never a recorded +``ConversationTraceV1`` turn (``merge_conversation_branches`` in +``merge.py`` operates directly on a shared base and two independently +verified ``BranchEditV1`` edges, and a successful merge starts a *fresh* +continuation trace rather than appending to either input trace). A row +would require independently replaying to a recorded chosen_output_state_id +(the issue's own acceptance criterion), and a conflicting merge has no +successor state to replay to -- fabricating a "what the user did instead" +row the trace never recorded would violate the issue's own adversarial +control that chosen/rejected rows must share exact, evidenced context. The +issue's own instruction to "mark rejected candidates as typed +illegal/conflict controls outside the ranking denominator" is honored +instead by construction: ``extract_merge_preference_row`` only ever offers +``merge:`` as a legal candidate action when +``merge_conversation_branches`` has already confirmed it succeeds, so a +conflicting merge is never mistakenly added to any ranking denominator; see +``test_merge_conflict_never_yields_a_preference_row``. """ from __future__ import annotations @@ -31,6 +50,11 @@ OperatorLegalSetV1, enumerate_operator_legal_set, ) +from slm_training.dsl.operators.merge import ( + BranchAuthorityResolver, + BranchEditV1, + BranchMergeDecisionV1, +) from slm_training.dsl.operators.registry import OperatorLibraryV1, OperatorStateV1 from slm_training.dsl.pack import DslPack from slm_training.harness_core.versioning import build_version_stamp @@ -44,6 +68,7 @@ class ReplayPreferenceRelation(str, Enum): PARTIAL_ROLLBACK = "partial_rollback" CHECKOUT_ANOTHER_STATE = "checkout_another_state" FORK_THEN_CHOOSE_ONE_BRANCH = "fork_then_choose_one_branch" + MERGE_SUCCESS = "merge_success" @dataclass(frozen=True) @@ -339,3 +364,76 @@ def extract_replay_preference_rows( rows=tuple(rows), version_stamp=build_version_stamp("dsl.operators.replay_preference"), ) + + +def _merge_candidate_action(left: BranchEditV1, right: BranchEditV1) -> str: + """A canonical, order-independent action name for merging two branch tips. + + Sorted so the same pair of tips always serializes identically regardless + of which edge is passed as ``left`` versus ``right`` -- matching + ``merge_conversation_branches``'s own order-invariant ``decision_id``. + """ + tips = tuple(sorted((left.output_node.state_id, right.output_node.state_id))) + return f"merge:{tips[0]}:{tips[1]}" + + +def extract_merge_preference_row( + *, + left: BranchEditV1, + right: BranchEditV1, + decision: BranchMergeDecisionV1, + authority_resolver: BranchAuthorityResolver, + provenance_for: ProvenanceFactory, +) -> OperatorReplayPreferenceRowV1 | None: + """One ``MERGE_SUCCESS`` row for a merge attempt between two fork tips. + + Grounded from ``left``'s exact output state: at that state, the legal + set is enumerated with ``merge:`` and ``checkout:`` (and ``undo``, when a parent exists) offered alongside the + ordinary operator actions -- the same + ``ordinary_nonoperator_actions`` mechanism ``extract_replay_preference_ + rows`` uses for ``undo``/``redo``/``checkout``. ``merge:`` is only + ever offered when ``merge_conversation_branches`` has already confirmed + ``decision.succeeded`` and produced a fresh continuation node; a + conflicting merge returns ``None`` here rather than a row (see the + module docstring for why merge conflict is not modeled as a row). + + ``authority_resolver`` is called with ``left.input_node`` -- the same + node ``merge_conversation_branches`` itself resolves the left edge's + authority from -- since a ``BranchEditV1`` is one verified single- + application edge and its input-state authority governs the actions + legal at its output tip too. + + Returns ``None`` when the merge conflicted, or when no unchosen legal + alternative exists at the decision state (never asserting merge + preferred by default, matching every other pattern in this module). + """ + if not decision.succeeded or decision.continuation is None: + return None + decision_tip = left.output_node + other_tip = right.output_node + pack, library = authority_resolver(left.input_node) + merge_action = _merge_candidate_action(left, right) + ordinary_actions = [f"checkout:{other_tip.state_id}", merge_action] + if decision_tip.parent_state_id is not None: + ordinary_actions.append("undo") + legal_set = enumerate_operator_legal_set( + pack=pack, + library=library, + state=decision_tip.state, + reference_table=decision_tip.reference_table, + provenance=provenance_for(decision_tip.state), + ordinary_nonoperator_actions=tuple(ordinary_actions), + ) + rejected = _pick_rejected(legal_set, merge_action) + if merge_action not in legal_set.all_serialized_actions or rejected is None: + return None + return OperatorReplayPreferenceRowV1( + input_state_id=decision_tip.state_id, + chosen_action=merge_action, + rejected_action=rejected, + chosen_output_state_id=decision.continuation.merged_node.state_id, + semantic_relation=ReplayPreferenceRelation.MERGE_SUCCESS, + correction_reason="user_merged_diverged_branches", + legal_set_fingerprint=legal_set.fingerprint, + ) diff --git a/src/slm_training/evals/advanced_operator_disposition.py b/src/slm_training/evals/advanced_operator_disposition.py index 674896e07..5ba873b8c 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="5 of 7 named patterns (edit-then-undo, undo-then-redo, partial-rollback, checkout-another-state, fork-then-choose-one-branch) extract and replay-verify; partial slice", + 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", ), _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, "5 of 7 named patterns are not attempted; no SFT/preference training, no four-baseline comparison, no held-out benefit measurement exists."), + (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, "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 b814b752f..7f41605af 100644 --- a/src/slm_training/resources/versions.json +++ b/src/slm_training/resources/versions.json @@ -631,6 +631,11 @@ "tests/test_dsl/test_operator_contracts.py" ], "history": [ + { + "version": "v16", + "date": "2026-07-27", + "note": "no-bump: SLM-418 (DSH5-10) fourth slice re-exports extract_merge_preference_row from operators/__init__.py (dsl.operators.replay_preference bumped to v5); no change to contracts.py itself" + }, { "version": "v16", "date": "2026-07-26", @@ -1071,13 +1076,18 @@ ] }, "dsl.operators.replay_preference": { - "version": "v4", + "version": "v5", "kind": "harness", "paths": [ "src/slm_training/dsl/operators/replay_preference.py", "tests/test_dsl/test_replay_preference.py" ], "history": [ + { + "version": "v5", + "date": "2026-07-27", + "note": "SLM-418 (DSH5-10) fourth slice: add extract_merge_preference_row, a standalone extraction path (not a trace-turn scan, since a merge attempt is never a recorded ConversationTraceV1 turn) producing one MERGE_SUCCESS row per successful merge_conversation_branches attempt, grounded at the left branch tip with merge: offered alongside checkout/undo in the legal set and replaying to the real BranchMergeContinuationV1 state. Merge conflict is intentionally NOT modeled as a row (no successor state to replay to, no recorded 'chosen instead' action) -- honored by construction, since merge: is only ever offered as a candidate when merge_conversation_branches has already confirmed success; a conflicting merge is proven to never pollute the ranking denominator via test_merge_conflict_never_yields_a_preference_row. 6 of ~7 verified patterns now covered; only pronoun/focus follow-ups remain, as does all SFT/preference training and held-out measurement" + }, { "version": "v4", "date": "2026-07-27", @@ -10832,6 +10842,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 (dsl.operators.replay_preference is now on its own v5, with merge-success bringing coverage to 6 of 7 named patterns; only pronoun/focus follow-ups remain, and merge conflict is honestly scoped as a non-row legality constraint) 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 5744fba49..a3cecd609 100644 --- a/tests/test_dsl/test_replay_preference.py +++ b/tests/test_dsl/test_replay_preference.py @@ -8,12 +8,16 @@ OperatorEventMemoryReportV1, ReplayPreferenceRelation, checkout_conversation_state, + extract_merge_preference_row, extract_replay_preference_rows, fork_conversation, + merge_conversation_branches, redo_conversation, undo_conversation, ) 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 def _sha(value: str) -> str: @@ -326,3 +330,112 @@ def test_report_carries_a_version_stamp() -> None: assert report.version_stamp["stamp_schema"] assert report.version_stamp["components"]["dsl.operators.replay_preference"] assert report.to_dict()["version_stamp"] == report.version_stamp + + +def test_merge_success_yields_a_row_preferring_merge_over_checkout_or_undo() -> None: + """A clean, disjoint-target merge is a legal candidate at the fork tip. + + Two branches fork from a common base and edit disjoint targets (title + vs body) -- the merge succeeds, and the row records that the user chose + ``merge:`` over the equally-legal alternative of just checking out + to the sibling branch instead. + """ + fixture = _MergeFixture() + left = fixture.branch(name="left", target_name="title", replacement=":hero.heading") + right = fixture.branch(name="right", target_name="body", replacement=":hero.copy") + decision = merge_conversation_branches( + pack=fixture.pack, + base=fixture.base, + left=left, + right=right, + authority_resolver=fixture.resolve, + reference_table_builder=fixture.rebuild_merged_table, + ) + assert decision.succeeded + + row = extract_merge_preference_row( + left=left, + right=right, + decision=decision, + authority_resolver=fixture.resolve, + provenance_for=_merge_provenance, + ) + + assert row is not None + assert row.semantic_relation is ReplayPreferenceRelation.MERGE_SUCCESS + assert row.input_state_id == left.output_node.state_id + assert row.chosen_action.startswith("merge:") + assert row.chosen_output_state_id == decision.continuation.merged_node.state_id + assert row.rejected_action != row.chosen_action + assert row.correction_reason == "user_merged_diverged_branches" + + +def test_merge_success_row_replays_independently_to_the_same_merged_state() -> None: + fixture = _MergeFixture() + left = fixture.branch(name="left2", target_name="title", replacement=":hero.heading") + right = fixture.branch(name="right2", target_name="body", replacement=":hero.copy") + decision = merge_conversation_branches( + pack=fixture.pack, + base=fixture.base, + left=left, + right=right, + authority_resolver=fixture.resolve, + reference_table_builder=fixture.rebuild_merged_table, + ) + + row = extract_merge_preference_row( + left=left, + right=right, + decision=decision, + authority_resolver=fixture.resolve, + provenance_for=_merge_provenance, + ) + assert row is not None + + replayed = merge_conversation_branches( + pack=fixture.pack, + base=fixture.base, + left=left, + right=right, + authority_resolver=fixture.resolve, + reference_table_builder=fixture.rebuild_merged_table, + ) + assert replayed.continuation is not None + assert replayed.continuation.merged_node.state_id == row.chosen_output_state_id + + +def test_merge_conflict_never_yields_a_preference_row() -> None: + """A conflicting merge is excluded from the ranking denominator entirely. + + Both branches edit the same target -- ``merge_conversation_branches`` + returns a typed ``SAME_NODE_INCOMPATIBLE_EDIT`` conflict, never a + merged state. Per the module's honesty rule, this yields no row (there + is no successor state to replay to and no recorded "chosen instead" + action), rather than a fabricated preference. + """ + fixture = _MergeFixture() + left = fixture.branch( + name="conflict_left", target_name="title", replacement=":hero.left" + ) + right = fixture.branch( + name="conflict_right", target_name="title", replacement=":hero.right" + ) + decision = merge_conversation_branches( + pack=fixture.pack, + base=fixture.base, + left=left, + right=right, + authority_resolver=fixture.resolve, + ) + assert not decision.succeeded + + row = extract_merge_preference_row( + left=left, + right=right, + decision=decision, + authority_resolver=fixture.resolve, + provenance_for=_merge_provenance, + ) + + assert row is None + From 330d7fba510e33c9e6613184d293149f3a360800 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 05:44:07 +0000 Subject: [PATCH 2/2] SLM-418: add pronoun-focus-followup replay preference pattern (DSH5-10) Fifth slice of SLM-418's replay-grounded preference extraction (DSH5-10): completes coverage to 7 of 7 named patterns by adding pronoun_focus_followup, a new branch inside extract_replay_preference_rows's existing turn-pair scan for 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. A 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 (a genuinely available, equally legal switch the user did not take). Switching to a different, legal, explicit reference is honestly left unrowed rather than asserted a correction. Stacked on #1118 (merge-success, 6 of 7). Corrects the hardcoded evidence strings in advanced_operator_disposition.py accordingly. No training or held-out evaluation is added -- this stays wiring evidence only, the same honesty class as the prior four slices (#1107, #1112, #1115, #1118). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GyJd3opkGqa6dwne9dXsVT --- docs/design/dsh5-10-replay-preference-rows.md | 81 +++++-- .../dsl/operators/replay_preference.py | 132 +++++++++-- .../evals/advanced_operator_disposition.py | 4 +- src/slm_training/resources/versions.json | 12 +- tests/test_dsl/test_replay_preference.py | 217 ++++++++++++++++++ 5 files changed, 404 insertions(+), 42 deletions(-) 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 7f41605af..00562a25a 100644 --- a/src/slm_training/resources/versions.json +++ b/src/slm_training/resources/versions.json @@ -1076,13 +1076,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", @@ -10842,6 +10847,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 == () +