From 16d5b907544e0cde75084b23d9bff0136dc61a48 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:20:09 +0900 Subject: [PATCH] fix(engine): correct the iterate-policy ceiling field docs to match the real precedence IterationState.maxIterations and costCeilingReached both documented an unconditional abandon ("regardless of self-review outcome"), but decideNextActionWithReason's own numbered precedence list puts the selfReview.kind === 'pass' branch (step 3) AHEAD of both ceilings (steps 4-5). So a clean predicted-gate pass at or past a ceiling still hands off, which the field docs said was impossible -- and the test suite had no case combining a pass with a reached ceiling, so the precedence between step 3 and steps 4-5 was unpinned. Correct both field docs to state the real contract (the ceiling abandons only when the self-review has not reached a clean pass; a clean pass still hands off, subject to autonomyLevel) and point at the precedence list as the one canonical statement. The precedence list, decideNextAction behaviour, reason strings and autonomyLevel handling are all unchanged -- this is a doc correction plus precedence-pinning tests. Closes #9997 --- .../src/miner/iterate-policy.ts | 12 +++++-- .../test/iterate-policy.test.ts | 32 +++++++++++++++++++ .../engine-iterate-policy-autonomy.test.ts | 14 ++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/loopover-engine/src/miner/iterate-policy.ts b/packages/loopover-engine/src/miner/iterate-policy.ts index 8c7aa3f665..282a04280a 100644 --- a/packages/loopover-engine/src/miner/iterate-policy.ts +++ b/packages/loopover-engine/src/miner/iterate-policy.ts @@ -71,14 +71,20 @@ export type IterationState = { /** 1-indexed count of iterations attempted so far, INCLUDING this one. */ iterationNumber: number; /** Hard ceiling enforced INSIDE this policy (#2333's own deliverable: not left to an external caller to - * remember to enforce). `iterationNumber >= maxIterations` abandons regardless of self-review outcome. */ + * remember to enforce). `iterationNumber >= maxIterations` abandons when the self-review has NOT reached a + * clean pass; a clean pass at or past the ceiling still hands off (subject to `autonomyLevel`), because the + * `pass` branch precedes both ceilings in `decideNextActionWithReason`'s numbered precedence list -- see + * that list for the one canonical statement of the ladder. */ maxIterations: number; /** True when the loop's own cumulative cost ceiling (e.g. total driver turns spent across every iteration of * this attempt so far, not just this one) has been reached or exceeded -- the loop mechanics' (#2333) OWN * "max-cost ceiling enforced inside the loop" deliverable, alongside the iteration ceiling above. This * policy has no notion of what "cost" means; the caller computes the boolean from whatever cost signal it - * tracks. Optional and defaults to not-reached, so `IterationState` fixtures that predate this field remain - * valid. */ + * tracks. Like the iteration ceiling, it abandons only when the self-review has NOT reached a clean pass; a + * clean pass at or past the ceiling still hands off (subject to `autonomyLevel`), because the `pass` branch + * precedes both ceilings in `decideNextActionWithReason`'s numbered precedence list -- see that list for the + * one canonical statement of the ladder. Optional and defaults to not-reached, so `IterationState` fixtures + * that predate this field remain valid. */ costCeilingReached?: boolean | undefined; selfReview: SelfReviewOutcome; /** The prior iteration's `fail` blocker codes, for the no-progress detector -- `null` when there is no prior diff --git a/packages/loopover-engine/test/iterate-policy.test.ts b/packages/loopover-engine/test/iterate-policy.test.ts index 401135f8d9..62a3295b09 100644 --- a/packages/loopover-engine/test/iterate-policy.test.ts +++ b/packages/loopover-engine/test/iterate-policy.test.ts @@ -249,3 +249,35 @@ test("autonomy #6560: rejectionSignaled and an ambiguous self-review still win o ); assert.equal(ambiguous.abandonReason, "self_review_ambiguous"); }); + +// #9997: the `maxIterations`/`costCeilingReached` field docs claimed the ceilings abandon "regardless of +// self-review outcome", but the precedence list puts the `pass` branch (step 3) AHEAD of both ceilings +// (steps 4-5), so a clean pass at or past a ceiling still hands off. These pin that precedence end to end so +// a future reordering fails the suite instead of silently discarding a passing attempt. +test("#9997: a clean pass AT the iteration ceiling still hands off (pass precedes the ceiling)", () => { + const decision = decideNextActionWithReason(passingState({ iterationNumber: 5, maxIterations: 5 })); + assert.equal(decision.action, "handoff"); + assert.equal(decision.abandonReason, undefined); +}); + +test("#9997: a clean pass with the cost ceiling reached still hands off", () => { + const decision = decideNextActionWithReason(passingState({ costCeilingReached: true })); + assert.equal(decision.action, "handoff"); + assert.equal(decision.abandonReason, undefined); +}); + +test("#9997: a clean pass with BOTH ceilings reached still hands off", () => { + const decision = decideNextActionWithReason(passingState({ iterationNumber: 5, maxIterations: 5, costCeilingReached: true })); + assert.equal(decision.action, "handoff"); + assert.equal(decision.abandonReason, undefined); +}); + +test("#9997: the two branches that DO win over a pass still do, pinning the ladder end to end", () => { + // rejectionSignaled (step 1) beats a pass... + const rejected = decideNextActionWithReason(passingState({ rejectionSignaled: true })); + assert.equal(rejected.abandonReason, "rejection_signaled"); + // ...and autonomy "observe" narrows the pass->handoff transition to an abandon, NOT max_iterations_reached, + // even at a reached ceiling (the pass branch is entered first, and observe abandons inside it). + const observed = decideNextActionWithReason(passingState({ autonomyLevel: "observe", iterationNumber: 5, maxIterations: 5 })); + assert.equal(observed.abandonReason, "autonomy_observe_only"); +}); diff --git a/test/unit/engine-iterate-policy-autonomy.test.ts b/test/unit/engine-iterate-policy-autonomy.test.ts index a24f24f472..5a2719de23 100644 --- a/test/unit/engine-iterate-policy-autonomy.test.ts +++ b/test/unit/engine-iterate-policy-autonomy.test.ts @@ -114,4 +114,18 @@ describe("autonomy never overrides the higher-precedence ladder steps (#6560)", expect(noProgress.abandonReason).toBe("no_progress"); }, ); + + it("#9997: a clean pass at or past a ceiling still hands off — the pass branch precedes both ceilings", () => { + // The field docs used to claim the ceilings abandon "regardless of self-review outcome"; the precedence + // list (step 3 before steps 4-5) is the real contract, so a clean pass wins over a reached ceiling. + for (const state of [ + passingState({ iterationNumber: 5, maxIterations: 5 }), + passingState({ costCeilingReached: true }), + passingState({ iterationNumber: 5, maxIterations: 5, costCeilingReached: true }), + ]) { + const decision = decideNextActionWithReason(state); + expect(decision.action).toBe("handoff"); + expect(decision.abandonReason).toBeUndefined(); + } + }); });