Skip to content

fix(engine): the loop period the engine routes on belongs to a seat — name it, everywhere - #1

Closed
lgray wants to merge 5 commits into
mainfrom
fix/dina-proposer-controller-gate
Closed

fix(engine): the loop period the engine routes on belongs to a seat — name it, everywhere#1
lgray wants to merge 5 commits into
mainfrom
fix/dina-proposer-controller-gate

Conversation

@lgray

@lgray lgray commented Aug 5, 2026

Copy link
Copy Markdown
Owner

🤖 AI text below 🤖

Summary

A bounded loop-shortcut win offer never fired on the user's 4-player Dina / Bloodthirsty Conqueror capture. The engine routed on last_loop_action_sequence as a bare "is something driving" flag at every seam that consumes it, so any seat's recorded period suppressed every seat's offer — an opponent's unrelated Currency Converter activation was enough to make the proposer's own drain loop unofferable for the rest of the game.

This names the seat a recorded period belongs to and routes on that instead, through one accessor rather than six restatements. The same question turned out to have a second half: three teardown sites cleared the field without asking whose period it was. Post-fix that is a live regression — a bounded offer can now be minted while a foreign period is live, so one seat's decline or aborted drive would wipe another seat's accumulating period. All three are ownership-scoped here.

Files changed

  • crates/engine/src/game/engine.rs — the routing sites behind one loop_period_controller() authority; handle_decline_shortcut and until_lethal_fallback clears ownership-scoped, proposer threaded from their dispatches; BoundedOfferRefusal::DrivingSequenceNotEmptyProposerHasDrivingPeriod; CR 603.5 census pin re-derived
  • crates/engine/src/analysis/resource.rssole_driver folded onto the same authority
  • crates/engine/src/types/game_state.rsmigrate_transient_loop_sequence doc: consumer count corrected 5 → 7
  • crates/engine/tests/integration/user_capture_probes.rsnew: the user's capture driven through production apply(), fixture-backed
  • crates/engine/tests/integration/loop_shortcut.rs — the decline seam, the aborted-drive seam, and the site-D reachability row
  • crates/engine/tests/integration/fantastic_four_bounded_loop.rs — the accept-side rows the load migration hid from every drain fixture
  • crates/engine/tests/fixtures/dina_conqueror_phase5_no_offer_4p.json.gznew, 771,577 B: the user capture this PR exists to fix
  • crates/engine/tests/integration/main.rs — module registration

Five commits, +1691/−91, 9 test rows added and 0 removed.

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

CR references

CR 732.2a (a shortcut is suggested by the player with priority, describing choices for all players — the basis for asking whose period is driving rather than whether one exists), CR 732.2b (the APNAP acceptance window), CR 732.3 (fragmented loops — cited as contrast, not support), CR 603.5 (the optional-trigger census this diff moves a pin in). Each verified verbatim against the rules text before use.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.
  • Gate A output below is for the current committed head.
  • Final review-impl below is stated precisely for the current committed head.
  • Both anchors cite existing analogous code at the same seam.

