diff --git a/docs/output-contract.md b/docs/output-contract.md index aab1b90e..242775d5 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 — 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 | @@ -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 `
` 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 "