Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/output-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — falls back to restating the standing rule when the engine proposes no change | — |
| 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 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 |
Expand Down Expand Up @@ -337,3 +337,4 @@ LLM judge and dogfood verdicts: structure is mechanical, prose is judged.
| 2026-07-24 | [#363](https://github.com/atomchung/fomo-kernel/issues/363) | Owner ruling: the Block-1 items #363 left open (2026-07-23 rows above) are closed. **Cumulative, not time-weighted:** the holdings-only and account-level return sentences (new copy keys `account_perf.holdings_only`, `account_perf.account_base`) rename their return-methodology word from "time-weighted return" to "cumulative return" in both locales — wording only; the engine field (`acct_perf.hold_twr`/`acct_twr`) and its number are unchanged. The neighbouring `irr_annual` clause ("annualized return") is a different, already-annualized indicator and keeps its own wording, so the same word never names two different things on the same sentence. **The alpha interval moves into the alpha tile's own sub** (new copy key `alpha_interval.tile_sub`, a short form, e.g. en "95% interval +7% to +54%"), whenever `alpha_stat.ci95` is present and finite; a card where it is not falls back to exactly the pre-#363 sub (the not-yet-credible legend, or nothing) — derived from that one data property (design-guidelines §3.3), not enumerated per scenario, so a non-credible alpha never loses the footnote for its value's "*" suffix just because no interval could be built. Putting *both* the interval and the not-yet-credible legend in the sub was measured and rejected first: at 760px the combined text clips (the sub's `-webkit-line-clamp:2; overflow:hidden` silently truncates the tail) — a tile's sub has room for one figure, and once the interval is that figure, the legend has nowhere left to go in the same slot. What a tile's sub still cannot hold once it carries the interval — the not-yet-credible legend and the #313 negative-interval caveat — collapses into one self-anchored standalone line below the whole grid (new copy keys `alpha_interval.below_unreliable`, `alpha_interval.below_negative`): each half fires on its own pre-existing, independent trigger (`credible`; the interval's lower bound), not merged, and each now names annualized alpha as its own subject, since the line no longer sits directly beneath a lead clause that already named alpha for it. The line renders only when at least one of the two triggers; a credible card with a wholly-positive interval now prints no alpha prose line below the grid at all — the point of the change. Markdown is untouched: it has no tile grid, so the complete original sentence (headline + interval + caveat, `_alpha_interval_line`) renders there exactly as before, on every card, through the pre-existing `kpi_id`/`html_text` mechanism (#344) — including cards with no alpha tile this period (mixed-market, month-gated), which keep the full sentence on HTML too. **The account-gate sentence drops its system-status framing.** All six `account_gate` keys lose their opening "account-level return stays locked" clause and their closing "the holdings pillar above is unaffected" reassurance, in both locales, leaving only the action for the four blockers a user can act on (e.g. `no_cash_anchor`, en: "Add one current cash balance, and you'll see the account-level return with cash included."); `mixed_trade_footprint` and `chain_unavailable` have no such action and instead state the blocking fact plainly and briefly. Copy-only — no engine change, no language branch. **Block-1 bullets, Markdown only.** Every Block-1 indicator line now renders as a `- ` bullet on the Markdown surface, superseding the 2026-07-22 bullet-pass ruling above (which bulleted only the market-grouped vs-market lines and left the main number lines as plain paragraphs). HTML is unchanged — it keeps `<p>` paragraphs, since its KPI tile grid already carries the visual structure Markdown's prose-only surface does not have. **Two defect fixes flagged on this issue land with it.** `cash_drag` (`acct_twr − hold_twr`, a difference of two returns) now renders through a new `_pp()` helper as e.g. `-33pp`, never through `_pct()`'s `-33%` (§5: "% means absolute return, pp means excess"). The zh `alpha_interval.lead` template gains a missing word-boundary space between the Latin "alpha" and the Chinese "annualized" word that immediately follows it whenever `{scope}` is empty (the common, single-market case) — cosmetic, copy-only; the en template already had that boundary and is unaffected. Measured (design-guidelines §4 step 7): a synthetic full-performance fixture (large P&L, cash, and `alpha_stat.ci95` populated for both a credible and a non-credible alpha) rendered at 360/520/760px, both locales — 12/12 combinations hold **row spread 0 and the alpha sub never clips** (`scrollHeight` ≤ `clientHeight` in every case), measured via `Emulation.setDeviceMetricsOverride` over the DevTools protocol rather than `--window-size` (this Chrome build floors `--window-size` at ~500px, too coarse for the 360px case). Persona-sweep byte-parity (`tests/persona_sweep.py --baseline`) drifts on all 156 rendered `.private.md` files, and every drifting line is the new bullet prefix alone: none of the offline sweep's mock personas populate `acct_perf`/`alpha_beta_breakdown` (no live prices in that environment), so the wording/pp/spacing/tile changes above are exercised instead by `test_review_v2.py`'s and `test_card_html.py`'s fixture-built unit and pin tests, not by the sweep. Bonus, same PR: `_performance_items`'s `account_hold`/`account` sentences — already being rewritten for the rename above — move out of their `if en`/`else` inline branches into a new `account_perf` copy group (`holdings_only`, `account_base`, `annualized_suffix`, `cash_drag_suffix`, `terminator`, split at each optional clause so the existing irr_annual/cash_drag conditional-concatenation logic is unchanged), continuing #368 Phase 2's migration; `tests/test_copy_ratchet.py`'s `BASELINE` moves 50 → 48. |
| 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 "<sources checked>"`. 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. |
1 change: 1 addition & 0 deletions skills/fomo-kernel/copy/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"risks": "No behavioral diagnosis this period: the engine scored no dimension.",
"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_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.",
Expand Down
1 change: 1 addition & 0 deletions skills/fomo-kernel/copy/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"risks": "本期没有行为诊断:引擎没有可评分的维度。",
"rule": "这次没有新的规矩承诺;下次复盘可再选一条。",
"rule_standing": "这期不改规矩;现行规矩维持:“{rule}”",
"rule_pending": "这期还没选规矩;建议是:“{rule}”",
"rule_skip": "你这次选择不设新承诺;下次仍可用同一份基线对账。",
"rule_snapshot": "这次开场检查先保留结构基线,不强迫设定承诺。",
"rule_structural": "这次是开场结构检查:先立基线、不硬塞承诺。已平仓的交易还太少,还看不出你的卖出是不是比买进更伤你;补上对应的卖出纪录就能回答这一题。",
Expand Down
1 change: 1 addition & 0 deletions skills/fomo-kernel/copy/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"risks": "本期沒有行為診斷:引擎沒有可評分的維度。",
"rule": "這次沒有新的規矩承諾;下次復盤可再選一條。",
"rule_standing": "這期不改規矩;現行規矩維持:「{rule}」",
"rule_pending": "這期還沒選規矩;建議是:「{rule}」",
"rule_skip": "你這次選擇不設新承諾;下次仍可用同一份基線對帳。",
"rule_snapshot": "這次開場檢查先保留結構基線,不強迫設定承諾。",
"rule_structural": "這次是開場結構檢查:先立基線、不硬塞承諾。已平倉的交易還太少,還看不出你的賣出是不是比買進更傷你;補上對應的賣出紀錄就能回答這一題。",
Expand Down
16 changes: 14 additions & 2 deletions skills/fomo-kernel/engine/card_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3435,10 +3435,22 @@ def _next_block(bundle, copy, facts, state, snapshot):
standing = (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
# this period's fresh prescription, never a carried-forward answer. "The
# standing rule remains" is a continuity claim, true only when a prior
# commitment was actually persisted, so it is gated on the same
# 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.
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"
text = None
if standing and missing.get("rule_standing"):
if standing and missing.get(standing_key):
try:
text = missing["rule_standing"].format(rule=standing)
text = missing[standing_key].format(rule=standing)
except (KeyError, IndexError, ValueError):
text = None
if not text:
Expand Down
Loading
Loading