From eb6b3b308b659bcef4e07efe7903c23a98942b5d Mon Sep 17 00:00:00 2001 From: test Date: Sat, 1 Aug 2026 01:39:36 +0800 Subject: [PATCH] fix(questions): planned_entry declares and collects its thesis capture in the same exchange (closes #667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initial_thesis`'s `planned_entry` choice declared an empty `requirements_by_choice`, so an agent following the emitted `question_opportunity.answer_contract` literally could submit the choice and still hit `_validate_thesis_completeness`'s refusal — a dead end the contract never predicted. On first_review this state is structurally unreachable honestly: every initial_thesis question exists because the cycle has no pre-existing captured thesis, so there was no earlier record to satisfy the gate with, and inventing one or spending an unbudgeted follow-up are both forbidden. Per the maintainer disposition: keep `_validate_thesis_completeness` strict (the defect is the contract, not the validator) and make `planned_entry` name what the gate will demand, reusing `new_evidence`'s existing shape (declared required field paths + non-empty requirement text) rather than inventing a parallel capture object. The capture lands in the same `thesis_updates` row every `missing_thesis_positions` entry already carries, submitted in the same `answers.json` as the `planned_entry` choice. - skills/fomo-kernel/engine/question_surface.py: `_INITIAL_THESIS_REQUIREMENTS["planned_entry"]` now names `thesis_updates.why`/`.exit_trigger`/`.maturity`, and `_requirement_copy` gives it bilingual agent-facing requirement text. - skills/fomo-kernel/engine/review.py: cross-reference comment on `_validate_thesis_completeness` pointing at the contract declaration it must stay in sync with; no behavior change. - skills/fomo-kernel/flows/first-review.md: step 3 states the one exception to the default inferred-thesis placeholder. - docs/maintainer-guide.md: new mirrored-surfaces row for the two hand-written literals (declared contract vs. enforced rule) that must stay in sync, the same drift class `new_evidence` already carries. - tests/test_question_surfaces.py: contract-drift unit test pinning the declared requirement (and that every other choice stays unrequired). - tests/test_review_v2.py: first-review fixture that reads the requirement off the real plan, supplies exactly what it names, and reaches preview; the same fixture also confirms the gate still refuses a planned_entry cycle left at maturity:"inferred". - tests/test_preview_gate.py: docstring fix — it named this exact defect as a still-current fact, which this change makes false. Co-Authored-By: Claude Fable 5 --- docs/maintainer-guide.md | 1 + skills/fomo-kernel/engine/question_surface.py | 22 +++++- skills/fomo-kernel/engine/review.py | 6 ++ skills/fomo-kernel/flows/first-review.md | 2 + tests/test_preview_gate.py | 12 +-- tests/test_question_surfaces.py | 27 +++++++ tests/test_review_v2.py | 78 +++++++++++++++++++ 7 files changed, 141 insertions(+), 7 deletions(-) diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 4d53d8e4..3d57b27c 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -156,6 +156,7 @@ When multiple sessions are active: | The price recovery kit, and the two lanes that degrade in opposite directions (#629) | `review._price_feed_status` is the single builder of the kit — `provenance`, `request`, the `recovery` block above, and the instruction. Two routes call it and neither restates it: `cmd_prepare` (through the plan's `input.price_feed`) and `review._consider_price_feed_status`, which supplies only this route's own inputs and delegates. Only two things differ between them, and both are parameters rather than a second sentence: `command` (which subcommand takes the envelope back) and `request_path` (where the caller finds the manifest in the payload it is holding). `review._declared_prices_unavailable` is likewise the single reader of `--prices-unavailable`, now accepted by both commands, so one cannot accept a declaration the other refuses. Readers: `references/price-feed.md` ("Two lanes, two opposite rules" — the single statement of the asymmetry) ↔ `references/freeform-answers.md` (the agent-facing bound, and why the lookup is not the multi-tool production rule 8 forbids) ↔ `references/trade-consequence.md` ("When the engine could not price the book") ↔ `SKILL.md` rule 8 / `AGENTS.md` boundary 8 ↔ `tests/test_consider.py` section O. **The asymmetry is the point and must stay written down**: the identical `--prices-unavailable` declaration delivers the degraded card on the review lane and *refuses* on `consider`. A retrospective card's cost weights describe what the user actually paid; a forward concentration decision computed on cost describes a book that no longer exists — on `mock/sample_momentum.csv` the largest position moves more than thirteen points and the second and third by size invert. `test_o_the_recovery_kit_is_the_prepare_builder_rather_than_a_second_one` is the anti-fork gate: it rewrites `prepare`'s own sentence with those two substitutions and requires `consider`'s to start with it, so a hand-written second manifest reddens. Deliberately **not** gated: whether a host actually performs the lookup, the same instruction-only footing `docs/development-guide.md` §4 admits for the recommendation ban. | | 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 `/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. | Date product assumptions when using them for prioritization. Reconfirm assumptions that are several weeks old or contradicted by new evidence. diff --git a/skills/fomo-kernel/engine/question_surface.py b/skills/fomo-kernel/engine/question_surface.py index 8c17c592..be3837e8 100644 --- a/skills/fomo-kernel/engine/question_surface.py +++ b/skills/fomo-kernel/engine/question_surface.py @@ -58,8 +58,19 @@ "external_constraint": [], "skip": [], } +# #667: `planned_entry` asserts a real thesis existed at entry, and +# `review.py` `_validate_thesis_completeness` refuses to record that assertion +# against a silently-inferred update (#291). Before this, the declared +# contract promised nothing further for this choice, so an agent that +# collected exactly what the contract named still landed in that refusal -- +# a dead end the contract never predicted. The capture itself is not a new +# object: it is the same `thesis_updates` row every `missing_thesis_positions` +# entry already carries (`flows/first-review.md` step 3), named here so the +# agent knows to make it a *real* capture -- non-placeholder `why`/ +# `exit_trigger` and a `maturity` other than `inferred` -- in this same +# exchange, rather than the default inferred placeholder. _INITIAL_THESIS_REQUIREMENTS = { - "planned_entry": [], + "planned_entry": ["thesis_updates.why", "thesis_updates.exit_trigger", "thesis_updates.maturity"], "momentum_follow": [], "external_call": [], "no_clear_thesis": [], @@ -111,7 +122,14 @@ def _requirement_copy(language, kind, choices): "skip": "", } if kind == "initial_thesis": - return {key: "" for key in _INITIAL_THESIS_REQUIREMENTS} + return { + "planned_entry": ("Requires your real reason for entering and what would prove " + "it wrong, captured now rather than filled in as a placeholder." + if en else + "必須提供你進場時真正的理由,以及什麼情況會證明這個想法是錯的," + "現在就講清楚,不能只是先佔位的推測。"), + "momentum_follow": "", "external_call": "", "no_clear_thesis": "", "skip": "", + } if kind == "condition_crossing": # One map covers both vocabularies, keyed off this question's own # canonical choices — a numeric surface never advertises `yes`. diff --git a/skills/fomo-kernel/engine/review.py b/skills/fomo-kernel/engine/review.py index e188d843..1f500690 100644 --- a/skills/fomo-kernel/engine/review.py +++ b/skills/fomo-kernel/engine/review.py @@ -4384,6 +4384,12 @@ def _validate_thesis_completeness(plan, answers): # it — that cycle must carry a real capture (maturity draft/testable). Every # other answer (momentum_follow/external_call/no_clear_thesis/skip) keeps # inferred legal, so different answers produce different downstream state. + # #667: this rule is enforced here, strictly, and unchanged by that fix — the + # defect it found was that `planned_entry`'s own declared answer contract + # (`question_surface._INITIAL_THESIS_REQUIREMENTS["planned_entry"]`) named + # none of this, so an agent could satisfy the contract and still land in the + # refusal below. Keep the two in sync (maintainer-guide.md mirrored-surfaces + # table) rather than loosening this check. answer_choice = {row.get("question_id"): row.get("choice") for row in (answers.get("answers") or []) if isinstance(row, dict)} planned_entry_cycles = {q.get("cycle_id") for q in plan.get("question_queue") or [] diff --git a/skills/fomo-kernel/flows/first-review.md b/skills/fomo-kernel/flows/first-review.md index d20a7991..c9bf627c 100644 --- a/skills/fomo-kernel/flows/first-review.md +++ b/skills/fomo-kernel/flows/first-review.md @@ -26,6 +26,8 @@ When the `exit_trigger` names something checkable, send it as a condition slot o The engine prefills `ticker`, `maturity:"inferred"`, and provenance. Never present an inferred thesis as user-confirmed. Optional fields (`stop`, `target_size`, `driver`, and the inference-only accumulation fields in `authoring_contract`) follow one rule: fill one only when the conversation contains a real signal, leave it null otherwise, and never add a question for it. An invented source or emotion is worse than a missing one, and these cannot be backfilled later. Upgrade `_inferred` to false or `source_confidence` to `"confirmed"` only when the user volunteers it. +One choice does not tolerate the inferred default: `planned_entry` asserts a real thesis existed at entry, and its own `question_opportunity.answer_contract.requirements_by_choice` names this same row's `why`, `exit_trigger`, and `maturity` as required (#667). When the cycle's `initial_thesis` answer is `planned_entry`, collect the user's real reason and real falsifier in that same exchange and submit `maturity:"testable"` or `"draft"` for it — `preview` refuses a `planned_entry` cycle left at `maturity:"inferred"`. Every other answer (`momentum_follow`, `external_call`, `no_clear_thesis`, `skip`) keeps the inferred placeholder legal. + **4. Write the narrative, qualitative and digit-free.** `headline` and `mirror` are required; `counterfactual` and `strength` are optional. Cover every `card_plan.required_honesty_keys` entry with one sentence in `narrative.honesty` (wording guidance in `card-spec.md`). Frame coverage gaps the engine never asked about as neutral facts per `authoring_contract.narrative.unprompted_gaps` — they are not the central judgment of the card. Optionally add `synthesis`: a closing block after the rule that connects facts sitting in separate sections into one point of view. If it only restates a number already on the card, leave it out. Do not write `rule_rationale`; the engine derives the rule's trade-off sentence itself and the field survives only so older sessions stay readable. **5. Run `preview`.** If validation fails, fix the artifact the error names rather than working around the gate. diff --git a/tests/test_preview_gate.py b/tests/test_preview_gate.py index eda682a7..2c8f03d6 100644 --- a/tests/test_preview_gate.py +++ b/tests/test_preview_gate.py @@ -55,11 +55,13 @@ def _choice(question): The two routes this suite drives queue different kinds -- `initial_thesis` on the CSV first review, `headline_motive` on a test drive -- so the answer cannot be one literal. It is an explicit map rather than a read of the - plan's own `requirements_by_choice`, because that contract describes a - choice's own follow-up fields and not the cross-field rules layered on top - of it: `planned_entry` advertises no requirements and still needs a - captured thesis of maturity draft or testable. This suite owns the preview - precondition; the question contracts belong to the suites that own them. + plan's own `requirements_by_choice`, because a choice whose declared + requirement resolves to a `thesis_updates` capture (`planned_entry`, #667) + would still need this suite to construct one, and this suite is about + the preview/finalize precondition, not thesis-capture mechanics. + `no_clear_thesis` keeps every question here payload-free by construction; + the question contracts themselves belong to the suites that own them + (`test_question_surfaces.py`, `test_review_v2.py`). """ return { "initial_thesis": "no_clear_thesis", diff --git a/tests/test_question_surfaces.py b/tests/test_question_surfaces.py index 65d03f94..152c7580 100644 --- a/tests/test_question_surfaces.py +++ b/tests/test_question_surfaces.py @@ -155,6 +155,33 @@ def test_exit_consistency_opportunity_is_engine_owned_and_grounded(): assert review_engine._exit_consistency_question({"ticker_diagnosis": []}, "en") is None +def test_planned_entry_declares_its_thesis_capture_requirement_and_others_stay_empty(): + """#667: `planned_entry` asserts a real thesis existed at entry, and + `review.py` `_validate_thesis_completeness` refuses to record that + assertion against a silently-inferred update (#291). Before this fix, the + declared contract promised nothing further for this choice -- an agent + that satisfied the *declared* contract could still land in that refusal, a + dead end the contract never predicted. The fix names the requirement here, + reusing `new_evidence`'s existing shape (a list of required field paths + plus non-empty requirement text) rather than inventing a parallel capture + object -- the paths resolve into the same `thesis_updates` row every + missing-thesis position already carries (`flows/first-review.md` step 3). + """ + question = review_engine._initial_thesis_question( + "AAA", {"cycle_id": "AAA#2026-01-01#1", "currency": "USD"}, 5000, + {"ticker_diagnosis": []}, {"holdings": {"positions": {}}}, "en", + ) + contract = question["question_opportunity"]["answer_contract"] + assert contract["requirements_by_choice"]["planned_entry"] == \ + ["thesis_updates.why", "thesis_updates.exit_trigger", "thesis_updates.maturity"] + assert contract["requirement_text_by_choice"]["planned_entry"], \ + "a declared requirement needs agent-facing requirement text" + for choice in ("momentum_follow", "external_call", "no_clear_thesis", "skip"): + assert contract["requirements_by_choice"][choice] == [], \ + f"{choice} keeps an inferred record legal (#291) and must stay unrequired" + assert contract["requirement_text_by_choice"][choice] == "" + + def _grounded_headline(ticker, pct): raw = {"dim": "部位 sizing", "max_ticker": ticker, "max_pct": pct, "risk_weights": {ticker: pct}} diff --git a/tests/test_review_v2.py b/tests/test_review_v2.py index c3e5f052..de7c87db 100644 --- a/tests/test_review_v2.py +++ b/tests/test_review_v2.py @@ -9896,6 +9896,84 @@ def test_initial_thesis_consumption_maturity_gate_and_idempotency(): assert again == projected, "an idempotent finalize retry must not duplicate rows" +def test_planned_entry_capture_declared_by_the_contract_reaches_preview(): + """#667: `planned_entry`'s own `question_opportunity.answer_contract` must + name what `_validate_thesis_completeness` will demand, so an agent that + follows the *declared* contract literally -- not one that already knows + the validator's cross-field rule -- can still submit a passing answer in + the same exchange. + + This reads `requirements_by_choice["planned_entry"]` off the real plan + rather than assuming its shape, resolves it into `thesis_updates` fields, + and fills only those. Before #667's fix the declared requirement was `[]` + and this exact construction -- follow the contract, add nothing the + contract did not ask for -- reached preview's refusal instead.""" + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) / "coach" + positions = {"AAA": _pos("AAA", 5000), "BBB": _pos("BBB", 4000)} + card, state = _density_artifacts(tmp, "contract", positions, thesis_questions=[]) + run = _run("prepare", "--root", root, "--language", "en", "--route", "first_review", + "--card-json", card, "--state-json", state) + assert run.returncode == 0, run.stdout + run.stderr + plan = _pending_plan(root, run.stdout) + aaa_q = next(q for q in plan["question_queue"] + if q["kind"] == "initial_thesis" and q["ticker"] == "AAA") + bbb_q = next(q for q in plan["question_queue"] + if q["kind"] == "initial_thesis" and q["ticker"] == "BBB") + + contract = aaa_q["question_opportunity"]["answer_contract"] + required_paths = contract["requirements_by_choice"]["planned_entry"] + assert required_paths, \ + "planned_entry must declare a requirement for this fixture to test anything (#667)" + fields = {path.split(".", 1)[1] for path in required_paths + if path.startswith("thesis_updates.")} + assert fields, f"declared requirement must resolve into thesis_updates fields: {required_paths}" + + base = {"session_id": plan["session_id"], "observations": [], + "commitment": {"choice": "candidate_0"}, + "answers": [{"question_id": aaa_q["id"], "choice": "planned_entry"}, + {"question_id": bbb_q["id"], "choice": "no_clear_thesis"}]} + for q in plan["question_queue"]: + if q["kind"] != "initial_thesis": + base["answers"].append({"question_id": q["id"], "choice": "deliberate_plan"}) + n_path = pathlib.Path(tmp) / "c-narrative.json" + n_path.write_text(json.dumps(_narrative("en"), ensure_ascii=False), encoding="utf-8") + + # Literally the contract and nothing else: why/exit_trigger get real + # content, and maturity -- the one field that turns a real capture + # into a legal planned_entry record -- is any non-inferred value from + # the engine's own vocabulary, never invented by this test. + capture = {"ticker": "AAA", "cycle_id": aaa_q["cycle_id"], + "why": "I bought after three quarters of accelerating backlog growth", + "exit_trigger": "Backlog growth reverses for two consecutive quarters", + "horizon": None} + if "maturity" in fields: + capture["maturity"] = sorted(thesis_engine.MATURITY_VALUES - {"inferred"})[0] + assert set(capture) >= fields, f"fixture must supply every declared field: {fields}" + + # The gate stays strict first: the same cycle, contract-following on + # why/exit_trigger but left at the default inferred maturity, is still + # refused (#291's honesty rule is unchanged by this fix). + bad = json.loads(json.dumps(base)) + bad["thesis_updates"] = [dict(capture, maturity="inferred"), + _thesis_update("BBB", maturity="inferred")] + bad_path = pathlib.Path(tmp) / "c-bad.json" + bad_path.write_text(json.dumps(bad, ensure_ascii=False), encoding="utf-8") + rejected = _run("preview", "--root", root, "--session-id", plan["session_id"], + "--answers", bad_path, "--narrative", n_path) + assert rejected.returncode == 2, rejected.stdout + rejected.stderr + assert "planned_entry" in json.loads(rejected.stdout)["error"] + + # Now the declared contract, satisfied literally: preview succeeds. + good = json.loads(json.dumps(base)) + good["thesis_updates"] = [capture, _thesis_update("BBB", maturity="inferred")] + good_path = pathlib.Path(tmp) / "c-good.json" + good_path.write_text(json.dumps(good, ensure_ascii=False), encoding="utf-8") + accepted = _run("preview", "--root", root, "--session-id", plan["session_id"], + "--answers", good_path, "--narrative", n_path) + assert accepted.returncode == 0, accepted.stdout + accepted.stderr + + def test_question_density_matrix_selects_expected_counts_per_route(): """#291: candidate counts of 1, 3, and 5 resolve to the per-route band — first review floors at three (backfilling), weekly caps at three."""