Measured at a5ba05dd42cca693405b2ce11a107cc20dcac96b, base fa5fbdfd7059b8cd1f2f2c045c7e79196efc05cc, clean tree (the gate log stamps HEAD_AT_MEASURE, DIRTY_TRACKED=<none>, and the fixture's sha256 as its own first lines, and re-stamps HEAD after the run — both stamps agree, so the figures cannot have drifted onto a different tree mid-run):

  • cargo fmt --all -- --checkEXIT_RC=0
  • cargo clippy --workspace --all-targets -- -D warningsEXIT_RC=0
  • cargo test -p phase-engine --lib18527 passed; 0 failed; 6 ignored
  • cargo test -p phase-engine --test integration4568 passed; 0 failed; 2 ignored

Row-count movement fully attributed, both signs. This branch adds 1 lib row and 8 integration rows and removes 0 of either. The pre-rebase measurement of the same content read 18523 / 4564; the rebase moved the merge-base across 8 upstream commits which add 4 lib rows and 4 integration rows and remove 0. 18523 + 4 = 18527 and 4564 + 4 = 4568, exactly. Zero unexplained movement.

Worth stating how that was measured, because the obvious way is wrong: the baseline for attribution is git merge-base of the pre-rebase tip and main, not whatever upstream/main pointed at when the earlier figure was taken. Those two differ for any tree that has not just rebased, and using the latter here returns a delta of zero and leaves the real +4 looking like an instrument fault.

Discrimination. Every new assertion has a DROP arm and a TRIVIALIZE arm that flip that named assertion, each pair flipping a different one, with every mutant test-binary sha256 distinct from pristine and from each other, and a 1 passed positive control per run so a mis-filtered mutant cannot report a vacuous pass. Decline seam: DROP (unconditional clear restored) fails the FOREIGN assertion; TRIVIALIZE (never clear) fails the OWN assertion, with FOREIGN reached and passing first. Aborted-drive seam and site D likewise. The offer's own conjunct: reverting it to the seat-blind form fails the live-sequence row while the as-loaded row still passes.

Gate A

Gate A PASS head=a5ba05dd42cca693405b2ce11a107cc20dcac96b base=fa5fbdfd7059b8cd1f2f2c045c7e79196efc05cc

Base stated so the range can be checked rather than taken on trust: it is the merge-base with the current origin/main, the diff over it is 8 files, and 0 of them are under crates/engine/src/parser. The PASS is real but uninformative for this diff — it passes because no parser code is touched, not because parser code was checked. (An earlier pre-commit run of this gate resolved against a stale fork ref; that figure is deliberately not the one quoted here.)

Anchored on

  • crates/engine/src/game/engine.rs:3002 — the pre-existing t.owner != proposal.proposer check inside apply_confirmed_shortcut (opens :2977): the shortcut seam already scopes a decision to the proposer's own objects rather than to "any". This change extends that same reasoning to the recorded period instead of adding a parallel notion of ownership.
  • crates/engine/src/game/turn_control.rs:354authorized_submitter_for_player, the pre-existing single authority for "which seat may act for this player". The new loop_period_controller() accessor is the same shape one layer down: one function answering a seat question that call sites previously each answered for themselves.

Final review-impl

Stated precisely, because the honest answer is not one line. Independent review passes ran on the first two commits (NON-BLOCKING, nine findings) and drove the two response rounds that followed; the review that found the handle_decline_shortcut regression is the reason this PR contains it at all. The third and fourth commits were self-verified with per-assertion mutant pairs and by driving both broken routes live through production apply(), and have not had a separate independent review pass. The fifth (the census-pin re-derivation) is a rebase adaptation, verified by content-hash uniqueness rather than by review.

I would rather say that than let one PASS line imply a review that did not happen.

Claimed parse impact

None. No file under crates/engine/src/parser is touched.

Scope Expansion

Two, both disclosed rather than folded in silently — and both are scope this fix created.

The approved plan's site census listed every site that routes on the action sequence and none that writes or clears it. Two teardown sites fell in that blind spot:

  1. handle_decline_shortcut cleared unconditionally. Harmless before this change — no LoopShortcut could exist with a foreign period in state — and a live regression after it, since DeclineShortcut dispatches from any LoopShortcut.
  2. until_lethal_fallback cleared unconditionally on an aborted drive. This one was first refused as out-of-scope on the argument that no fixture reached it; that argument was corpus-relative, and this PR adds fixtures. Re-tested by injection it proved reachable and discriminating, so it is fixed here rather than deferred.

Both censuses — read/route and write/clear — were re-derived as separate lists with stated predicates, rather than patching the sites a review happened to find.

Validation Failures

An earlier revision of this PR carried four disclosures. Three are now fixed and one was refuted; what remains is one pre-existing gap, stated with an owner.

Fixed rather than shipped as disclosures:

  1. The headline result no longer depends on an env-gated probe. It ran only when a DINA_DUMP path was supplied, so CI could never reproduce it. The capture is now a tracked fixture (sha256 ec1521d96ecd481716e48bcb27a58aea05e65913433e5335d28a9010e363441f, byte-reproducible via jq -c '{gameState}' | gzip -9 -n from the pristine root, and independently re-derived to the same hash). The env gate is deleted, not kept alongside. Worth naming the obstacle, because it is not obvious: migrate_transient_loop_sequence clears last_loop_action_sequence at every load that is not a shortcut window, so tracking the dump alone yields a second control arm rather than the live one — the rows re-inject the recorded sequence from the fixture's own JSON, exactly as the env-gated probe did. The tracked rows reproduce the env-gated figures cell for cell; nothing was weakened to make them pass.
  2. The dominated mint-census residual is retracted, not re-argued. A per-frame cleared twin measured the domination directly: all five residual refusals attributed to the driving-sequence conjunct also refuse on ProposerIsNotActivePlayer0 load-bearing, 5 dominated. The figure was worth nothing and is withdrawn in the commit message; two real per-frame assertions replace it.
  3. The CR 732.3 position is stated where it can be read — in the commit message, with its measured reachability limit, rather than only in an in-code comment.

Refuted:

  1. "Site D is unreachable by any fixture" was false. It is reachable by injection at a Path-A UntilLethal accept and discriminating there: the shipped guard yields GameOver{winner: P0} with the foreign period intact, the DROP mutant yields Priority{P0} with it wiped. Now rowed. The sibling site F remains unreachable on this corpus, and that is now three measured assertions (points == 0, is_bounded, predicted_winner == None) instead of prose.

Still open — one, pre-existing, with a named owner. Site D's OWN-period branch is asserted by no row in the tree. Measured rather than argued: forcing that dispatch to the drain branch unconditionally leaves the whole integration suite green, because both branches end at the same Priority handback once the fallback rolls back to committed. This PR neither creates nor closes it. It is stated in the row's own doc comment and is owned by this lane in its deferral-burndown queue.

CI Failures

None.

lgray added 5 commits August 5, 2026 10:14
…ere the engine routes on it

`last_loop_action_sequence` records ONE seat's driving period, but every site that
routed on it tested only `!is_empty()` — while the consumer those sites route to,
`try_offer_object_growth_shortcut`, already required every step to belong to the
priority holder. The routing signal was strictly coarser than the admission predicate
of the thing it routes to, so a foreign period could not produce an object-growth offer
(CR 732.2a) yet still refused the bounded one. One opponent activation therefore refused
a proposer's own certified bounded offer for the rest of the game — measured on the
user's Dina/Bloodthirsty Conqueror capture, where re-injecting the dump's own recorded
step (`Activate{source_id:268}`, controller 2) turned `LoopShortcut(0)` into
`Priority(2)` for the whole 140-beat drive.

Hoist that whole-period test into `GameState::loop_period_controller() -> Option<PlayerId>`
(typed, `None` for no period and fail-closed `None` for a heterogeneous run) and read it
at all five routing sites, so signal and consumer cannot disagree by construction:

* the empty-stack Interactive bridge precondition (behaviour-preserving — the consumer
  already returns `None` for a foreign period; consistency only),
* the bounded mint's conjunct (1b) — the bug,
* `materialize_fixed_shortcut`'s object-growth dispatch, mandatory in lockstep: relaxing
  (1b) alone lets a bounded offer be accepted with a foreign sequence in state and then
  commits ZERO cycles, the exact silent misroute (1b) exists to prevent,
* `apply_until_lethal_shortcut`'s drive dispatch — the same hazard on the `UntilLethal`
  route, and PRE-EXISTING: Path A never reads the sequence, so a Path-A offer accepted
  with a foreign period already drove the wrong seat,
* `handle_declare_shortcut`'s `template: None` arm, the strictest constraint of the five:
  a merely-non-empty test would let a foreign period take the sibling arm that performs
  zero pin validation and open the APNAP window on a client-supplied declaration.

`try_offer_object_growth_shortcut`'s own `any(|c| c.controller != caster)` now calls the
same method rather than restating it.

Scope the certification window's cast-set to the bound proposer (`window_cast_card_ids`,
gate (5)) so a foreign period cannot relieve a soundness gate: without it, an opponent's
choice of WHICH CARD to activate selects which relief applies to the proposer's
certification. `is_some_and`, not `is_some` — the proposer-less 2-arg cover entry that
`analysis::loop_check` uses stays byte-identical.

