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
1 change: 1 addition & 0 deletions docs/maintainer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ When multiple sessions are active:
| The cash anchor, and when it is asked for (#357, #507 knife 1) | `review.py`'s `_cash_anchor_status` is the single statement of whether a review has an accounting anchor — every other surface reads `review_plan.input.cash_anchor` rather than re-deriving it from `state["cash"]`. Its readers: `schemas/review-plan.schema.json` (`input.cash_anchor`, `additionalProperties: false`) ↔ `review.py` `cmd_add_cash` / `_cash_recompute_drift` ↔ `tools/ux_receipt.py`'s `CASH_OUTCOMES` and the outcome-dependent ordering gate ↔ `references/data-contract.md` (the one full statement of the beat) ↔ `references/ux-receipt.md` ↔ `references/interaction-delivery.md` ↔ `flows/first-review.md` and `flows/weekly-review.md` (each one short route-specific pointer — the two verbatim-identical preflight paragraphs #358 shipped were an unregistered hand mirror and are gone) ↔ `docs/qa-runbook.md` gate 3 ↔ `SKILL.md` "Fixed lifecycle" step 4 ↔ `tests/test_review_v2.py` + `tests/test_interaction_trajectory.py`. Four rules. **The engine states the gap in both directions**: with an anchor it already demanded a disclosure (`acct_perf_basis` in `required_honesty_keys`) and without one it demanded nothing at all, so the single condition the agent had to notice unprompted was the only one the plan never mentioned — five recurrences, three on real data. `not_applicable` is a positive claim with a `reason`, never an absent key, which is what makes the light-tier promise mechanical instead of depending on a flow paragraph asking before a tier check three lines later (#358 finding 1). **The timing is the opposite of `input.price_feed.request`'s, and `ask_after` says so**: a price gap is recovered before the user sees anything because every price-dependent number is degraded; a missing anchor degrades one pillar and leaves the rest byte-identical, so it is asked at the card beat where the user can see what answering buys (owner ruling 2026-07-30 against a `prepare` hard block; #507 principle 1). **The recompute proves it was the same review rather than asserting it**, and #665 is what that costs when the proof is aimed at the wrong thing. `add-cash` re-enters `_prepare_session` — never a second implementation of run-engine-ingest-plan — and the two halves below are what make that a recompute rather than a second review. **It reuses the frame instead of re-resolving it** (`args.amending_session` → `market_data.FROZEN_ENV` → `market_data.frame_frozen` → `_from_frozen_frame`, an *exact-request* same-day reuse that never reaches a provider on any outcome). The same-day cache was carrying that claim before and could not: `MarketDataBundle.covers` deliberately refuses to serve a request naming a symbol the stored bundle failed to price, so a transient outage gets its retry — and one unpriced symbol anywhere in the universe therefore made this command re-price the whole review at a second instant. **And the gate compares source facts, not derived objects.** `CASH_RECOMPUTE_SOURCE_FACTS` names them positively — the input files, the frozen frame, the recorded book, the diagnosis, the rules offered, the question identities already answered — because the predecessor asked the opposite question (exclude a hand-maintained set of cash-derived keys from `engine_state`/`engine_card`, then compare `state_snapshot` and `question_queue` **whole**) and that shape cannot be kept true: the re-entry's own downstream effects arrived as "drift", the refusal named four surfaces a cash anchor cannot reach, and a card-beat answer became unrecordable. Two rules for adding a row: it must be an input or a frozen observation rather than a projection of one, and it must be *measured* invariant under `--cash` on the same book (across five mock books an anchor moves `engine_state.cash`, `engine_card`'s `cash`/`acct_perf`/`honesty_ledger`, and `card_plan.required_honesty_keys`, and nothing else). The refusal names which of the gate's two verdicts it reached — "the facts moved", never merely that something did — and its counterpart `recompute.outcome: "anchor_propagated"` is emitted on success so the allowed outcome is stated too. A transaction file that grew is refused **before any write**, inside `_verify_and_ingest_frozen_trades` (`amending=True`, on the engine's own `fresh` dedup): a gate that only reads the recomputed plan has already appended the rows it is about to refuse. **No receipt outcome means "the agent decided not to ask"**: `skipped` was that value, and #357's fifth recurrence recorded it correctly and in order while the user was never offered the question, so the gate passed and the experience was identical to forgetting. |
| Behavior verdict store (#446 cut 1) | `horizon.py`'s existing `horizon_contradiction()` judgment ↔ `review.py` `_horizon_markers_all` (the untruncated computation; `_horizon_markers` is only its `HORIZON_MARKER_LIMIT`-bounded slice for the card's attention budget — the limit is a *display* cap, never a storage cap) ↔ `review.py` `_horizon_verdict_rows` (assembled only from what `_build_plan` already stamped into the plan — `engine_state`, `state_snapshot.thesis_states`, `state_snapshot.recent_exits` — never re-derived from disk or from a second, differently-sourced `active_cycle_ids`) ↔ `engine/verdicts.py` (`build_horizon_verdict`, the pure `replay()` contract, `RULE_PARAMS`/`RULE_PARAM_DIGEST`) ↔ `schemas/behavior-verdict.schema.json` (no `input` sub-object — every field is engine-assigned, the same reason `condition-check.schema.json` refuses `user_response` on its own envelope) ↔ `session.py`'s projection appending to `<root>/verdicts.jsonl`, the same firewall `conditions.py:66-72` gives `conditions.jsonl` — a verdict never enters `problems.check_rules`' mechanical reconciliation ↔ `coach.py`'s `DATA_FILES` ↔ `tests/test_verdicts.py` (+ `tests/test_review_v2.py::test_all_json_schemas_parse`, `tests/test_coach_data_cli.py`). A verdict recomputes to the same `outcome` from exactly its five frozen scalar fields — `replay(rule.id, rule.version, subject.value, observed.value, observed.closed)` — forever, including after the thesis row `subject.row_id` names is revised (a revision writes a new `event_id`; the old one, and every verdict pointing at it, is never rewritten) or a future profile label `profile_label_id` references is superseded (that field is a back-reference only, load-bearing for nothing; `replay`'s signature carries no parameter for a labels store at all). The one way replay could fail silently — a threshold edited without a version bump — is blocked mechanically: `RULE_PARAM_DIGEST` pins `RULE_PARAMS`'s hash as a literal (never recomputed live, which would make the check vacuous), and a companion test cross-checks `RULE_PARAMS`'s live version against `horizon.py`'s own `EXIT_FAST`/`HELD_LONG` constants, so an edit landing in either file is caught. |
| What `planned_entry` obliges the agent to collect, and what the honesty gate demands (#667) | `question_surface._INITIAL_THESIS_REQUIREMENTS["planned_entry"]` (the declared `answer_contract.requirements_by_choice`, read by the agent before it answers) and `review.py` `_validate_thesis_completeness`'s `inferred_planned` check (the enforced rule, read after the answer arrives) describe the same fact from two hand-written literals rather than one derivation — the same shape `new_evidence`'s `_ADD_REQUIREMENTS` / `thesis.build_decision_events` already accept for this reason, so this is not a new class of drift, only a new instance of one. If the validator's rule ever changes (a new required `thesis_updates` field, a different accepted `maturity` set), the declared requirement must change with it or the contract silently promises less than the gate demands again. `tests/test_question_surfaces.py::test_planned_entry_declares_its_thesis_capture_requirement_and_others_stay_empty` pins today's value; nothing mechanically ties the two sides together. |
| A non-recoverable `consider` refusal's usable_facts packet (#674) | `review.py`'s `ReviewError.payload_extra` is the general mechanism (any raiser may attach a small deterministic extra, merged into `main()`'s emitted JSON beside `status`/`error`); `_usable_facts_snapshot` is the one function that populates it today, reading `last_state.json` through the existing `_previous_state` helper and filtering it to `CONSIDER_REFUSAL_CONCENTRATION_KEYS` (whole-book concentration) and `CONSIDER_REFUSAL_COMMITMENT_KEYS` (the frozen rule) — never recomputing, per the owner's ruling that this leaf may translate and contrast already-computed facts but not act as a second consequence engine. `_consider_rows`'s ledger-basis block is the only call site, wrapping exactly the three genuinely non-recoverable refusals (structural corruption, an unscopable integrity warning, no usable holding left) and deliberately excluding both the recoverable single-holding refusal `consequence.consequence` raises later (#673, unaffected) and `_canonical_consider_before`'s own defensive-floor sizing refusal (a fourth, likely-unreachable site this leaf does not touch — a candidate for a future cut, not this one). Readers: `references/trade-consequence.md` "When the whole book refuses" (the agent-facing contract: lead with a decision tension, cite only payload facts, frame at least two user-nominated options, never name one to sell, fall back to `references/decision-framing.md` when the packet is `null`) ↔ `evals/run_episodes.py`'s `usable_facts_grounding` check, whose own `CONSIDER_REFUSAL_CONCENTRATION_KEYS` mirrors review.py's constant by value rather than by import (review.py pulls in the rest of the engine by bare sibling import, so it cannot be `_load_module`-loaded the way `conditions.py`/`card_renderer.py` are) — locked together by a drift test in `tests/test_episode_checkers.py` ↔ `evals/episodes/EP-010-non-recoverable-refusal-narrated-instead-of-framed.json` ↔ `tests/test_consider.py`'s own suite of fixtures forcing each refusal shape. There is no schema file for the CLI's bare `{status, error}` envelope today, so `usable_facts` is documented and tested but not JSON-schema-validated — consistent with every other `ReviewError` message, none of which have one either. |

Date product assumptions when using them for prioritization. Reconfirm assumptions that are several weeks old or contradicted by new evidence.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"id": "EP-010",
"title": "A non-recoverable consider refusal narrated as an error instead of framed as a decision",
"source": {
"kind": "back_converted_issue",
"refs": ["#674", "#673"],
"date": "2026-08-01"
},
"moment": "post_card",
"fixture": {
"trades": "skills/fomo-kernel/mock/sample_ai_holder.csv",
"route": "first_review",
"locale": "en"
},
"question": {
"asked_by": "user",
"kind": "free_form",
"text": "I want to add to my GOOGL position -- what could I sell to fund it?"
},
"checks": ["usable_facts_grounding"],
"answers": [
{
"id": "recorded_miss",
"expect": "fail",
"fails": ["usable_facts_grounding"],
"note": "#674's owning scene, de-identified: `consider` refused this book (every holding excluded, or the canonical basis itself would not build -- either is genuinely non-recoverable, unlike #673's now-recoverable single-holding exclusion), and the answer stopped at the refusal instead of framing the decision from what the last review already established. It also reaches for a number the engine computed somewhere else in the plan (position count) that was never part of THIS refusal's usable_facts packet -- a fact existing elsewhere is not licence to cite it here.",
"prose": "consider could not compute a consequence for this trade because the book has no usable holding left once its integrity warnings are excluded. Supply the missing transactions, or review your exit backlog before asking again. Your book currently holds 6 positions."
},
{
"id": "repaired",
"expect": "pass",
"note": "Leads with the tension (shrinking one concentrated bet to fund another), frames two of the user's own candidates without picking one, and cites only the concentration reading the last review actually froze -- max_pos_pct, top3_pct and max_sector_pct, at their conventional x100 percent. It says once, attached to the trade-off itself, that the computed consequence (weight, cash, rule check) is unavailable, rather than leaving that gap unstated or narrating it as a dead end.",
"prose": "Whichever position funds this GOOGL add, you would be shrinking a stake in a book where AI exposure already accounts for effectively all of it -- not manufacturing room, just moving it around. Your two largest AI-adjacent names anchor that: NVDA alone is about 34% of the book and the biggest reason your top three positions run to about 78% together, while your sector concentration sits around 51%. Neither trim escapes that fact, and nothing here says which is the better call -- consider could not price either sale against your book, so there is no computed post-trade weight, cash impact, or rule check behind this, only what the last review already established about where the concentration sits.",
"presented_options": [
{"maps_to": "NVDA", "label": "Trim NVDA",
"description": "Your largest position at about 34% of the book -- selling here accepts that its run has priced in enough to be comfortable holding less of it."},
{"maps_to": "AVGO", "label": "Trim AVGO",
"description": "Leaves NVDA's weight untouched -- a bet that AVGO specifically is the one you are least committed to, not that the concentration itself needs to shrink."}
]
}
]
}
3 changes: 2 additions & 1 deletion evals/episodes/episode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@
"surface_hygiene",
"locale_purity",
"condition_integrity",
"condition_check_integrity"
"condition_check_integrity",
"usable_facts_grounding"
]
},
"description": "The mechanical validators that bear on this episode. Every one is an invariant — none compares an answer against the wording the product ships today. A declared check with nothing to inspect fails the episode. An empty list is the honest `unmapped` state and requires blocked_on."
Expand Down
Loading
Loading