Skip to content

fix(card): a returning user's standing rule is the one they committed to, not this period's prescription (closes #645) - #656

Merged
atomchung merged 1 commit into
mainfrom
claude/issue-645-standing-rule-truth
Jul 31, 2026
Merged

fix(card): a returning user's standing rule is the one they committed to, not this period's prescription (closes #645)#656
atomchung merged 1 commit into
mainfrom
claude/issue-645-standing-rule-truth

Conversation

@atomchung

Copy link
Copy Markdown
Owner

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 checking

The 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.jsoncommitment declares dim (optional, alongside rule/metric_key/goal).
  • skills/fomo-kernel/engine/review.py:3197_candidate_rules builds every candidate row as {"id": ..., "dim": dim_id, "rule": ..., "metric_key": ..., "goal": "down"}, where dim_id = card_renderer.dimension_id(dim) (line 3182). The canonical id, not the legacy label.
  • skills/fomo-kernel/engine/review.py:5027_resolve_commitment takes chosen = dict(candidates[selected]) for a candidate choice, so dim survives verbatim into the stored commitment; line 5031 carries the agent-supplied choice.get("dim") for a custom one.
  • skills/fomo-kernel/engine/review.py:3906state_snapshot.prior_commitment is _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:2191state["rule_dim"] = actionable[0].get("dim") is the legacy label (部位 sizing), since prescribe writes 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_rules already calls. Both sides go through it: dimension_id(prior["dim"]) == dimension_id(state["rule_dim"]).

The task's warning about session.PKEY holds and is why nothing else was used: PKEY (session.py:36) maps metric_key into a problem namespace (oversize, avgdown_breach, concentration, …) which is not the rules copy namespace rule_dim resolves in, and concentration has three metric_keys feeding it, so it is not invertible. No PKEY→dim table was added.

Which reading I implemented, and why

Owner step 2 — rule_standing only 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. Quoting localized_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 on averaging_down would still be replaced by the catalog's averaging_down text). So the standing rule is now prior_commitment["rule"], quoted verbatim — the same treatment _reconciliation_lines already 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:

Prior commitment Dims Wrapper Quotes
none persisted rule_pending (#546, byte-stable) this period's prescription
persisted, no comparable dim rule_pending (fail-safe) this period's prescription
persisted agree rule_standing the committed rule
persisted differ rule_diverged (new) committed rule and prescription

Owner step 4 (fail safe rather than guess) applies to the second row: _slot_commitment (review.py:4953) drops dim from a condition-slot commitment deliberately, and a custom commitment need not carry one. rule_pending is 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_lines still opens the card with their standing rule and its then/now.

A missing state["rule_dim"] still falls through to the generic block_missing.rule line exactly as before (the interpolation guard became all(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_pending are untouched.

  • enThe standing rule is still "{standing}"; this period's leading issue is a different one, so "{recommendation}" stays a recommendation until you choose it.
  • zh-TW現行規矩仍是「{standing}」;這期最該處理的是另一件事,「{recommendation}」在你選定之前都只是建議。
  • zh-CN现行规矩仍是“{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 cp from a saved copy between runs (never git checkout), __pycache__ cleared each time. All five red; m1 is the owner's named target — reverting the quoted rule to state.rule_dim on a fixture whose prior commitment intentionally differs.

Mutation Result
m1 — revert to pre-#645 main (one wrapper decision, always quoting state.rule_dim) RED: the standing rule must be the one the user committed to, quoted verbatim
m2 — keep the divergence line but fill its standing slot from the prescription RED: same assertion
m3 — agree arm quotes the prescription instead of the committed rule RED: the continuity line quotes the committed rule, not the prescription that happens to share its dimension
m4 — drop the fail-safe, assert divergence on an uncomparable commitment RED: must not be presented as diverging either
m5 — drop the fail-safe the other way, assert continuity RED: must not be presented as unchanged

The 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-TW and en, plus a direct three-locale assertion that rule_diverged exists with both placeholders in zh-CN too. 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 carries dim: "position_sizing" to match rule_dim, which is what the narrowed contract requires for continuity.

Persona sweep

python3 tests/persona_sweep.py --baseline <origin/main checkout>/skills/fomo-kernel/engine
persona sweep: 156 bundles x 3 surfaces — PASS: all gates pass
NOTE  html vs baseline: 156/156 byte-identical

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:

  • 8 .second.preview bundles reach the agreement arm (ai_holder, momentum, panic_seller, pyramid × 2 locales), all position_sizing vs 部位 sizing — and in every one the persisted rule text is already byte-identical to the prescription, because the persona committed to candidate_0 on the same dimension. The agree arm is genuinely exercised and genuinely byte-stable.
  • 4 (day_trader, swing × 2 locales) carry a commitment with no rule_dim to compare against, so they keep the generic line they already had.
  • 0 reach the divergence arm. No mock persona diverges — which is exactly why the corpus never exposed this defect, and why the new test brings its own diverging fixture rather than relying on the sweep.

Tests

python3 tests/run_all.py
PASS: all 49 suites passed.

Run on this tree with pandas 3.0.3, yfinance and rich present, so nothing skipped its way to green.

Deliberately out of scope

  • No broader rule-memory redesign, per the ruling's closing line.
  • No dim backfill 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's rule_pending path is unchanged for a first review; nothing about the first-review experience moved.
  • The agree arm quotes the commitment verbatim and therefore does not translate it. That matches the existing product posture (_reconciliation_lines already 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

… 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>
@atomchung
atomchung merged commit d4c7d2e into main Jul 31, 2026
3 checks passed
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.

fix(card): the standing-rule line names this period's prescription, not the rule the user actually committed to

1 participant