fix(consider): a trade that reduces an over-cap position is no longer delivered as a breach (refs #579, refs #674, refs #676) - #791
Conversation
… delivered as a breach (refs #579, refs #674, refs #676) Owner-live acceptance failed on 2026-08-02 because `already_over` — where the book stands — was read as the transaction's verdict. A sell taking a position from 80% to 75% against a self-authored 20% cap emitted `already_over + worsens:false`, which is arithmetically correct, and the answer built from it told the owner the sell broke their own rule. `consequence.classify_rule_effect` is now the single derivation over before, after and the line, and `rule_collision` stamps `rule_effect` on every row. Two of the six transitions were not expressible in the old pair at all: `_worsened` is a strict `>`, so an improvement and a stall are the same `false`, and `clear` cannot say whether the line was crossed before — which is why a prompt-only cut could not have held whatever it said. Every decision-facing consumer reads the effect and nothing re-derives it: the challenge's `must_state` value and anchor, the new product-safe `rule_effects` projection, `required_coverage`, and the provenance gate, where a claim must now declare the frozen transition as a six-way assertion instead of a two-way boolean. `state`/`worsens` stay on the stored row as machine diagnostics for QA and replay, and a row whose two vocabularies contradict each other is refused by both readers through one table. The same cut closes the other half of that walk on the same surface: a delivered claim that narrates a snake_case engine token is refused, reusing the question surface's own rule (#305) on the answer surface that lacked it. `rule_effect`/`limit`/`limit_source` are optional on a stored row, so a row recorded before them still validates, still speaks, and still has its direction checked through the pre-#579 `worsens` arm. Twelve mutations — status-only interpretation restored, the improving effect losing its prohibition, the projection leaking diagnostics, a reversed transition accepted, raw diagnostic narration allowed, the legacy row going silent — each turned its named suite red. Full offline suite green on Python 3.11, 3.12 and 3.14. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified, and one of the two "needs an owner ruling" items is already ruled(Reposted — the first version of this comment lost four identifiers to shell substitution. Content below is the intended text.) The mechanical conclusion checks out. Combined with current Ruling 2 —
|
Closes the deterministic half of #579 and the process-leakage half of #674/#676 in one cut, because both failed on the same
considerchallenge/projection surface (#713's scope guard forbids competing PRs over one user-visible outcome).Branch cut from
main@a33f315. Not merged — for review.(a) What the failed surface actually emitted
Reproduced mechanically on a fictional book: a position at 80% of the book against a self-authored 20% cap, and a sell that takes it to 75%.
The engine emitted
already_over + worsens:false; the direction arithmetic was correct._worsened(0.80, 0.75)isfalse, as it should be. The defect was entirely in what the decision-facing surface presented: the absolute state was the headlinevalueand the transaction's direction was a nullable boolean buried insidedetail.Two further mechanical findings turn this from "the agent composed it badly" into "the payload could not express it":
already_over + worsens:falseis emitted for bothimproved_but_still_overandunchanged_existing_breach—_worsenedis a strict>, so "reduced" and "did not move" are byte-identical payloads.clearis emitted for bothcompliantandresolved_existing_breach, andclearwas not in_SPEAKING_STATES, so a trade that fully resolved a self-authored breach said nothing at all.Two of the six required transitions were therefore not derivable from the frozen fields, whatever the prompt said. That is why PR #608's reference-only cut could not have held.
(b)
rule_effectand the six transitionsconsequence.classify_rule_effect(before_reading, after_reading, limit)is the single derivation.limitis a strict upper bound — the same>dim_size/dim_diversifyalready use, so the effect and the state beside it cannot disagree about which side of the line a reading is on — and movement uses_worsened's own epsilon, so one tolerance decides both fields.new_breachworsened_existing_breachimproved_but_still_overunchanged_existing_breachresolved_existing_breachcompliantunjudged/unmappedunmappedexists to preventEach collision branch classifies from the readings that branch judged: the premise ticker's own weight where causality was decided on it, the book's
max_pctwhere it was not, this metric's own reading and line for the concentration trio, and a count of breaching average-down events against a limit of zero foravgdown_count(which is why that row carries nolimit). Classifying every branch off one reading would produce the contradiction the pairing exists to avoid.Two rows in the regression matrix are where
stateandrule_effectlegitimately differ, and they are the proof the derivation is not a relabelled state:statecalls thatalready_overeven though the book was under the line before it; the effect saysnew_breach, which is the true reading.clearcovers bothcompliantandresolved_existing_breach.A mutation that derives the effect from the state fails on exactly those rows.
limitandlimit_sourceare new on the row for one reason: the correct sentence is "reduces the position from 80% to 75%, but it remains above your 20% rule", and nothing in the payload carried the 20%.limit_sourceisuser_caponly whentrade_recap.valid_position_capaccepted the user's own override — every other threshold is an engine constant, and an answer that calls a product default "your rule" is a claim the user never made.(c) Which consumers changed
evaluation_challengespeaking setstate in (would_breach, already_over, unjudged, unmapped)compliant— which is what makesresolved_existing_breachexpressible at allmust_staterule-collision entryvalue: "already_over", anchor.statevalue: <effect>, anchor.rule_effect; state and worsens demoted intodetailevaluation_challenge(new)rule_effects, the product-safe projectionanswer_provenance.required_coverage_COVERED_STATES_COVERED_EFFECTS— the same four rows, keyed on what the trade didanswer_provenancecase 7worsens(2-way)rule_effect(6-way);worsenssurvives as the legacy armanswer_provenance(new)opening_valueis on #579's list and is untouched here:card_renderer.build_opening_valueis the review-card lane and reads the honesty ledger, notrule_collisions. When it does project rule material it readsrule_effect; the mirrored-surfaces row records that.The gate's width is deliberately unchanged:
_COVERED_EFFECTSis exactly the old(would_breach, already_over)set re-expressed as transitions, so no case that was submittable becomes unsubmittable and vice versa.resolved_existing_breachis stated but not required — silence about good news cannot hide a risk, and the floor is allowed to be wider than the gate.(d) Diagnostics vs user projection (#713 step 4)
stateandworsensstay complete on the stored evaluation row, which is what QA and a later replay read. Therule_effectsblock carries the effect, the line, whose line it is, and semantic slots — nostate, noworsens, no retrieval/recovery/validator chronology. A schema-leveladditionalProperties: falseplus a test on the real emission enforce that.The slots are meanings, not wording, and the negative half is the load-bearing one:
{"rule_id": "r1", "text": "Cap any single position at 20%.", "effect": "improved_but_still_over", "limit": 0.20, "limit_source": "user_cap", "must_convey": ["over_before_this_trade", "moved_toward_the_line", "over_after"], "must_not_convey": ["crossed_by_this_trade", "moved_further_over"]}It is a fixed six-row table over one route's rule effect — not a generic response framework, not a second rules engine.
The #674/#676 half on the same surface is mechanical rather than prose: a claim whose text contains a snake_case token out of the engine's own enums (
already_over,improved_but_still_over,cost_basis,partial_book,unusable_shares, …) is refused before the row is written. The mechanism and the snake_case-only discipline arequestion_surface._assert_no_internal_leak's (#305), applied to the answer surface that never had it, and the vocabulary is read offconsequence's constants so a new disclosure key is covered the day it is added.clear/unjudged/unmappedare single ordinary words and are excluded by that same rule, not by an exception — "computed on cost basis" passes,cost_basisdoes not.Where the two vocabularies can contradict each other, the row is refused.
consequence.effect_disagrees_with_stateis one table read by both the challenge surface and the provenance gate, so neither believes whichever field it read first. That is #579's "validators must reject disagreement with the recomputed effect".Compatibility
rule_effect,limitandlimit_sourceare optional ontrade-evaluation.schema.json's collision row, so a row already on a user'strade_evaluations.jsonlstill validates. A legacy row also still speaks (_LEGACY_SPEAKING_STATES), still obliges a claim (_LEGACY_COVERED_STATES), and still has its direction checked through theworsensarm — the floor and the gate stay one list on an old row too, because a challenge that dropped what the gate still requires would make every case on such a book unsubmittable.test_a_legacy_row_still_states_what_the_gate_still_requiresandtest_a_legacy_row_still_has_its_direction_checkedpin both halves.Evaluation ids move for a book with tracked rules, because
_evaluation_idseeds onrule_collisionsand the answer genuinely gained a computed fact — which is that function's own stated contract ("any input that changes the answer necessarily changes what gets hashed"). Existing rows keep their stored ids,--resolveand reconciliation are unaffected, and retries remain idempotent within this version. The pinned-seed regression is unchanged.(e) Mutation verification
cpbackup and restore (nevergit checkout),__pycache__cleared before every run, verdict read from the exit code (never grepped forFAIL), baseline and post-restore both re-verified green. 12/12 turned their named suite red; no survivors.classify_rule_effectreads only "is it over now" (status-only)must_statestates the absolute state againimproved_but_still_overlosescrossed_by_this_tradefrommust_not_conveystate/worsens(raw diagnostic narration)Assertion discipline followed deliberately: each of the six transitions is asserted on its own matrix row and separately by name in a negatively-phrased test, so a mutation returning a plausible-but-wrong member of the vocabulary is caught rather than passing a table lookup; no assertion sits inside a branch a mutation can delete; and the
improved_but_still_overfixture is checked to produce the samestate/worsenspayload as theunchanged_existing_breachone, so the fixture can tell right from wrong instead of passing by coincidence.(f) Tests
python3 tests/run_all.py— 55/55 suites on 3.11.9, 3.12.4 and 3.14.5 (localpython3). CI runs 3.11/3.12.tests/test_consequence.pysection H (transition matrix + endpoints + attribution + disagreement),tests/test_evaluation_challenge.pysection 6 (projection, slots, diagnostics separation, legacy),tests/test_answer_provenance.pysection 7/7b (reversed and omitted transition, leak, legacy arm),tests/test_consider.py(the owner scenario end-to-end on the real CLI, plus both refusals).references/trade-consequence.md↔qa/SKILL.md's challenge example ↔evals/trade_answers/TA-001↔tests/test_interaction_trajectory.py's synthetic challenge ↔ a newdocs/maintainer-guide.mdrow.Parallel-session note
PR #789 also touches
consequence.py,review.py,references/trade-consequence.md,tests/test_consequence.pyandtests/test_consider.py, but in the cash-flow/anchor region (portfolio_state,consequence) rather than the rule-collision region. No semantic overlap; whichever lands second should still be re-run merged rather than trusting per-PR CI.Not in this PR
Owner-live acceptance. #713 step 7 (freeze a new exact
main@<sha>and restart the three-journey count from zero) is the owner's, after this merges. This is steps 1–6.🤖 Generated with Claude Code