From 556fab8ff19ec3c50065b167fee559191969cd13 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 10:52:46 +0800 Subject: [PATCH] fix(card): a returning user's standing rule is the one they committed 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 --- docs/output-contract.md | 3 +- skills/fomo-kernel/copy/en.json | 1 + skills/fomo-kernel/copy/zh-CN.json | 1 + skills/fomo-kernel/copy/zh-TW.json | 1 + skills/fomo-kernel/engine/card_renderer.py | 46 ++++++- tests/test_card_html.py | 135 +++++++++++++++++++-- 6 files changed, 170 insertions(+), 17 deletions(-) diff --git a/docs/output-contract.md b/docs/output-contract.md index 242775d5..fb28390d 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -187,7 +187,7 @@ never silent omission. | 3 strengths + hole | engine diagnosis present | one-line note | | 3 `[v]` amplify row | an `amplify` / `amplify_hypothesis` / `selection_inconclusive` prescription exists (strongest one only, in that order) | panel renders with the strength line alone | | 3 `[?]` pattern panel | an unjudged pattern fired (today: `sold_winner_early` tags on ≥1 instrument) **and** the review did not queue the answerable `exit_consistency` question about it | omit the panel — either nothing fired, or the question already carries the facts answerably | -| 4 next step | always — restates the standing rule only when a persisted `prior_commitment` is on record; a review with none (a first review, or any later one whose most recent finalize set none) states the recommendation as pending instead (#546) | — | +| 4 next step | always — restates the standing rule only when a persisted `prior_commitment` is on record (#546) **and** its own `dim` and this period's `rule_dim` are proven to name the same dimension (#645); a review with no persisted commitment (a first review, or any later one whose most recent finalize set none), and one whose commitment carries no comparable `dim`, state the recommendation as pending instead; a proven divergence gets its own line naming both | — | | 4 rule targets | the commitment carries a `dim` and that dimension has per-position facts (`risk_weights` over the cap; per-ticker averaging-down counts) | fall back to the aggregate `#248` grounding sentence; never leave the rule unanchored | | 4 trade-off line | the rule's dimension shrinks a position (`position_sizing` / `diversification`) **and** the card carries an `amplify` row | omit the line — an unconditional one is caveat noise | | snapshot route | suppresses history-performance modules by design (`card_renderer.py:1411`) | Block 1 = position-structure baseline only | @@ -338,3 +338,4 @@ LLM judge and dogfood verdicts: structure is mechanical, prose is judged. | 2026-07-24 | [#382](https://github.com/atomchung/fomo-kernel/issues/382) | **The #361 row above's "zero clipped subs" measurement did not hold at every width the layout supports.** #361 measured 520/760px; #382 (independently reproduced, then re-measured against the shipped #363 fix) found the pnl and payoff tiles' subs still clip at 320-390px once realized/unrealized or win/loss reach 5-6 digits — the README's own headline magnitudes (`+$138,058`, realized `$19,000`, unrealized `$119,058`). The alpha tile's own sub, the third tile #382 found clipping, was already fixed by #363's unrelated interval-placement change (confirmed by re-measuring 30 combinations against the shipped `9923be3`: never clips). What was missing was the mechanism §1 names: a rule that bounds the cell's height (R2) is not a rule that bounds its *content* to that height — nothing had measured whether the copy actually fits at the narrowest supported width, in both locales, at realistic magnitudes, so the fixtures behind every prior "zero clipped subs" claim used 3-digit amounts (`avg_win: 140`, `total_pnl: -300`) that were never close to the cap. Two independent fixes, gated at the point each stops being sufficient: `kpi.payoff_sub` drops its redundant leading qualifier in both locales (en "Average", zh-TW "average") — the tile's own label already says the figure is realized, so the word was doing no work — and fits both locales down to 320px unmodified, no change to the money format. `kpi.pnl_sub` needed more: even after dropping the equivalent leading word the way payoff did, a 5-6 digit realized/unrealized pair still does not fit two-up in the ~84px-wide sub at 320-360px no matter how the surrounding words are trimmed — the figures themselves are wider than the slot. A new `_money_compact()` (card_renderer.py) rounds to the nearest thousand only once `abs(value) >= 10000` (e.g. `+$119k`), leaving anything smaller — including a genuinely small account's real `+$200` — at full precision, so a small gain can never misprint as `+$0k`; only the HTML tile's `.sub` reads through it, `_performance_items`'s Markdown mirror of the same sentence keeps full `_display_money` precision, since Markdown has no line-clamp to work around. en's final template (`{realized}, {unrealized} unrealized`) goes one step further and drops the leading "realized" word too — the number stays, first and unlabeled, following the realized-first order convention `pnl_lines.total` already establishes elsewhere in this catalog — while "unrealized" keeps its explicit label, since that is the figure a caveat actually attaches to (the card's own lede: "almost all of it is held and never sold"). zh-TW did not need that second cut: its pnl_sub keeps both the "already realized" and "not yet realized" words at full sentence structure, unchanged, once their two figures compact to "k" — its shorter, denser glyphs afford the fuller sentence at the same compacted-figure width where en's could not. Measured (design-guidelines §4 step 7) with a real viewport resize, not `--window-size` (the same ~500px floor #363's row above recorded): both templates, at the values above, clear `scrollHeight<=clientHeight` **and** `scrollWidth<=clientWidth` (a single unspaced token can overflow the box sideways instead of wrapping, which a height-only check misses — caught one false pass here) at en 320/360/390px and zh-TW 320/360px, row spread 0 throughout — the narrowest width each locale was reported broken at. Known residual, out of the issue's own measured scope: zh-TW's pnl sub still clips at 300px, one step past the 320px this ruling and the issue both measured; closing it would cost zh-TW its own explicit "already realized" label, the same trade en already made, and nothing reported 300px as broken. Enforced by `test_pnl_and_payoff_tile_subs_fit_the_two_line_cap_at_narrow_widths` (design-guidelines §7), hardcoded literals across three magnitude cases (large, the pre-#382 small fixture, and a negative-unrealized sign check) so the pin cannot compare the catalog against itself. Persona-sweep byte-parity drifts on 92 of 156 rendered `.private.md` files; every drifting line is the two wording changes above with the underlying figures unchanged (verified by extracting and comparing every number on every changed line against baseline) — no mock persona's realized/unrealized reaches the $10,000 compaction threshold, so that path is exercised only by the new pinned test, not the sweep, the same gap #363's row above recorded for its own change. | | 2026-07-30 | [#623](https://github.com/atomchung/fomo-kernel/issues/623) | **A card sentence that names a reward must attach a way to claim it, or say plainly that there is none.** A sweep of `copy/*.json`'s `block_missing` found five strings sharing the shape #357's fifth recurrence was diagnosed from ("telling me to add it on the finished card leaves me nowhere to act on it"), in three classes needing three different fixes. **Invitations** (`snapshot_unlock`, `rule_structural`) now name the one answer this user's book cannot reach — "whether your selling costs you more than your buying" — instead of a parenthetical feature catalogue ("win rate, payoff ratio, averaging-down discipline, and more"). The governing rule is [#617](https://github.com/atomchung/fomo-kernel/issues/617)'s, stated once in `references/decision-framing.md` and routed to from `references/card-policy.md` rather than restated, because two surfaces disagreeing about what an invitation owes is its own defect; at most one per card, and none at all on a complete behavioral review, which is asserted as its own counterweight (a manufactured invitation is the same defect as a manufactured disclosure). **A disclosure with a product path** (`annualized_reconciliation`) names the path inline in the user's own terms — "Share your current holdings view and the difference can be settled" — beside the number it explains, never promoted into the closing block, which would put two invitations on one card. **The price-blocked strings** (`annualized_prices`, `vs_market_prices`) were not a copy problem at all: `flows/first-review.md` step 0 already requires recovery before a degraded card is delivered, so that sentence reaching a user is evidence a required step was skipped — indistinguishable, until now, from sources that genuinely publish nothing. `review_plan.input.price_feed.recovery` records which happened and the shared draft path refuses the unattempted one, clearable by doing the step or by declaring the dead end with `prepare --prices-unavailable ""`. Net line count on every affected card is unchanged: no Note was added, and no sentence became a second paragraph. The other 12 `block_missing` strings honestly report a non-computable result with no user action available and are deliberately untouched. | | 2026-07-31 | [#546](https://github.com/atomchung/fomo-kernel/issues/546) | **The pre-commitment preview claimed a standing rule "remains" on a review with no persisted commitment to restate.** Block 4's no-commitment branch (`card_renderer.py`'s `_next_block`) resolved `state.rule_dim` — this period's fresh prescription — into `block_missing.rule_standing` ("the standing rule remains: …") without checking whether a prior commitment was ever recorded. Because `cmd_preview` always drafts with `require_commitment=False`, this branch fires on every preview before the user has chosen, not only a first-ever review. Fixed by gating the wrapper on the same `review_plan.state_snapshot.prior_commitment.rule` predicate `_reconciliation_lines` already reads for #292: with a real prior commitment on record, the standing-rule copy is unchanged; with none, a new key (`block_missing.rule_pending`, en "No rule chosen yet this period; the recommendation is: …", each locale phrased natively rather than translated word-for-word, synchronized across `en`/`zh-TW`/`zh-CN`) states the same `{rule}` as a recommendation awaiting the user's choice, replacing rather than adding a sentence (net card line count unchanged). Both renderers share the branch through `_card_structure`, including Markdown's compact text-first scan (`_read_first_panels`, which quotes this same panel, so there is no second mirror to update). `tests/persona_sweep.py --baseline` against pre-fix `main` drifted exactly 12 of 156 rendered Markdown cards and 12 of 156 HTML cards — every one a `.first.preview` (or, for the `rotator` persona, a `.second.preview` whose own first review also set no commitment) switching from the standing to the pending wrapper, and nothing else. | +| 2026-07-31 | [#645](https://github.com/atomchung/fomo-kernel/issues/645) | **The standing-rule line named this period's prescription, not the rule the user actually committed to.** The row above gated *whether* Block 4 claims continuity and left the quoted rule resolved once, from `state.rule_dim`, inside both wrappers — so a returning user with a real persisted commitment 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`, this period's top prescription, and has no input from `prior_commitment`; the two diverge whenever the book's leading problem changes, which is the ordinary case across weeks. Fixed 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 existed 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 (`schemas/answers.schema.json`'s `commitment.dim`) — 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`, en "The standing rule is still \"{standing}\"; this period's leading issue is a different one, so \"{recommendation}\" stays a recommendation until you choose it.", each locale phrased natively, synchronized across `en`/`zh-TW`/`zh-CN`) — still one paragraph replacing one paragraph, so net card line count is unchanged. A commitment carrying no comparable `dim` fails safe onto the existing `rule_pending` wrapper rather than guessing: `_slot_commitment` drops the field from a condition slot deliberately and a custom rule need not carry one, and `session.PKEY` maps `metric_key` into a *problem* namespace (`oversize`, `concentration`, …) that is not the `rules` copy namespace `rule_dim` lives in, so inferring one from the other would be 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 (`_read_first_panels`). `tests/persona_sweep.py --baseline` against pre-fix `main` drifted **zero** of 156 rendered Markdown cards and zero of 156 HTML cards: 8 persona `.second.preview` bundles reach the agreement arm and in every one the persisted rule text is already byte-identical to the prescription (the commitment was `candidate_0` on the same dimension), 4 more carry a commitment with no `rule_dim` to compare against and keep the generic line they already had, and no mock persona reaches the divergence arm at all — which is why the corpus never exposed this and the acceptance test carries its own diverging fixture. | diff --git a/skills/fomo-kernel/copy/en.json b/skills/fomo-kernel/copy/en.json index d30738f4..42a989a5 100644 --- a/skills/fomo-kernel/copy/en.json +++ b/skills/fomo-kernel/copy/en.json @@ -43,6 +43,7 @@ "rule": "No new rule commitment this time; you can pick one at the next review.", "rule_standing": "No rule change this period; the standing rule remains: \"{rule}\"", "rule_pending": "No rule chosen yet this period; the recommendation is: \"{rule}\"", + "rule_diverged": "The standing rule is still \"{standing}\"; this period's leading issue is a different one, so \"{recommendation}\" stays a recommendation until you choose it.", "rule_skip": "You chose not to set a new commitment; the same baseline remains available next time.", "rule_snapshot": "This opening check keeps the structural baseline without forcing a commitment.", "rule_structural": "This is an opening structural check: it sets a baseline without forcing a commitment. Too few closed trades here to say yet whether your selling costs you more than your buying; a file carrying the matching sells would settle that one question.", diff --git a/skills/fomo-kernel/copy/zh-CN.json b/skills/fomo-kernel/copy/zh-CN.json index 52c568c2..da05602a 100644 --- a/skills/fomo-kernel/copy/zh-CN.json +++ b/skills/fomo-kernel/copy/zh-CN.json @@ -43,6 +43,7 @@ "rule": "这次没有新的规矩承诺;下次复盘可再选一条。", "rule_standing": "这期不改规矩;现行规矩维持:“{rule}”", "rule_pending": "这期还没选规矩;建议是:“{rule}”", + "rule_diverged": "现行规矩仍是“{standing}”;这期最该处理的是另一件事,“{recommendation}”在你选定之前都只是建议。", "rule_skip": "你这次选择不设新承诺;下次仍可用同一份基线对账。", "rule_snapshot": "这次开场检查先保留结构基线,不强迫设定承诺。", "rule_structural": "这次是开场结构检查:先立基线、不硬塞承诺。已平仓的交易还太少,还看不出你的卖出是不是比买进更伤你;补上对应的卖出纪录就能回答这一题。", diff --git a/skills/fomo-kernel/copy/zh-TW.json b/skills/fomo-kernel/copy/zh-TW.json index 7e0c58eb..743d9ec4 100644 --- a/skills/fomo-kernel/copy/zh-TW.json +++ b/skills/fomo-kernel/copy/zh-TW.json @@ -43,6 +43,7 @@ "rule": "這次沒有新的規矩承諾;下次復盤可再選一條。", "rule_standing": "這期不改規矩;現行規矩維持:「{rule}」", "rule_pending": "這期還沒選規矩;建議是:「{rule}」", + "rule_diverged": "現行規矩仍是「{standing}」;這期最該處理的是另一件事,「{recommendation}」在你選定之前都只是建議。", "rule_skip": "你這次選擇不設新承諾;下次仍可用同一份基線對帳。", "rule_snapshot": "這次開場檢查先保留結構基線,不強迫設定承諾。", "rule_structural": "這次是開場結構檢查:先立基線、不硬塞承諾。已平倉的交易還太少,還看不出你的賣出是不是比買進更傷你;補上對應的賣出紀錄就能回答這一題。", diff --git a/skills/fomo-kernel/engine/card_renderer.py b/skills/fomo-kernel/engine/card_renderer.py index d7033bbe..caabf3f9 100644 --- a/skills/fomo-kernel/engine/card_renderer.py +++ b/skills/fomo-kernel/engine/card_renderer.py @@ -3432,9 +3432,9 @@ def _next_block(bundle, copy, facts, state, snapshot): # resolution the committed private card and the public card already use. # A prescription without a resolvable dimension falls through to the # generic localized line rather than the untranslated literal. - standing = (localized_rule(state.get("rule_dim"), language, - cap=state.get("max_position_pct")) - if state.get("rule_dim") else None) + prescription = (localized_rule(state.get("rule_dim"), language, + cap=state.get("max_position_pct")) + if state.get("rule_dim") else None) # #546: this branch also fires at preview time on a user's very first # review — ``require_commitment=False`` nulls ``bundle["commitment"]`` # before the user has ever chosen a rule, and ``state["rule_dim"]`` is @@ -3444,13 +3444,47 @@ def _next_block(bundle, copy, facts, state, snapshot): # predicate ``_reconciliation_lines`` already reads for #292: a genuine # first review has nothing to restate and gets the pending-choice line # instead, naming the same recommendation as awaiting the user's choice. + # + # #645: that gate decided *whether* to claim continuity and left the + # quoted rule resolved from ``state["rule_dim"]`` — so a returning user + # read "the standing rule remains" followed by a rule they never chose. + # The prior commitment is the only record of what is standing, so it is + # what gets quoted, and continuity is claimed only when the two are + # *proven* to name the same dimension. ``commitment["dim"]`` is that + # proof and it already exists: ``review._candidate_rules`` stamps the + # canonical ``dimension_id`` on every candidate row and + # ``_resolve_commitment`` carries it into the stored commitment. Both + # sides go through ``dimension_id`` because the two namespaces differ — + # a stored commitment carries the canonical id, ``state["rule_dim"]`` + # the legacy label — and a custom commitment may supply either. + # + # A commitment with no ``dim`` cannot be compared without guessing: a + # condition slot drops the field deliberately (``_slot_commitment``) and + # a custom rule need not carry one. That case fails safe onto the same + # pending-choice line — the one wrapper here that asserts neither + # continuity nor divergence — rather than inferring a dimension from + # ``metric_key`` through a mapping no existing reader owns. prior_commitment = (((bundle.get("review_plan") or {}).get("state_snapshot") or {}) .get("prior_commitment") or {}) - standing_key = "rule_standing" if prior_commitment.get("rule") else "rule_pending" + standing_rule = prior_commitment.get("rule") + prior_dim = prior_commitment.get("dim") + comparable = bool(prior_dim) and bool(state.get("rule_dim")) + if not standing_rule: + # #546: nothing was ever persisted, so nothing is standing. + standing_key, fields = "rule_pending", {"rule": prescription} + elif not comparable: + # #645 fail-safe: something is standing, but not something this + # payload can compare. Claim neither continuity nor divergence. + standing_key, fields = "rule_pending", {"rule": prescription} + elif dimension_id(prior_dim) == dimension_id(state.get("rule_dim")): + standing_key, fields = "rule_standing", {"rule": standing_rule} + else: + standing_key, fields = "rule_diverged", {"standing": standing_rule, + "recommendation": prescription} text = None - if standing and missing.get(standing_key): + if all(fields.values()) and missing.get(standing_key): try: - text = missing[standing_key].format(rule=standing) + text = missing[standing_key].format(**fields) except (KeyError, IndexError, ValueError): text = None if not text: diff --git a/tests/test_card_html.py b/tests/test_card_html.py index ae91be26..be57d2e7 100644 --- a/tests/test_card_html.py +++ b/tests/test_card_html.py @@ -1973,10 +1973,12 @@ def _pre_commitment_bundle(language, rule_dim="部位 sizing", prior_commitment= Default (``prior_commitment=None``) is a genuine first review: nothing was ever persisted to ``review_plan.state_snapshot.prior_commitment``, so (#546) Block 4 must use the pending-choice wrapper rather than claim a - standing rule "remains". Pass a dict (e.g. ``{"rule": "..."}"``) to build - a returning user's preview instead, which keeps the standing-rule - continuity wrapper — the same ``prior_commitment.rule`` predicate - ``_reconciliation_lines`` already reads for #292.""" + standing rule "remains". Pass a dict to build a returning user's preview + instead — the same ``prior_commitment.rule`` predicate + ``_reconciliation_lines`` already reads for #292. #645: the continuity + wrapper additionally requires that dict to carry a ``dim`` naming the same + dimension as ``rule_dim``, so a returning fixture that wants the standing + copy passes e.g. ``{"rule": "...", "dim": "position_sizing"}``.""" bundle = copy.deepcopy(_session(language)["bundle"]) bundle["commitment"] = None answers = dict(bundle.get("answers") or {}) @@ -2054,11 +2056,13 @@ def test_first_review_preview_states_the_recommendation_as_pending_not_standing( first review, or any later one whose most recent finalize offered no candidate rule, confirmed against real personas by ``tests/persona_sweep.py --baseline``) must not claim otherwise; a - returning user with a real persisted commitment may still see the - standing-rule continuity copy. Both renderers share this branch through - ``_card_structure`` (Markdown's compact text-first scan quotes the same - panel via ``_read_first_panels``, and HTML's rule panel is scoped by the - ``sec keystep`` section, the card's last content section).""" + returning user with a real persisted commitment that agrees with this + period's prescription may still see the standing-rule continuity copy + (#645 narrowed "may" to that agreement — the test below owns it). Both + renderers share this branch through ``_card_structure`` (Markdown's + compact text-first scan quotes the same panel via ``_read_first_panels``, + and HTML's rule panel is scoped by the ``sec keystep`` section, the card's + last content section).""" continuity_markers = {"en": ("remain", "keep", "standing"), "zh-TW": ("維持",)} for language in ("zh-TW", "en"): markers = continuity_markers[language] @@ -2074,7 +2078,8 @@ def test_first_review_preview_states_the_recommendation_as_pending_not_standing( f"{language}: fresh-root HTML preview must not claim rule " \ f"continuity ({marker!r} found in Next step)" - returning = _pre_commitment_bundle(language, prior_commitment={"rule": "placeholder"}) + returning = _pre_commitment_bundle( + language, prior_commitment={"rule": "placeholder", "dim": "position_sizing"}) returning_md_block4 = _next_step_text(card_renderer.render_private(returning)) returning_html = card_renderer.render_html(returning) returning_html_tail = returning_html[returning_html.index('
'):] @@ -2086,6 +2091,115 @@ def test_first_review_preview_states_the_recommendation_as_pending_not_standing( f"the standing-rule continuity copy in HTML" +def test_a_returning_users_standing_rule_is_the_one_they_committed_to(): + """#645: the wrapper gate #546 added decided *whether* to claim continuity + and left the quoted rule resolved from ``state.rule_dim`` — this period's + fresh prescription — inside both wrappers. A returning user therefore read + "the standing rule remains" followed by a rule they never chose: both + halves of one sentence false at once, and the quoted half is the evidence + offered for the other. + + 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 + (``review._candidate_rules`` stamps the canonical ``dimension_id`` on every + candidate row and ``_resolve_commitment`` carries it into the stored + commitment; both sides normalize through ``dimension_id`` because a stored + commitment holds the canonical id and ``rule_dim`` the legacy label). + + Four cases, and the expectations are literal catalog sentences rather than + ``load_copy`` lookups — reading the expected value from the same source the + renderer reads makes the assertion a tautology that passes however the + wiring breaks: + + - **Diverge** — the standing rule is quoted verbatim, the prescription is + named as a recommendation, and neither sibling wrapper's claim survives. + - **Agree** — the continuity copy returns, still quoting the *committed* + text; the catalog prescription for that same dimension must not be + substituted for it, which is the narrow form of the same defect. + - **Not comparable** — a commitment with no ``dim`` (a condition slot drops + it deliberately; a custom rule need not carry one) fails safe onto the + pending-choice wrapper: neither continuity nor divergence is asserted. + - **First review** — unchanged, owned by the #546 test above. + + Markdown and HTML both, through the shared ``_card_structure`` path. + """ + prescription = { + "zh-TW": "單筆部位上限定死 20%;超過就減,不新增。", + "en": "Cap any single position at 20%. Trim if it goes over, and do not add.", + } + committed = "Hold any new position at least 30 days before selling" + + def surfaces(bundle): + html = card_renderer.render_html(bundle) + return (("Markdown", _next_step_text(card_renderer.render_private(bundle))), + ("HTML", html[html.index('
'):])) + + for language in ("zh-TW", "en"): + # The continuity claim itself, taken from the wrapper that makes it, so + # the ban cannot drift away from the sentence it bans. Deriving a + # *negative* expectation from the catalog is not the tautology the + # positive literals above avoid: it stays exactly as wrong as the copy. + wrappers = card_renderer.load_copy(language).get("block_missing") or {} + continuity_claim = wrappers["rule_standing"].split("{rule}")[0].strip("\"「“: ;;") + pending_claim = wrappers["rule_pending"].split("{rule}")[0].strip("\"「“: ;;") + + # rule_dim is position_sizing; the standing commitment is not. + diverged = _pre_commitment_bundle( + language, prior_commitment={"rule": committed, "dim": "holding_period"}) + for surface, text in surfaces(diverged): + assert committed in text, \ + f"{language}/{surface}: the standing rule must be the one the " \ + f"user committed to, quoted verbatim" + assert prescription[language] in text, \ + f"{language}/{surface}: the divergence line still names this " \ + f"period's prescription as a recommendation" + assert continuity_claim not in text, \ + f"{language}/{surface}: a diverging period must not claim that " \ + f"the rule did not change ({continuity_claim!r} found)" + assert pending_claim not in text, \ + f"{language}/{surface}: a returning user has a standing rule; " \ + f"the first-review wrapper must not absorb this case" + + # Agreement proven: the continuity copy returns, quoting what the user + # actually wrote rather than the catalog text for that same dimension. + agreed = _pre_commitment_bundle( + language, prior_commitment={"rule": committed, "dim": "position_sizing"}) + for surface, text in surfaces(agreed): + assert continuity_claim in text, \ + f"{language}/{surface}: an agreeing period keeps the continuity copy" + assert committed in text, \ + f"{language}/{surface}: the continuity line quotes the committed " \ + f"rule, not the prescription that happens to share its dimension" + assert prescription[language] not in text, \ + f"{language}/{surface}: this period's prescription must not be " \ + f"substituted for the rule the user committed to" + + # No comparable dimension: suppress the continuity claim rather than + # guess. The pending-choice wrapper asserts nothing about the past. + opaque = _pre_commitment_bundle( + language, prior_commitment={"rule": committed}) + for surface, text in surfaces(opaque): + assert continuity_claim not in text, \ + f"{language}/{surface}: a commitment the payload cannot compare " \ + f"must not be presented as unchanged" + assert committed not in text, \ + f"{language}/{surface}: a commitment the payload cannot compare " \ + f"must not be presented as diverging either" + assert prescription[language] in text, \ + f"{language}/{surface}: the fail-safe still states this " \ + f"period's recommendation" + + # Three locales move together (zh-CN has no persona run of its own, so the + # catalog is asserted directly): same key, same two placeholders. + for locale in ("en", "zh-TW", "zh-CN"): + line = (card_renderer.load_copy(locale).get("block_missing") or {}).get("rule_diverged") + assert isinstance(line, str) and line.strip(), \ + f"{locale}: block_missing.rule_diverged is missing" + assert "{standing}" in line and "{recommendation}" in line, \ + f"{locale}: the divergence line must carry both rules, not one" + + def test_standing_rule_placeholder_carries_the_user_cap_override(): """#324 applies to the placeholder too: the standing rule the card restates is the same canonical text a candidate would offer, so a valid standing @@ -2162,6 +2276,7 @@ def main(): test_public_committed_rule_carries_the_user_cap_override, test_standing_rule_placeholder_resolves_copy_not_the_v1_literal, test_first_review_preview_states_the_recommendation_as_pending_not_standing, + test_a_returning_users_standing_rule_is_the_one_they_committed_to, test_standing_rule_placeholder_carries_the_user_cap_override, test_account_gate_sentence_names_the_actual_blocker, test_account_gate_degrades_instead_of_rendering_blank,