Correct two docs this change falsifies: the load migration's "the only consumer is the
live detection re-drive, so dropping is strictly safe" (four other consumers, none a
re-drive — that false premise is why the signal went un-audited), and the basis-B
reasoning block's claim that gate (5)'s relief cannot fire "because (1b) requires the
sequence to be EMPTY" (conclusion survives, reason is now the proposer scoping).

Re-pin the CR 603.5 prompt census entry `engine.rs:11549` -> `:11619`: +70 lines above
it, producer line sha256 `8a544e878d3e77fb` and its 40-line block `a5d40248d9015329`
identical on both sides, still inside `begin_pending_trigger_target_selection`, other
four entries in byte-untouched files and unmoved.

Assisted-by: ClaudeCode:claude-opus-5
…drain fixture

`migrate_transient_loop_sequence` clears `last_loop_action_sequence` at every load whose
`waiting_for` is not a shortcut window, so no dump-driven row has ever STARTED from a board
carrying one. The whole accept-side dispatch on that field was untested — the blindness is
in the fixture pipeline, not in the rows, and the answer is injection into a tracked
fixture rather than a new tracked dump.

Two additions, on two different seams:

* `an_accepted_bounded_grant_drains_even_with_a_foreign_period_in_state` — inject an
  opponent's period into the tracked 4p Dina/Conqueror fixture at the offer beat, accept
  `Fixed(3)`, and assert the committed life delta is exactly `3 x` the published per-period
  delta, i.e. the DRAIN materializer ran rather than the object-growth one. Asserts that
  dina's schema publishes no points, so the declare-seam arm is provably not on this path
  and the outcome attributes to the materialize dispatch alone.

