fix(card): a returning user's standing rule is the one they committed to, not this period's prescription (closes #645) - #656
Merged
Conversation
… to, not this period's prescription (closes #645) #546 gated *whether* Block 4's no-commitment branch claims rule continuity, and left the quoted rule resolved once -- from state.rule_dim, this period's fresh prescription -- inside both wrappers. A returning user with a real persisted commitment therefore read "No rule change this period; the standing rule remains: ..." followed by a rule they never chose, with the false quote standing as the evidence for the false claim. rule_dim is actionable[0].dim and has no input from prior_commitment, so the two diverge whenever the book's leading problem changes -- the ordinary case across weeks. Per the owner's 2026-07-31 ruling (option 2): the persisted commitment is the only record of what is standing, so it is what gets quoted, and continuity is claimed only when the commitment's own dim and state.rule_dim are proven to name the same dimension. That identity already exists and no new state was added -- review._candidate_rules stamps the canonical dimension_id on every candidate row and _resolve_commitment carries it into the stored commitment -- and both sides normalize through card_renderer.dimension_id because a stored commitment holds the canonical id while rule_dim holds the legacy label. A proven divergence replaces the continuity sentence with one new key (block_missing.rule_diverged, synchronized across en/zh-TW/zh-CN, each phrased natively), naming the standing rule and this period's recommendation in one line -- one paragraph for one paragraph, so net card line count is unchanged. A commitment carrying no comparable dim (a condition slot drops the field deliberately; a custom rule need not carry one) fails safe onto the existing rule_pending wrapper rather than inferring a dimension from metric_key through session.PKEY's problem namespace, which is the heuristic matcher the ruling forbids. #546's rule_pending path is byte-stable and a first review is unaffected. Both renderers share the branch through _card_structure, including Markdown's compact text-first scan. tests/persona_sweep.py --baseline against pre-fix main drifted zero of 156 Markdown cards and zero of 156 HTML cards: 8 persona .second.preview bundles reach the agreement arm with the persisted rule text already byte-identical to the prescription, 4 more have no rule_dim to compare against and keep the generic line, and no mock persona reaches the divergence arm at all -- which is why the corpus never exposed this and the new test carries its own diverging fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #645. Implements the owner's 2026-07-31 handoff comment (option 2), step for step.
Does the persisted commitment carry a comparable
dim? Yes — and it needed checkingThe issue asked this before option 2 could be costed. It does, on every commitment that has a dimension at all, and nothing new had to be stored:
skills/fomo-kernel/schemas/answers.schema.json—commitmentdeclaresdim(optional, alongsiderule/metric_key/goal).skills/fomo-kernel/engine/review.py:3197—_candidate_rulesbuilds every candidate row as{"id": ..., "dim": dim_id, "rule": ..., "metric_key": ..., "goal": "down"}, wheredim_id = card_renderer.dimension_id(dim)(line 3182). The canonical id, not the legacy label.skills/fomo-kernel/engine/review.py:5027—_resolve_commitmenttakeschosen = dict(candidates[selected])for a candidate choice, sodimsurvives verbatim into the stored commitment; line 5031 carries the agent-suppliedchoice.get("dim")for acustomone.skills/fomo-kernel/engine/review.py:3906—state_snapshot.prior_commitmentis_with_condition_line((previous or {}).get("commitment")), i.e. that same stored object, so the renderer reads it frozen from the plan and never reconstructs it.skills/fomo-kernel/engine/trade_recap.py:2191—state["rule_dim"] = actionable[0].get("dim")is the legacy label (部位 sizing), sinceprescribewrites those.So the two live in two namespaces, and
card_renderer.dimension_id(card_renderer.py:260) is the existing, deterministic normalizer between them — the same one_candidate_rulesalready calls. Both sides go through it:dimension_id(prior["dim"]) == dimension_id(state["rule_dim"]).The task's warning about
session.PKEYholds and is why nothing else was used:PKEY(session.py:36) mapsmetric_keyinto a problem namespace (oversize,avgdown_breach,concentration, …) which is not therulescopy namespacerule_dimresolves in, andconcentrationhas threemetric_keys feeding it, so it is not invertible. No PKEY→dim table was added.Which reading I implemented, and why
Owner step 2 —
rule_standingonly on proven agreement — and step 1 — read the commitment from the frozen snapshot, never reconstruct it — together mean the quoted rule has to change too, not only the wrapper choice. Quotinglocalized_rule(state["rule_dim"])inside the continuity wrapper is the reported defect; leaving it there for the agree case would just narrow the same defect to "agrees on dimension, still not the sentence the user wrote" (a custom rule onaveraging_downwould still be replaced by the catalog'saveraging_downtext). So the standing rule is nowprior_commitment["rule"], quoted verbatim — the same treatment_reconciliation_linesalready gives it in the card's opening (card_renderer.py:2347), which also makes the two places a card names the standing rule agree with each other for the first time.Four arms, in
card_renderer._next_block:rule_pending(#546, byte-stable)dimrule_pending(fail-safe)rule_standingrule_diverged(new)Owner step 4 (fail safe rather than guess) applies to the second row:
_slot_commitment(review.py:4953) dropsdimfrom a condition-slot commitment deliberately, and acustomcommitment need not carry one.rule_pendingis the one wrapper in this branch that asserts neither continuity nor divergence, so the claim is suppressed by falling back onto it — no new memory object, no new state field, no new key for that case. The user does not lose the fact:_reconciliation_linesstill opens the card with their standing rule and its then/now.A missing
state["rule_dim"]still falls through to the genericblock_missing.ruleline exactly as before (the interpolation guard becameall(fields.values())).The exact copy, all three locales
One new key,
block_missing.rule_diverged. Each phrased natively, not translated word-for-word;rule_standing/rule_pendingare untouched.The standing rule is still "{standing}"; this period's leading issue is a different one, so "{recommendation}" stays a recommendation until you choose it.現行規矩仍是「{standing}」;這期最該處理的是另一件事,「{recommendation}」在你選定之前都只是建議。现行规矩仍是“{standing}”;这期最该处理的是另一件事,“{recommendation}”在你选定之前都只是建议。It replaces the continuity sentence rather than adding one — one paragraph for one paragraph, so net card line count is unchanged and the standing no-explanatory-Note ruling is respected. Both renderers share it through
_card_structure; Markdown's compact text-first scan (_read_first_panels) quotes this same panel, so there is no second mirror.docs/output-contract.md's Block-4 row moved with it, plus a dated register row in the same register as #546's.Mutations run, and what reddened
Restored with
cpfrom a saved copy between runs (nevergit checkout),__pycache__cleared each time. All five red; m1 is the owner's named target — reverting the quoted rule tostate.rule_dimon a fixture whose prior commitment intentionally differs.main(one wrapper decision, always quotingstate.rule_dim)the standing rule must be the one the user committed to, quoted verbatimthe continuity line quotes the committed rule, not the prescription that happens to share its dimensionmust not be presented as diverging eithermust not be presented as unchangedThe test is
tests/test_card_html.py::test_a_returning_users_standing_rule_is_the_one_they_committed_to— diverge / agree / uncomparable, Markdown and HTML,zh-TWanden, plus a direct three-locale assertion thatrule_divergedexists with both placeholders inzh-CNtoo. It is registered in that file's explicit runner list (confirmed running, not silently absent: the suite goes 62 → 63 tests, and it reddens on demand above). Its banned-sentence expectations are derived from the wrapper catalog rather than pinned as literals, so the ban cannot drift away from the sentence it bans.#546's own test keeps its meaning: its returning-user fixture now carriesdim: "position_sizing"to matchrule_dim, which is what the narrowed contract requires for continuity.Persona sweep
Zero drift, Markdown and HTML (Markdown byte-parity is a gate, not a note, so PASS is the statement). Classifying every swept bundle against the new branch explains why, and it is the useful half:
.second.previewbundles reach the agreement arm (ai_holder,momentum,panic_seller,pyramid× 2 locales), allposition_sizingvs部位 sizing— and in every one the persisted rule text is already byte-identical to the prescription, because the persona committed tocandidate_0on the same dimension. The agree arm is genuinely exercised and genuinely byte-stable.day_trader,swing× 2 locales) carry a commitment with norule_dimto compare against, so they keep the generic line they already had.Tests
Run on this tree with
pandas 3.0.3,yfinanceandrichpresent, so nothing skipped its way to green.Deliberately out of scope
dimbackfill for existing condition-slot or dim-less custom commitments, and no change to_slot_commitment's deliberate drop — that would be the new memory object step 4 forbids. Those users get the fail-safe.#546'srule_pendingpath is unchanged for a first review; nothing about the first-review experience moved._reconciliation_linesalready prints the persisted rule as written, and a user's own words are never translated); a returning user who switched locales mid-history is the same pre-existing case that opener already has, not something this cut introduces.🤖 Generated with Claude Code