* `user_capture_probes.rs` — the Dina user-capture arms, env-gated on `DINA_DUMP` and inert
  without it, so the capture that FOUND this defect stays re-runnable against the tree that
  fixed it. ARM D1 loads the dump as production does (field cleared); ARM D2 re-injects the
  dump's own recorded step and asserts the fix bar against D1 re-driven in the same process
  rather than against transcribed numbers.

Measured on the user's capture with the seat-relative routing in place: the two arms are
identical on every recorded axis — `fired=Some(21) wf=LoopShortcut(0) ring=5
life=[51,29,34,34]` — while `seq=1` still holds the foreign step. Before the fix the live
arm answered `Priority(2)` for all 140 beats.

Assisted-by: ClaudeCode:claude-opus-5
…p period

Round-2 review fixes on the seat-relative loop-period authority. One behavioural
regression this branch's own fix enabled, two naming contracts it falsified, and four
measured-false or vacuous artifacts.

THE REGRESSION (a SEVENTH routing site the plan's census missed). The plan enumerated
every site that ROUTES ON `last_loop_action_sequence` and none that WRITES OR CLEARS it.
`handle_decline_shortcut` cleared the field unconditionally. Pre-fix that was harmless by
construction: no `WaitingFor::LoopShortcut` could exist with a foreign period, so the
handler only ever cleared the proposer's own. Post-fix a bounded offer CAN be minted with
a foreign period, and `DeclineShortcut` dispatches from any `LoopShortcut` — it is the
AI's only action at a bounded offer — so one seat declining a drain offer wiped another
seat's accumulating object-growth period and suppressed that seat's own offer until it
re-armed. Self-healing and not rules-wrong, but real. The clear is now scoped to
`loop_period_controller() == Some(proposer)`, with `proposer` threaded in from the
dispatch arm; scoping costs the suppression nothing, because
`try_offer_object_growth_shortcut` already returns `None` for every period that is not the
priority holder's, so the only period whose survival could re-fire it is the one this
branch still clears. The handler's doc — which still described a `!is_empty()` gate that
no longer exists — is corrected in the same hunk.

BOTH CENSUSES, re-taken by hand rather than inherited. Predicate: every
`last_loop_action_sequence` occurrence under `crates/*/src`, classified PROD/TEST by
brace-tracked `#[cfg(test)]` item extents (a first-hit line heuristic is invalid —
`engine.rs` carries 18 column-0 `#[cfg(test)]` attributes).
* READ/ROUTE: seven production sites read the accessor (bridge precondition, mint (1b),
  the two drive dispatches, `try_offer_object_growth_shortcut` itself, the declare arm,
  and `window_cast_card_ids`).
* WRITE/CLEAR: fourteen production mutations in two classes. ACCUMULATION (the field
  records the most recent driving seat's period; a controller change legitimately resets
  it — that IS the design): `accumulate_loop_action_step`, `record_mana_loop_action_step`,
  `record_loop_pin`, `apply_action`'s activate arm, and the recast capture in
  `casting_costs.rs`. TEARDOWN (a clear as a consequence of a shortcut-protocol event —
  the class where a foreign period can now be present): `until_lethal_fallback`,
  `materialize_object_growth_shortcut`, `handle_decline_shortcut`, and the load migration.
  Of those, `materialize_object_growth_shortcut` is ownership-correct BY CONSTRUCTION (only
  reachable through site C's gate) and the load migration has no seat semantics.
  `until_lethal_fallback` IS an unconditional cross-seat clear, but its reachability is
  IDENTICAL before and after this branch: `handle_declare_shortcut` rejects `UntilLethal`
  against a bounded offer, so the only route in is Path A, which never read the sequence
  pre-fix either. It is the already-disclosed site-D Path-A hazard, not a regression this
  fix enabled, and no fixture reaches it — reported, deliberately not patched blind.

TWO NAMING CONTRACTS THIS BRANCH FALSIFIED — renamed, not annotated.
* `BoundedOfferRefusal::DrivingSequenceNotEmpty` -> `ProposerHasDrivingPeriod`. The
  conjunct now refuses only for the proposer's OWN period; the old name was the contract
  roughly nine rows assert by. Blast radius measured before renaming: 12 tracked
  references in three files, all inside `crates/engine`; the enum derives no `Serialize`
  and appears in no TypeScript, WASM, or JSON surface.
* `a_nonempty_action_sequence_mints_no_bounded_offer` ->
  `a_proposers_own_driving_period_mints_no_bounded_offer`. Its name and doc asserted a
  general property that is false at this tree; it passed only because its arm happens to
  use `controller: proposer`. It sat directly above a row asserting the negation, so the
  file presented two adjacent rows claiming contradictory general properties.
A third falsified doc, found while renaming: `try_offer_bounded_cycle_shortcut`'s own
summary still called the (1b) discriminant "an EMPTY `last_loop_action_sequence`".

ALSO CORRECTED.
* `migrate_transient_loop_sequence`'s doc exists to fix an undercount and had a smaller
  one: it named five consumers and omitted the bridge precondition and the certification
  window's cast-set scoping. Seven, enumerated from the accessor's own call set.
* `resource.rs` X4-5's rustdoc named the wrong mutation for its arm (1). MEASURED: the
  `is_some`-instead-of-`is_some_and` swap fails arm (2), not (1) — with no bound proposer
  it never returns early. The mutation that fails (1) is the unconditional-match form.
* `probe_dina_with_live_sequence` had no in-row reach guard, so on a board where neither
  side reaches an offer both sides compare equal and the row passed vacuously. Its sibling
  had one; this one now has its own, on the control it re-drives in-process.
* `window_scope_from_cover_frames`'s `sole_driver` was an exact two-frame twin of the new
  authority and was not consolidated. It now reads `loop_period_controller()` per frame
  and requires the two answers to agree — the change's whole thesis is one authority.

THE CONTROL ARM NO EXISTING FIXTURE COULD SUPPLY, because the pre-fix world could not
express the state: `declining_a_shortcut_discards_only_the_decliners_own_driving_period`
drives the tracked 4p Dina/Conqueror capture to its real bounded offer and declines it
through the production `apply()` reducer with a period injected for a DIFFERENT seat
(FOREIGN, must survive) and for the proposer (OWN, must be cleared). DROP the ownership
test and FOREIGN fails while OWN passes; TRIVIALIZE it to never clear and OWN fails while
FOREIGN passes — each direction flips a different named assertion, so no constant
implementation passes.

CORRECTION TO THIS BRANCH'S EARLIER COMMIT MESSAGES. They claim the pre-fix live arm
answered `Priority(2)` "for all 140 beats". That is wrong on the distribution and right
only on the endpoint. Re-measured by restoring step (1b)'s `!is_empty()` form on this tree
and re-running the capture (nothing downstream of (1b) can change a per-beat verdict while
(1b) refuses, so the pre-fix trajectory is reproduced faithfully): over the 140 driven
beats the destinations are `31 Priority(0) + 31 Priority(1) + 32 Priority(2) +
31 Priority(3) + 15 OrderTriggers`, and the arm ENDS at `fired=None wf=Priority(2) ring=16
seq=1 life=[64,16,21,21]` without ever firing. The load-bearing figure is the census —
`125 step-(1b) refusals + 15 NotAtPriority` — not the seat label.

Re-pin the CR 603.5 prompt census entry `engine.rs:11619` -> `:11644`: +25 lines above it,
producer line sha256 `8a544e878d3e77fb` and its 40-line block `a5d40248d9015329` identical
on both sides, still inside `begin_pending_trigger_target_selection` (which moved by the
same +25), and the other four entries' files byte-untouched.

Assisted-by: ClaudeCode:claude-opus-5
…wn, everywhere — and the capture that proves it is tracked

Round-3 conversion of the round-2 DISCLOSURES into fixes, under the policy that an
"out-of-scope follow-up" is not exempt from the no-deferrals mandate. Three of the four are
closed; the fourth's honest exit turned out to be a RETRACTION rather than a better figure.
A fifth site — the one round 2 named and deliberately did not patch — proved reachable by
exactly the technique that reached its sibling, so it is fixed here instead of queued.

THE HEADLINE RESULT NOW RUNS IN CI. The end-to-end claim — the bounded offer firing on the
user's own board with a FOREIGN driving period live in state — rested entirely on an
env-gated probe over an untracked bug-report attachment, so nobody outside this lane could
reproduce it. The capture is now a tracked fixture,
`crates/engine/tests/fixtures/dina_conqueror_phase5_no_offer_4p.json.gz` (771 577 B, sha256
`ec1521d96ecd4817…`), derived by the lane's own recipe `jq -c '{gameState}' <dump> |
gzip -9 -n` from the PRISTINE root archive `dina-conqueror-phase5-no-offer.zip` (sha256
`f85f3cd4ec9f8b5f…`, matching that root's own `MANIFEST.sha256`; member
`game-state-turn-5-2026-08-03T19-29-36-888Z.json`, 20 610 078 B, sha256 `87266bbed39c1b41…`).
Re-deriving reproduces the archive byte-for-byte, which is the whole point of `-n`. The
`DINA_DUMP` gate is DELETED rather than kept alongside: it buys nothing the fixture does not.

Tracking the dump alone would NOT have reproduced the result, and that is the interesting
part. `migrate_transient_loop_sequence` clears `last_loop_action_sequence` at every load that
is not a shortcut window, so `into_game_state()` drops the one field this whole file is about
no matter where the bytes came from — a naive tracked row is ARM D1 twice. ARM D2 reads the
field back out of the fixture's OWN serialized JSON and restores it, and ARM D1 now ASSERTS
both halves of that asymmetry (the fixture still serializes exactly one step; the loaded board
carries none), so a future re-derivation that loses the field reds instead of silently
degrading into a second control. The tracked rows reproduce the env-gated figures cell for
cell: DINA-LOADED, DINA-LIVE and DINA-CONTROL all end `fired=Some(21) ring=5
life=[51,29,34,34]`, with `seq` 0 / 1 / 0.

THE RESIDUAL MINT-CENSUS FIGURE IS RETRACTED, and the instrument that would have caught it is
now in the row. Round 2 published `5 ProposerHasDrivingPeriod` residual refusals on the live
arm as the guard "working exactly as designed". The mint runs (1b) BEFORE (2), so that
attribution is evidence only where the same frame with the field EMPTY would have offered —
and it would not have. `mint_verdict` is now taken twice per frame: once on the board as
driven, once on the identical board with only `last_loop_action_sequence` emptied. MEASURED on
this tree, the live arm's 21 census frames are `4 NoCertification + 3 NotAtPriority +
5 ProposerHasDrivingPeriod + 9 ProposerIsNotActivePlayer`, and all five (1b) refusals read
`cleared=ProposerIsNotActivePlayer` ⇒ **0 LOAD-BEARING + 5 DOMINATED**. The residual count was
worth nothing and is withdrawn. What replaces it are two assertions the endpoint equality
could not state, both over that paired instrument:
* FOREIGN-INERTNESS — at each of the 13 frames whose recorded period belongs to a seat OTHER
  than the proposer, the live verdict must EQUAL the cleared verdict. Inertness frame by
  frame, not only at the endpoint.
* OWN-PERIOD — at each of the 5 frames where the period IS the proposer's, (1b) must refuse.
  The load-bearing half of the guard, asserted directly instead of inferred from a residual.
They are ordered before the endpoint bar deliberately: under the DROP mutant the endpoint bar
would otherwise panic first and leave FOREIGN-INERTNESS unobserved.

CR 732.3 IS NOT WHAT STEP (1b) EVER ENFORCED — stated here, not only in a comment. CR 732.3
verbatim: "Sometimes a loop can be fragmented, meaning that each player involved in the loop
performs an independent action that results in the same game state being reached multiple
times. If that happens, the active player (or, if the active player is not involved in the
loop, the first player in turn order who is involved) must then make a different game choice
so the loop does not continue." The engine implements no CR 732.3 gate anywhere, and the old
`!is_empty()` form was not an accidental one either. MEASURED at the three arming sites,
`last_loop_action_sequence` is populated ONLY by mana activations
(`record_mana_loop_action_step`), by activations whose ability graph contains an
`Effect::Token` (`apply_action`'s activate arm, which CLEARS for every other lone activation),
and by buyback-paid token-creating recasts (`casting_costs.rs`, gated on `additional_cost_paid
&& has_buyback && is_token_creating`). CR 732.3's own example — one player granting flying,
another removing it — arms none of the three, so the textbook fragmented loop never populated
the field and was never suppressed. The removal is real; its reachability is narrow, and that
limit belongs beside the claim.

THE TWO ROW-LESS SITES, RE-TESTED RATHER THAN RE-ASSERTED.
* SITE D (`apply_until_lethal_shortcut`'s drive dispatch) was reported unreachable by any
  fixture. That was a fact about the corpus, not about the engine. Site D needs an
  `UntilLethal` proposal, which `handle_declare_shortcut` rejects against any bounded offer, so
  the route in is a Path-A offer — and `interactive_3p_optional_cascade_apnap_accept_win`
  raises one. Injecting an opponent's period at that accept REACHES the branch and
  DISCRIMINATES: shipped, the drive crowns (`GameOver { winner: Some(P0) }`) with the foreign
  period still length 1; with the seat test dropped back to `!is_empty()`, it takes the
  object-growth branch, falls through to `until_lethal_fallback`, ends at `Priority { P0 }`,
  and the foreign period comes back length 0. It has a row.
* SITE F (`handle_declare_shortcut`'s `template: None` arm) sits under
  `!offer.schema.points.is_empty()`. Re-tested against the new fixture: its offer publishes
  `points = 0`, `is_bounded = true`, `max_iterations = 28`, `predicted_winner = None`, so this
  capture can host neither site F nor site D. That is now three assertions in ARM D1 instead of
  a scope claim in prose — a future capture that DOES publish points reds the line rather than
  quietly staling its siblings' scope arguments.

THE SITE ROUND 2 NAMED AND REFUSED IS FIXED, not queued. `until_lethal_fallback` is the SECOND
unconditional cross-seat clear in the teardown family; round 2 found it and declined to patch
it blind on the same "no fixture reaches it" ground. It opens with `*state = committed`,
restoring the PRE-DRIVE board — which, since step (1b) went seat-relative, can carry another
seat's period. MEASURED on the shipped tree before this guard: with a foreign period injected
at a sprout-swarm `UntilLethal` accept, the drive aborts, the fallback runs, and the foreign
seat's period comes back length 0. The clear is now scoped to `loop_period_controller() ==
Some(proposer)`, with `proposer` threaded in from the proposal at all five call sites — the
same authority, the same one-line shape and the same argument as the decline seam: scoping
costs the suppression nothing, because `try_offer_object_growth_shortcut` returns `None` for
every period that is not the priority holder's, so the only period whose survival could
re-fire the offer this fallback walks away from is the proposer's own, which the branch still
clears. The `loop_detect_ring` clear stays unconditional — it is a board-wide sampler with no
seat semantics.

A REALIZED NEGATIVE, recorded rather than hidden. The complementary constant at site D —
always take the drain branch — was measured against the WHOLE integration suite at this tree
and **4564 rows passed, 0 failed**. Site D's own-period branch is asserted by no row in this
tree. That is a pre-existing gap this change neither creates nor closes: both branches end at
the same `Priority` handback because `until_lethal_fallback` rolls the board back to
`committed`, so they are observationally identical on every fixture that reaches them.

MUTANT PAIRS (source-swapped arms; every arm's test-binary CONTENT sha256 DIFFERS, so no arm
silently reused a stale build. Each filter is a FULL libtest path under `--exact`, and the
matching PRISTINE arm reports `1 passed` for it — the positive control against a filter that
matches nothing and lets libtest exit 0. Reverts are content writes, never `git restore` or
`git stash`; `engine.rs` is byte-identical to the committed tree afterwards.)

| arm | mutation | row that flips | outcome | binary sha256 / size |
|---|---|---|---|---|
| PRISTINE | — | all three | 1 passed each | `a25178b4a94783db` / 382 928 928 |
| B_DROP | (1b) back to `!is_empty()` | ARM D2 | FAILS **FOREIGN-INERTNESS** — 93 of 93 foreign frames diverged | `b3d03fe9de495704` / 382 928 864 |
| B_TRIV | (1b) deleted (never refuse) | ARM D2 | FAILS **OWN-PERIOD** — 5 of 5 own frames answered `ProposerIsNotActivePlayer` | `a92e3fa6af0cd64b` / 382 928 664 |
| D_DROP | site D back to `!is_empty()` | site-D row | FAILS **SITE D** — `Priority { P0 }` where `GameOver { Some(P0) }` was required | `7f13de0b89b9370c` / 382 928 816 |
| D_TRIV | site D constant `true` | `interactive_3p_optional_cascade_apnap_accept_win` | FAILS — engine index panic driving an EMPTY sequence | `2c6272dd913c4ebb` / 382 923 304 |
| ULF_PRISTINE | — | fallback row | 1 passed | `ac732e4df0865206` / 382 968 032 |
| ULF_DROP | fallback clear unconditional | fallback row | FAILS the **FOREIGN** arm — `[]` where `[PlayerId(1)]` was required | `9079f83de4031027` / 382 967 816 |
| ULF_TRIV | fallback clear deleted | fallback row | FAILS the **OWN** arm — the proposer's own period survives its own abort | `fb1d43209f60d252` / 382 967 776 |

B_DROP/B_TRIV flip DIFFERENT named assertions, as do ULF_DROP/ULF_TRIV, so no constant
implementation of either guard passes. HONEST PARTIAL: under B_DROP the OWN-PERIOD assertion
is not directly observed (FOREIGN-INERTNESS panics first); structurally it cannot fail there,
because an unconditional (1b) refuses at every own frame too.

GATES at this tree (`cargo fmt --all -- --check` 0; `cargo clippy --workspace --all-targets --
-D warnings` 0): `cargo test -p phase-engine --lib` **18523 passed / 0 failed / 6 ignored**;
`cargo test -p phase-engine --test integration` **4564 passed / 0 failed / 2 ignored**.

Re-pin the CR 603.5 prompt census entry `engine.rs:11644` -> `:11662`, **+18** measured from
the `@@` hunks whose OLD start is above the pin (`+14` on the fallback's doc and new parameter,
`+2` and `+2` on its two clears; the five call-site hunks are line-for-line). Identity
re-established at the new coordinate by two independent instruments: the producer line is
sha256 `8a544e878d3e77fb` on both sides and occurs EXACTLY ONCE in the file, its 40-line block
is `a5d40248d9015329` on both sides, and it is still inside
`begin_pending_trigger_target_selection` (which moved `:11495` -> `:11513`, the same +18).
`git diff --stat` on `effects/mod.rs` and `effects/scoped_library_search.rs` is empty, so the
other four entries could not have moved.

Assisted-by: ClaudeCode:claude-opus-5
The rebase onto `fa5fbdfd7` conflicted in the census-producer array on all
three of this branch's commits — each side carried a coordinate measured
against a base that is now history. Resolving those conflicts by taking either
side would have shipped a pin that names the wrong line.

The pin is re-derived at the rebased tip instead, and located by CONTENT: the
producer's sha256 `8a544e878d3e77fb` is unique in the file, at `:11696`.
Uniqueness of the hash is the evidence that it is the same producer; the +113
shift is only a description of where it landed. Three-way conflict resolution
is not a line-shift, so a sum-of-hunks figure would have been the wrong
instrument here whatever it summed to.

Enclosing fn unchanged (`begin_pending_trigger_target_selection`, :11547). Set
preservation: `effects/mod.rs` and `effects/scoped_library_search.rs` are
byte-untouched by this branch, so the other four entries could not have moved.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

🤖 AI text below 🤖

Opened against the fork by mistake — the correct target is phase-rs/phase. Reopened there; closing this one to avoid a duplicate.

@lgray lgray closed this Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant