diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 5ae0821e..8a8ff504 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -136,9 +136,9 @@ When multiple sessions are active: | Card copy on the branches no persona reaches | `skills/fomo-kernel/copy/*.json` ↔ `tests/golden/copy-corpus.txt`, generated by `tests/copy_corpus.py --update` from real `render_private`/`render_html`/`render_public` output (#402 knife 5). Never hand-edit the golden, and never pin a catalog sentence as a literal inside a test again: add a scene instead. `tests/copy_corpus.py` also fails when a key in a claimed register reaches no surface, so dead copy and a renderer that silently stopped emitting a sentence both surface as red rather than as a passing suite. | | Design-bundle preview CSS | `tools/design_bundle.py` `TOKENS`/`CARD` (`.rc2`/`--` aliases) are derived at build time from `_HTML_WIDGET_CSS` / `_HTML_SHIM_CSS`, not hand-copied. Rerun `python3 skills/fomo-kernel/tools/design_bundle.py` after a runtime CSS change to refresh `ds-bundle/`. | | Coach-root persistence registry (#452) | `coach.py`'s `DATA_FILES` (name, kind, user-facing description) is the only registry — `data-status`/`data-export`/`data-reset` all read it through `_scan_root(root)`, never a second list. Completeness is generated, not hand-verified: `tests/test_coach_data_cli.py`'s `test_data_files_registry_covers_every_engine_written_path` parses every non-test module under `skills/fomo-kernel/engine/` for the `os.path.join(, "")` idiom every writer already uses, and fails if a literal it finds is not registered — `condition_checks.jsonl` was exactly this gap (shipped in #412's second half, never registered, nothing failed until this check existed). `review.py`'s `_engine_version()` is named `repo_root` rather than `root` specifically so the scan needs no hand-written exclusion for the skill's own checkout path. Two already-registered entries the scan cannot see — `profile.md` (the agent writes it directly per `SKILL.md`) and `ux/` (`tools/ux_receipt.py`'s pathlib writer, outside `engine/`) — are covered instead by their own dedicated tests in the same file, not by this one. | -| Pre-trade evaluation (Layer 2, `review.py consider`) | `schemas/trade-evaluation.schema.json` (`premise` is `$ref`-ed straight from `trade-premise.schema.json`, `context` from `schemas/decision-context.schema.json`, and `agent_case` from `schemas/answer-provenance.schema.json` — never restated) ↔ `review.py`'s `cmd_consider` / `_rows_from_ledger` (the ledger-reconstruction fallback for a caller with no CSV in hand) / `_evaluation_id` / `_append_evaluation_row` / `_validate_decision_context` / `_validate_agent_case` ↔ `engine/answer_provenance.py`'s `validate_agent_case` (#414; the semantic gate `cmd_consider` calls after `consequence`/`rule_collisions` exist and before either is persisted or returned — #479 Wave B) ↔ `coach.py`'s `DATA_FILES` (so `data-export`/`data-reset` see `trade_evaluations.jsonl` like every other stored file — #452 is exactly the bug that shipped when a file skipped this step) ↔ `references/trade-consequence.md` ↔ `tests/test_consider.py`. Every stored field is a frozen value, never a pointer into mutable state the ledger keeps growing past — the frozen-subject design ratified in issue #446's specification comment. `--resolve` appends a new row carrying the same `evaluation_id` rather than rewriting the old one; `_fold_evaluations` (mirroring `conditions.fold_slots`'s supersede-by-chain precedent) is the only reader that decides which row is current. `review.CONSIDER_DECISIONS` and the schema's `decision` enum are locked together by a drift test in `tests/test_consider.py`, the same discipline the question-kind enum row above holds; so are `EVALUATION_EVIDENCE_REFS_CAP` / `EVALUATION_CONTEXT_TEXT_MAX` / `EVALUATION_EVIDENCE_REF_MAX` and the context schema's own `maxItems`/`maxLength`. Three rules the DecisionContext (#479 Wave A) adds. **Absent means absent**: `_evaluation_id` omits the `context` key from its seed and `cmd_consider` omits it from the row on the identical `is not None` test — a seed carrying `"context": null` moves the hash of *every* context-free call, so an existing user's next plain re-ask mints a new id instead of converging on the row already on disk, duplicating it, with nothing else in the suite pinning an id value. `test_a_context_free_evaluation_id_is_exactly_what_it_was_before_context_existed` pins the literal digest this function returned at `main@52df7f9`; that value must never move. **Identity, never arithmetic**: the context seeds the id so the same premise re-asked with a different `why_now` is a distinct evaluation, and `consequence`/`rule_collisions` are computed from the premise and the book before the seed is taken — the paired test asserts both halves at once, because seeding on the context is only legitimate while the arithmetic is byte-identical. **Every bound refuses, none truncates**: a shortened reason or a clipped evidence list is a statement the user never made, so `_validate_decision_context` raises with the limit named rather than repairing the envelope. `linked` stays out of `CONSIDER_DECISIONS` and out of the `decision` enum — that tuple is `--decision`'s argparse `choices`, so admitting it would let a user assert a link the engine never made (#490 derives link status at read time from its own stream and never writes it back here). Two rules #479 Wave B's provenance-gate integration adds, on top of #414's already-frozen validator. **The gate reads exactly what gets stored, and reads it once**: `cmd_consider` calls `validate_agent_case` with the same `consequence_stored`/`collisions` objects the row itself carries, never a separately assembled copy, and passes `user_statements=(context["reason"], context["why_now"])` — the exact, unparaphrased strings, never a summary — when a `--decision-context` was supplied, `()` otherwise; `agent_case` still never enters `_evaluation_id`'s seed, matching the reasoning the `context` row above already states: it is the agent's interpretation, not the subject being evaluated. `_validate_agent_case` (the cheap structural precheck that still runs first) was narrowed to require only that a claim carry `claim`/`provenance`, not *exactly* those two fields — the full field set is provenance-dependent (`anchor`/`worsens` for `engine_fact`, `source`/`as_of` for `public_fact`) and is now checked in exactly the one place that also enforces it, `answer_provenance.py`. **Reconciling the two claim shapes was Wave B's call, made once, not left open**: `trade-evaluation.schema.json`'s `agent_case` property is a bare `$ref` to `answer-provenance.schema.json` rather than a second, narrower claim `$defs` of its own — the old inline shape (`additionalProperties: false`, only `claim`/`provenance`) could not express what `engine_fact`/`public_fact` claims are required to carry at all, so leaving it unreconciled would have made both of those provenance kinds permanently unusable through the CLI even after the validator was wired in. What `consequence.DISCLOSURES` may contain is settled in one place and read three times (#598/#599/#600): the constant itself, `trade-evaluation.schema.json`'s stored enum, and `evaluation-challenge.schema.json`'s live one, locked by two drift tests in `tests/test_consider.py`. The stored enum is `DISCLOSURES` **plus** `RETIRED_DISCLOSURES` and nothing else — a key no path emits stays valid on a row written before it stopped being emitted, the replay posture #549's `declared_partial` already has — while the challenge enum is `DISCLOSURES` alone, because that block is emitted fresh and never stored, so offering a retired key there would tell an agent it may owe a disclosure nothing can produce. Three rules about what the keys themselves are for. **A blind spot is disclosed with its size, never only its existence**: `unclassified_book`/`etf_not_decomposed` name the *book's* illegible positions where `unmapped_driver` had only ever looked at the premise's own ticker, and each carries an identity list beside it (`unclassified_holdings`, `undecomposed_etfs`) on #515's exact division of labour — the key says THAT the concentration figures were measured over part of the book, the list says WHICH positions and at what weight. Both feed `answer_provenance.required_coverage`, which reads `disclosures` generically, so adding a key is what makes an answer owe the fact; the enforcement is free and the naming is the whole decision. The lists are stamped by `consequence.book_legibility`, called from `portfolio_state` rather than from `consequence()`, so a state carries its concentration readings and the positions those readings were measured *without* in the same dict — `consequence()` forwards `after`'s pair instead of deriving a second one, and `review._canonical_consider_before` is the only other call site, recomputing because it is the one place a state's denominator is replaced after the state was built. Placing this in `consequence()` alone was the shipped defect: a probe workflow that read the book and printed `ai_pct` received the reading with none of its limitations, offline suite green — the same failure shape as a reader of the book not being handed the split map, and fixed the same way. **The two lists are disjoint and the split is the remedy, not tidiness**: a fund the instrument map does not recognize is an unclassified single name, where `--driver-map` is the fix, and declaring it a fund moves it to the limitation that is true, where no fix exists yet — naming one position twice would point the user at a remedy that cannot work. Nothing here is look-through; #599 still owns prorating a fund's constituents across sector/AI buckets. **A currency gap is not a disclosure at all**: `portfolio_state` refuses a book whose held currency has no rate, because `usd_view` resolves a missing one as 1.0 and a ~31:1 currency summed at face value does not make the aggregate incomplete, it inverts which holding is the largest — AGENTS.md boundary 6's fail-closed rule, and #497's canonical-lane treatment finally reaching the legacy CSV lane that never had it. `_canonical_consider_before` converts that `ConsequenceError` rather than letting it escape, and `portfolio_state` carries no `fx_gaps` companion any more: past the refusal it could only ever be empty. | +| Pre-trade evaluation (Layer 2, `review.py consider`) | `schemas/trade-evaluation.schema.json` (`premise` is `$ref`-ed straight from `trade-premise.schema.json`, `context` from `schemas/decision-context.schema.json`, and `agent_case` from `schemas/answer-provenance.schema.json` — never restated) ↔ `review.py`'s `cmd_consider` / `_rows_from_ledger` (the ledger-reconstruction fallback for a caller with no CSV in hand) / `_evaluation_id` / `_append_evaluation_row` / `_validate_decision_context` / `_validate_agent_case` ↔ `engine/answer_provenance.py`'s `validate_agent_case` (#414; the semantic gate `cmd_consider` calls after `consequence`/`rule_collisions` exist and before either is persisted or returned — #479 Wave B) ↔ `coach.py`'s `DATA_FILES` (so `data-export`/`data-reset` see `trade_evaluations.jsonl` like every other stored file — #452 is exactly the bug that shipped when a file skipped this step) ↔ `references/trade-consequence.md` ↔ `tests/test_consider.py`. Every stored field is a frozen value, never a pointer into mutable state the ledger keeps growing past — the frozen-subject design ratified in issue #446's specification comment. `--resolve` appends a new row carrying the same `evaluation_id` rather than rewriting the old one; `_fold_evaluations` (mirroring `conditions.fold_slots`'s supersede-by-chain precedent) is the only reader that decides which row is current. `review.CONSIDER_DECISIONS` and the schema's `decision` enum are locked together by a drift test in `tests/test_consider.py`, the same discipline the question-kind enum row above holds; so are `EVALUATION_EVIDENCE_REFS_CAP` / `EVALUATION_CONTEXT_TEXT_MAX` / `EVALUATION_EVIDENCE_REF_MAX` and the context schema's own `maxItems`/`maxLength`. Three rules the DecisionContext (#479 Wave A) adds. **Absent means absent**: `_evaluation_id` omits the `context` key from its seed and `cmd_consider` omits it from the row on the identical `is not None` test — a seed carrying `"context": null` moves the hash of *every* context-free call, so an existing user's next plain re-ask mints a new id instead of converging on the row already on disk, duplicating it, with nothing else in the suite pinning an id value. `test_a_context_free_evaluation_id_is_exactly_what_it_was_before_context_existed` pins the literal digest this function returned at `main@52df7f9`; that value must never move. **Identity, never arithmetic**: the context seeds the id so the same premise re-asked with a different `why_now` is a distinct evaluation, and `consequence`/`rule_collisions` are computed from the premise and the book before the seed is taken — the paired test asserts both halves at once, because seeding on the context is only legitimate while the arithmetic is byte-identical. **Every bound refuses, none truncates**: a shortened reason or a clipped evidence list is a statement the user never made, so `_validate_decision_context` raises with the limit named rather than repairing the envelope. `linked` stays out of `CONSIDER_DECISIONS` and out of the `decision` enum — that tuple is `--decision`'s argparse `choices`, so admitting it would let a user assert a link the engine never made (#490 derives link status at read time from its own stream and never writes it back here). Two rules #479 Wave B's provenance-gate integration adds, on top of #414's already-frozen validator. **The gate reads exactly what gets stored, and reads it once**: `cmd_consider` calls `validate_agent_case` with the same `consequence_stored`/`collisions` objects the row itself carries, never a separately assembled copy, and passes `user_statements=(context["reason"], context["why_now"])` — the exact, unparaphrased strings, never a summary — when a `--decision-context` was supplied, `()` otherwise; `agent_case` still never enters `_evaluation_id`'s seed, matching the reasoning the `context` row above already states: it is the agent's interpretation, not the subject being evaluated. `_validate_agent_case` (the cheap structural precheck that still runs first) was narrowed to require only that a claim carry `claim`/`provenance`, not *exactly* those two fields — the full field set is provenance-dependent (`anchor`/`worsens` for `engine_fact`, `source`/`as_of` for `public_fact`) and is now checked in exactly the one place that also enforces it, `answer_provenance.py`. **Reconciling the two claim shapes was Wave B's call, made once, not left open**: `trade-evaluation.schema.json`'s `agent_case` property is a bare `$ref` to `answer-provenance.schema.json` rather than a second, narrower claim `$defs` of its own — the old inline shape (`additionalProperties: false`, only `claim`/`provenance`) could not express what `engine_fact`/`public_fact` claims are required to carry at all, so leaving it unreconciled would have made both of those provenance kinds permanently unusable through the CLI even after the validator was wired in. What `consequence.DISCLOSURES` may contain is settled in one place and read three times (#598/#599/#600): the constant itself, `trade-evaluation.schema.json`'s stored enum, and `evaluation-challenge.schema.json`'s live one, locked by two drift tests in `tests/test_consider.py`. The stored enum is `DISCLOSURES` **plus** `RETIRED_DISCLOSURES` and nothing else — a key no path emits stays valid on a row written before it stopped being emitted, the replay posture #549's `declared_partial` already has — while the challenge enum is `DISCLOSURES` alone, because that block is emitted fresh and never stored, so offering a retired key there would tell an agent it may owe a disclosure nothing can produce. Three rules about what the keys themselves are for. **A blind spot is disclosed with its size, never only its existence**: `unclassified_book`/`etf_not_decomposed` name the *book's* illegible positions where `unmapped_driver` had only ever looked at the premise's own ticker, and each carries an identity list beside it (`unclassified_holdings`, `undecomposed_etfs`) on #515's exact division of labour — the key says THAT the concentration figures were measured over part of the book, the list says WHICH positions and at what weight. Both feed `answer_provenance.required_coverage`, which reads `disclosures` generically, so adding a key is what makes an answer owe the fact; the enforcement is free and the naming is the whole decision. The lists are stamped by `consequence.book_legibility`, called from `portfolio_state` rather than from `consequence()`, so a state carries its concentration readings and the positions those readings were measured *without* in the same dict — `consequence()` forwards `after`'s pair instead of deriving a second one, and `review._canonical_consider_before` is the only other call site, recomputing because it is the one place a state's denominator is replaced after the state was built. Placing this in `consequence()` alone was the shipped defect: a probe workflow that read the book and printed `ai_pct` received the reading with none of its limitations, offline suite green — the same failure shape as a reader of the book not being handed the split map, and fixed the same way. **The two lists are disjoint and the split is the remedy, not tidiness**: a fund the instrument map does not recognize is an unclassified single name, where `--driver-map` is the fix, and declaring it a fund moves it to the limitation that is true, where no fix exists yet — naming one position twice would point the user at a remedy that cannot work. Nothing here is look-through; #599 still owns prorating a fund's constituents across sector/AI buckets. **A currency gap is not a disclosure at all**: `portfolio_state` refuses a book whose held currency has no rate, because `usd_view` resolves a missing one as 1.0 and a ~31:1 currency summed at face value does not make the aggregate incomplete, it inverts which holding is the largest — AGENTS.md boundary 6's fail-closed rule, and #497's canonical-lane treatment finally reaching the legacy CSV lane that never had it. `_canonical_consider_before` converts that `ConsequenceError` rather than letting it escape, and `portfolio_state` carries no `fx_gaps` companion any more: past the refusal it could only ever be empty. One rule about the price day the basis freezes (#618). **A date is recorded only where one was observed, per instrument, and the summary is derived from the instruments rather than declared over them**: `basis.price_observations` is stamped by `review._price_observation_record` from the parsed feed's own `observed_date`, scoped to the same `priced_universe` the recovery kit is built over, and is *absent* — never null, never a placeholder, never today's — whenever `valuation_basis` is `unpriced`, which is what keeps an offline answer from growing a date it never had. Its `as_of` is `max(by_ticker)` and not `feed["as_of"]`: the envelope's declared frame date is an upper bound `price_feed.parse` enforces on its rows, so a supplied envelope may legitimately declare today over yesterday's closes, and copying it forward would put the summary ahead of every number it summarizes — the same confusion at frame level that per-ticker observation exists to prevent (#583 §2), and the definition `market_data.to_price_feed_envelope` already takes for its own `as_of`. A priced call's `evaluation_id` moves because of this field and that is correct — the id seeds on the frozen answer and the answer genuinely differs — while a context-free *unpriced* call's must not, which `test_a_context_free_evaluation_id_is_exactly_what_it_was_before_context_existed`'s literal digest is what pins. | | Pre-trade evaluation → review reconciliation | `review.py`'s `_evaluation_reconciliation` (the single reader; calls `_fold_evaluations`, and reads real dated trade events through `_ledger_trade_events` — **never** `_rows_from_ledger`, whose synthesized anchor row raises on a position declared with shares alone and would break `prepare` for an ordinary snapshot user) ↔ `evaluation_reconciliation` in `schemas/review-plan.schema.json` (declared, `additionalProperties: false` at every level, deliberately not dropped into schema-open `state_snapshot`; optional rather than `required`, matching the `engine_version`/`authoring_contract` replay-compatibility precedent) ↔ `EVALUATION_RECONCILE_CAP` and its `summary.beyond_cap` disclosure ↔ `references/trade-consequence.md` ↔ `tests/test_consider.py`. Two invariants. **The engine states a fact, never a cause**: a matching trade may be reported, but `decision` is the user's word and moves only through `consider --resolve` — an engine-written decision would manufacture an adjudication nobody made, the prohibition `condition-check.schema.json` states about `user_response`. **A capped list discloses what it dropped**, or a bounded read reads as a complete one. This is the consumer that keeps `trade_evaluations.jsonl` from being a written-never-read store (#429); nothing else outside `consider` reads it. | -| What a `consider` answer owes the user (#479 Wave B cut 2) | `engine/evaluation_challenge.py`'s `build_challenge` is the single statement — `must_state`, `quote_verbatim`, `unchecked`, `case_required`, `required_coverage` — emitted by `review.py` `cmd_consider` beside the row. Its readers: `schemas/evaluation-challenge.schema.json` ↔ `references/trade-consequence.md` "What the answer owes" ↔ `SKILL.md` rule 3 / `AGENTS.md` boundary 2 ↔ `tests/test_evaluation_challenge.py` + `tests/test_consider.py` section M. Wave B's first cut made a *fabricated* case unstorable and left the user's side untouched; this is the other half, and it is this guide's own "Honesty decisions belong in code" applied to the surface that never had a `build_honesty_ledger()`. Four rules. **The floor and the gate are one list, not two**: `required_coverage` is `answer_provenance.required_coverage`, the same function `validate_agent_case` calls internally, so what the agent is *told* it owes and what a `--agent-case` is *refused* for dropping cannot drift — deriving it in both places is the hand-mirrored surface this file forbids, and `test_dropping_any_single_entry_the_challenge_named_is_refused` fails in both directions (an obligation nothing enforces, and an obligation nobody was told about). Widening it is now the only way to add an enforced obligation; the `would_breach`/`already_over` arm is what this cut added, and before it a rule the trade breaks could vanish from a case with the whole suite green. **`must_state` is deliberately the larger list**: an `unjudged`/`unmapped` collision must be *named* — an unevaluated rule presented as no issue tells the user something the engine never checked — but forcing a claim for each would make a book with eight behavioral rules need eight claims saying nothing was measured, which is answer-padding to satisfy a checker. **An anchor is never offered unless it resolves**, through `answer_provenance.resolve_anchor` rather than a second walk, so the surface that says how to cite a fact and the gate that judges the citation agree by construction; a real ticker containing a dot (`2330.TW`) cannot be addressed by a dotted path, and such an entry keeps its value and loses only its `anchor` — dropping the fact instead would have been the wrong repair. **Emitted, never stored and never in the id seed**: it is a pure function of fields the row already freezes, so storing it would be a derived duplicate able to disagree with its own inputs, and no reader needs the historical version (#429 in the other direction). Boundary with rule 8: this is the "which facts it owes" clause made computable, and says nothing about length — the delivery half is instruction-only, the same footing `docs/development-guide.md` §4 admits for the recommendation ban, and is observed by an owner-live receipt on `tools/ux_receipt.py`'s `consider` route (#544 Slice B; the receipt-route row above holds its contract), which machine-checks what containment and digits can decide and leaves the rest to the owner's `comprehension` verdict. | +| What a `consider` answer owes the user (#479 Wave B cut 2) | `engine/evaluation_challenge.py`'s `build_challenge` is the single statement — `must_state`, `quote_verbatim`, `unchecked`, `case_required`, `required_coverage` — emitted by `review.py` `cmd_consider` beside the row. Its readers: `schemas/evaluation-challenge.schema.json` ↔ `references/trade-consequence.md` "What the answer owes" ↔ `SKILL.md` rule 3 / `AGENTS.md` boundary 2 ↔ `tests/test_evaluation_challenge.py` + `tests/test_consider.py` section M. Wave B's first cut made a *fabricated* case unstorable and left the user's side untouched; this is the other half, and it is this guide's own "Honesty decisions belong in code" applied to the surface that never had a `build_honesty_ledger()`. Four rules. **The floor and the gate are one list, not two**: `required_coverage` is `answer_provenance.required_coverage`, the same function `validate_agent_case` calls internally, so what the agent is *told* it owes and what a `--agent-case` is *refused* for dropping cannot drift — deriving it in both places is the hand-mirrored surface this file forbids, and `test_dropping_any_single_entry_the_challenge_named_is_refused` fails in both directions (an obligation nothing enforces, and an obligation nobody was told about). Widening it is now the only way to add an enforced obligation; the `would_breach`/`already_over` arm is what this cut added, and before it a rule the trade breaks could vanish from a case with the whole suite green. The `price_basis` arm (#618) is the second widening and it carried a latent defect out with it: **one claim pays one debt, the narrowest it matches**. `basis.price_observations` is the first required path ever to sit *under* another (`basis`), and plain prefix matching let a single price-day citation discharge the staleness obligation too — a case that never mentioned staleness accepted, whole suite green, because until then no two required paths nested. `answer_provenance._paid_path` is the repair: the longest matching path wins and the broader obligation stays open. Adding any nested required path is now safe; adding one without that rule was not. **`must_state` is deliberately the larger list**: an `unjudged`/`unmapped` collision must be *named* — an unevaluated rule presented as no issue tells the user something the engine never checked — but forcing a claim for each would make a book with eight behavioral rules need eight claims saying nothing was measured, which is answer-padding to satisfy a checker. **An anchor is never offered unless it resolves**, through `answer_provenance.resolve_anchor` rather than a second walk, so the surface that says how to cite a fact and the gate that judges the citation agree by construction; a real ticker containing a dot (`2330.TW`) cannot be addressed by a dotted path, and such an entry keeps its value and loses only its `anchor` — dropping the fact instead would have been the wrong repair. **Emitted, never stored and never in the id seed**: it is a pure function of fields the row already freezes, so storing it would be a derived duplicate able to disagree with its own inputs, and no reader needs the historical version (#429 in the other direction). Boundary with rule 8: this is the "which facts it owes" clause made computable, and says nothing about length — the delivery half is instruction-only, the same footing `docs/development-guide.md` §4 admits for the recommendation ban, and is observed by an owner-live receipt on `tools/ux_receipt.py`'s `consider` route (#544 Slice B; the receipt-route row above holds its contract), which machine-checks what containment and digits can decide and leaves the rest to the owner's `comprehension` verdict. | | Snapshot position provenance (`carried`, `since`/`since_basis` — #485 Slice C, #531) | `ledger.SNAPSHOT_POSITION_KEYS` and `ledger.ENGINE_ASSIGNED_POSITION_KEYS`/`SINCE_BASES` are the single declaration — `snapshot_adapter.POSITION_KEYS` reads them rather than restating them, which is exactly the hand-mirrored whitelist that let `carried` ship broken with a green suite. Three rules no surface may weaken. **`since` and `since_basis` are one fact, validated as a pair** in `snapshot_adapter._normalize_provenance`: a date that could travel without its stamp is a reconstruction every renderer is free to print as an exact day, so the pairing — not renderer discipline — is what makes rule 2 of #531's ruling true. **A caller never supplies them**: `normalize_envelope`/`normalize_book` refuse both by default and `_load` (the file path, i.e. everything an agent can write) has no way to opt in; `book_refresh.build_adoption` is the single `allow_engine_provenance=True` call site, counted mechanically by `tests/test_book_refresh.py::test_only_the_refresh_lane_may_write_engine_assigned_provenance`. **`ledger.derive_holdings` is the only reader**, via `_anchored_cycle_start`, and it degrades to the anchor date with a named `integrity` issue rather than trusting a hand-edited row. Neither key enters `portfolio_basis._normalized_anchor` (provenance stays out of the book's identity) or the `holdings` dict (`portfolio_basis`'s `holding_keys` is an exact-key gate and `since` must stay a real date there) — `cycle_id` is what carries an unknown start, as `ticker#unknown`. Kinds, options, and answer fields are locked across `book_refresh.CONFIRMATION_KINDS`/`CLASSIFICATIONS_BY_KIND`/`HELD_MONTHS_MAX` ↔ `schemas/book-refresh.schema.json` ↔ `flows/book-refresh.md` step 2 ↔ `references/data-contract.md` ↔ `docs/prd-ledger.md` by two drift tests in `tests/test_book_refresh.py`. Widening what refresh raises automatically widens `review._refuse_if_the_book_must_catch_up`'s refusal (#530), which calls `plan_refresh` rather than restating its criterion. | | Freeform informational answer shape (#543, second cut 2026-07-29) | `skills/fomo-kernel/SKILL.md` rule 8 (`## Non-negotiable rules`) ↔ `AGENTS.md` boundary 8 (`## Non-negotiable boundaries`) — the two guaranteed-delivery entry points (`docs/development-guide.md` §6) — ↔ `skills/fomo-kernel/references/freeform-answers.md` (the full rationale and the named chart set) ↔ cross-references in `references/interaction-delivery.md` and `references/trade-consequence.md` ↔ `tests/test_doc_language.py`'s `test_freeform_answer_shape_is_a_boundary_in_both_entry_points` and its paired mutation test. Three rules, not one. **The default is text, not a picture**: an ad hoc question, including a `consider` call, gets a quick, direct answer with no chart, rendered artifact, or multi-tool production unless the user asks for more — the 34-turn dogfood session that motivated this rule is the counter-example the rule forbids, not a hypothetical. **A chart is named in advance, never improvised**: the owner reversed the first cut's empty set (PR #554) into two named entries — the review card itself, reachable in freeform conversation only through the existing `card-delivery.md` contract (never a fresh rendering, and the card's own sparkline stays scoped to the card rather than becoming independently reachable), and a Positions view, at the time a fixed ungraphical table (`Ticker | Shares | Avg cost | Weight of book`, sorted by weight descending) that `freeform-answers.md` stated plainly had no dedicated no-side-effect `engine/review.py` data outlet yet — `consider` requires a real premise and durably records an evaluation, `refresh` requires a newly supplied broker snapshot, and neither is a passive-lookup shortcut. A third, owner-approved entry is still a name written into the reference file, not a shape invented inline. The gap closed in #561 (owner ruling 2026-07-30): the shape widened into the richer, already-demoed per-position diagnosis (README.md's "What it looks like") — ticker, shares, avg cost, value, $ P&L, and the sizing/averaging-down/exit-discipline/hold-consistency tags, sorted by size — and `engine/review.py positions` is the outlet, reconstructing the book from `ledger.jsonl` alone through the exact FIFO pipeline (`round_trips` → `fifo_held` → `classify_adds` → `dim_size` → `ticker_diagnosis`) `trade_recap.py`'s own CSV review already runs, deliberately not the canonical average-cost book `consider`'s ledger route uses (`ticker_diagnosis` sums FIFO-realized and unrealized P&L into one figure, and mixing that with an average-cost remaining balance misstates the sum on a multi-lot partial sell — `engine/review.py`'s `_positions_diagnosis` docstring). Whitelisted alongside `resolve-market-data` as writing no session, ledger, or evaluation row. **The set bounds the agent, not the user**: both rules above constrain what the agent decides to produce unprompted; neither is a ceiling on an explicit user request, and every other non-negotiable rule still governs however that request gets answered. Only the rule's *presence in both entry points* is mechanically gated — the shared literal phrase check catches one entry point silently dropping the rule while the other keeps it, but nothing offline can observe whether a live host conversation actually stayed brief, the same instruction-only footing `docs/development-guide.md` §4 already admits for the recommendation ban. Boundary with #525: this is an effort/scope ceiling on production cost, not a disclosure-content contract — whether a freeform answer states every material limitation as rigorously as the card's `honesty_ledger` is #525's separate, still-open question, not settled here. **That boundary is itself a pinned phrase in both entry points**, not only in the soft-routed reference: the rule names `consider`, which is exactly the surface #479 delivers a visible two-sided challenge on, so an entry point that says "answered briefly" without `Brevity bounds what an answer produces, never which facts it owes` invites compressing away the disclosures that surface exists to make. The user-override clause is pinned the same way — `bounds what the agent decides to produce on its own initiative, never what the user explicitly asks for` appears verbatim in both entry points, so one cannot silently narrow back to "not even if the user asks" while the other still permits it. **The rule now carries exactly one exception, pinned the same way** (#629, owner ruling 2026-07-30): price recovery *is* multi-tool production by the letter of the rule, so an entry point carrying only the rule tells a host not to do what the ruling requires — and the failure is silent, because the host then relays a concentration answer weighted on cost. Five further phrases are therefore pinned in both entry points — the exception itself (`recovering a price the engine could not retrieve is completing the input, not production`), its narrowness (`It licenses no chart, artifact, or other multi-tool work`), its bound (`transcription, not analysis`, `at most twenty instruments`, `delegated to whatever faster tier the host has`), and what it degrades to (`refused rather than answered on cost basis`). The delegation clause names no model, tier, or subagent mechanism (owner ruling 2026-07-26): this is a cross-host public skill, so the contract states the outcome and the host decides how. The row below owns the engine half. | | Market-data acquisition (#605) | `engine/market_data.py` is the only place this repository reaches a market-data provider, and `tests/test_market_data.py`'s AST guard says so mechanically — only that module may import `yfinance`, and a companion test fails when a grandfathered site has migrated, so the exemption list decays instead of becoming a permanent hole. Its readers are the four projections in `trade_recap.py` (`fetch_prices`, `fetch_splits`, `fetch_fx`, `fetch_fx_series`, which now select out of one bundle rather than fetching) ↔ `market_request` / `_ledger_rebase_origin` / `_project_frame` ↔ `price_feed.py`'s supplied adapter and its `_ERROR_SIGNATURES` table ↔ the `cache` entry in `coach.py`'s `DATA_FILES`. Four rules. **One request per distinct symbol, never twice**: `yf.download` reads like a batch endpoint but fans out one chart request per symbol, so the floor is one per symbol and the contract is not exceeding it — measured 17→8 on a six-instrument, two-currency book, where the waste was `fetch_splits` re-requesting the endpoint the price download already hit. **One instant**: closes, split observations and FX come out of the same response, because two calls seconds apart really do disagree (`^VIX` did, in the probe that motivated this). **Offline is a value the resolver reads, never discipline at a call site**: `TR_OFFLINE`, set once by `tests/run_all.py`, makes the adapter degrade to *exactly* the missing-provider answer — which is why `price_feed.classify_error` maps both to one `client_missing` code, so the state sha256 behind `session_id` cannot tell them apart. **A gap is a stable code, never a number**: `GAP_CODES` refuses an undeclared code at the emit site, a missing rate stays absent rather than becoming 1.0, and a resolution that answered nothing is retried rather than frozen as the day's conclusion. **A resolved bundle reaches a route only as a `price-feed` envelope**: `market_data.to_price_feed_envelope` ↔ `review._resolve_consider_prices` / `_consider_market_universe` ↔ `cmd_resolve_market_data`. `consider` with no `--prices` now retrieves current facts (#605 section E, superseding the claim that the route is offline by construction) and pours them into the *supplied* lane, which already reconciles currencies, checks the split basis, builds the valuation manifest and freezes provenance — so live resolution adds no second downstream path, and "a resolved run produces the same normalized valuation facts as an equivalent supplied fixture" is pinned by `tests/test_consider.py`'s section N rather than argued. Two rules that lane imposes. **Only current observations, never a restated series**: a provider close is already retro-adjusted while the envelope's contract is that a close is raw and gets rebased from its own date, so a declared split *newer than the close it would divide* double-adjusts it — the tenfold error #583 exists to prevent, arriving through the repair for it — and is therefore dropped, which is also the correct pairing, since a split no close reflects has been applied to neither operand. **`TR_OFFLINE` degrades to exactly the pre-#605 answer** (`valuation_basis: unpriced`), which is what keeps ninety-odd `--prices`-free `consider` tests meaningful instead of silently re-baselined. The route half is gated by driving the real CLI against an injected provider, not by calling the helper — a test that calls the helper stays green when the call site is severed, the escape that got past the split gates three times in one day. The window half of this boundary is stated in the split row below, because that is where being wrong about it costs a share count. | diff --git a/skills/fomo-kernel/engine/answer_provenance.py b/skills/fomo-kernel/engine/answer_provenance.py index 74343652..928c5499 100644 --- a/skills/fomo-kernel/engine/answer_provenance.py +++ b/skills/fomo-kernel/engine/answer_provenance.py @@ -493,7 +493,8 @@ def required_coverage(basis, consequence, rule_collisions=()): so `basis` accepts any `basis.*` citation and `rule_collisions.` accepts either `.state` or `.worsens`. A path is therefore a coverage target, not necessarily a resolvable anchor of its - own -- the containers here deliberately do not resolve. + own -- the containers here deliberately do not resolve. Where two paths + nest, an anchor pays the narrower one only (`_paid_path`). """ out = [] for index, key in enumerate(consequence.get("disclosures") or ()): @@ -513,6 +514,23 @@ def required_coverage(basis, consequence, rule_collisions=()): "key": "stale_and_unverified" if (stale and incomplete) else ("stale" if stale else "unverified")}) + # #618. Which market session valued this book. Enforced rather than only + # stated, on #479 Wave B's own rule that what the agent is told it owes and + # what a case is refused for dropping are one list: since #611 every weight + # in the answer is a share of a current close, so an answer that never says + # which close leaves the user unable to attribute a number that moved. + # + # Its own entry rather than a widened `basis` one. The `basis` entry is + # satisfiable by any `basis.*` citation, so folding this into it would make + # a claim about `basis.source` discharge the price-day obligation, which is + # an obligation nothing enforces — the exact failure the two-directional + # test exists to catch. + observations = basis.get("price_observations") + if isinstance(observations, Mapping) and isinstance(observations.get("as_of"), str) \ + and observations["as_of"]: + out.append({"path": "basis.price_observations", "owes": "price_basis", + "key": "price_observed"}) + for row in rule_collisions or (): if not isinstance(row, Mapping) or row.get("state") not in _COVERED_STATES: continue @@ -526,15 +544,39 @@ def required_coverage(basis, consequence, rule_collisions=()): return tuple(out) +def _under(anchor, path): + return anchor == path or anchor.startswith(path + ".") + + +def _paid_path(anchor, paths): + """The *most specific* required path this anchor discharges, or None. + + Prefix matching alone made a nested obligation pay for its own parent as + well: `basis` is matched by any `basis.*` anchor, so once + `basis.price_observations` became a required path of its own (#618), a + single claim citing the price day covered the staleness obligation too and + a case that never mentioned staleness was accepted. The whole suite stayed + green, because until then no two required paths ever nested. + + One claim pays one debt: the longest matching path wins, and the broader + obligation stays open until something cites it that is not already + answering a narrower one. Behaviour is unchanged wherever the required + paths are disjoint, which is every case that existed before #618 -- + `consequence.disclosures.` and `rule_collisions.` never nest. + """ + candidates = [path for path in paths if _under(anchor, path)] + return max(candidates, key=len) if candidates else None + + def _check_required_coverage(resolved_anchors, required): """Case 6: nothing on `required_coverage`'s list may be silently dropped from the answer. "Covered" means at least one engine_fact claim anchors at or under the path -- the same bar every other engine_fact claim - already has to clear, not a new, softer one.""" - anchors = [anchor for anchor, _ in resolved_anchors] - missing = [entry for entry in required - if not any(anchor == entry["path"] or anchor.startswith(entry["path"] + ".") - for anchor in anchors)] + already has to clear, not a new, softer one -- and anchors under a + narrower required path pay that one instead (see `_paid_path`).""" + paths = [entry["path"] for entry in required] + paid = {_paid_path(anchor, paths) for anchor, _ in resolved_anchors} + missing = [entry for entry in required if entry["path"] not in paid] if missing: raise AnswerProvenanceError( "agent_case leaves uncovered what this evaluation owes: " diff --git a/skills/fomo-kernel/engine/evaluation_challenge.py b/skills/fomo-kernel/engine/evaluation_challenge.py index 19a3d28d..d2f55a51 100644 --- a/skills/fomo-kernel/engine/evaluation_challenge.py +++ b/skills/fomo-kernel/engine/evaluation_challenge.py @@ -117,11 +117,13 @@ # Presentation order for `must_state`. The basis comes first because every # number after it is measured against that book, and the disclosures come -# last because they qualify what precedes them. An agent is free to compose -# these into whatever prose the moment calls for; the order is the order the -# facts depend on each other, not a script. -TOPICS = ("basis", "position", "concentration", "cash", "rule_collision", - "disclosure", "excluded_holding") +# last because they qualify what precedes them. `price_basis` sits second for +# the same dependency reason: which book, then which market session that book +# was valued at, then every number measured from the two. An agent is free to +# compose these into whatever prose the moment calls for; the order is the +# order the facts depend on each other, not a script. +TOPICS = ("basis", "price_basis", "position", "concentration", "cash", + "rule_collision", "disclosure", "excluded_holding") # What `consider` never looked at. The first four are unconditional and are # lifted verbatim out of references/trade-consequence.md's own sentence @@ -195,6 +197,50 @@ def _basis_entries(record, basis): return out +def _price_basis_entries(record, basis): + """Which market session valued this book (#618). + + ``basis.as_of`` is the last row of the *record* — the last trade or the + snapshot anchor. It says nothing about when the market was observed, and + since #611 every weight below is a share of a current close rather than of + cost. Without this the same premise re-asked returns different numbers with + no attributable cause, which is the #429-class question that comes back as + a dogfood finding. + + Present exactly when the frozen basis carries the observations — an + unpriced run has none, and the `cost_basis` disclosure is what speaks for + that answer instead. Nothing here manufactures a date. + + The frame summary is stated unconditionally and a per-ticker date is added + only where it differs from that summary. This is #583 §2's rule made + brief: naming every ticker on a same-day frame would pad the floor with + one entry per holding saying the same thing, while a frame date alone lets + one fresh instrument stand in for a stale one. The exceptions are exactly + the instruments the summary does not describe. + + A ticker whose own name contains a dot (``2330.TW``) keeps its date and + loses only its anchor, like every other unaddressable fact here — so its + identity rides in `detail`, which is the only place an answer could read + it back from once the path is gone. + """ + observations = basis.get("price_observations") + if not isinstance(observations, Mapping): + return [] + as_of = observations.get("as_of") + if not isinstance(as_of, str) or not as_of: + return [] + out = [_entry(record, "price_basis", "basis.price_observations.as_of", as_of)] + by_ticker = observations.get("by_ticker") + if isinstance(by_ticker, Mapping): + for ticker in sorted(by_ticker): + day = by_ticker[ticker] + if isinstance(day, str) and day and day != as_of: + out.append(_entry(record, "price_basis", + f"basis.price_observations.by_ticker.{ticker}", + day, detail={"ticker": ticker})) + return out + + def _position_entries(record, premise, consequence): """What this trade does to the position itself — the question the user actually asked. `before` only when the ticker is already held: a weight @@ -392,6 +438,7 @@ def build_challenge(*, premise, basis, consequence, rule_collisions=(), context= must_state = [] must_state.extend(_basis_entries(record, basis)) + must_state.extend(_price_basis_entries(record, basis)) must_state.extend(_position_entries(record, premise, consequence)) must_state.extend(_concentration_entries(record, consequence)) must_state.extend(_cash_entries(record, consequence)) diff --git a/skills/fomo-kernel/engine/review.py b/skills/fomo-kernel/engine/review.py index ea9b5abc..4a7cd79a 100644 --- a/skills/fomo-kernel/engine/review.py +++ b/skills/fomo-kernel/engine/review.py @@ -873,6 +873,53 @@ def _resolve_consider_prices(args, root, premise_ticker=None, premise_currency=N return None, bundle +def _price_observation_record(feed, universe): + """Which market session each number in this answer was valued at (#618). + + Before #611 a ``consider`` weight was a share of cost, or of whatever the + last review froze, so the same premise re-asked returned the same weights. + It is now a share of the current close — which is the point, and which + makes every weight a function of a market day the row never named. The + user could see that the numbers moved and had no way to tell whether the + market moved or their own book did. + + **Per ticker, with a frame-level summary**, rather than one date for the + frame. That choice is #583 §2's, already paid for one surface over: a + single ``as_of`` over a mixed frame makes one fresh instrument stand in + for every stale one, and ``price_feed.parse`` already stores each row's + own ``observed_date`` so there is nothing to derive a second time here. + + ``as_of`` is the newest observation **actually used**, computed from the + per-ticker map rather than copied from ``feed["as_of"]``. The envelope's + own declared frame date is an upper bound the parser enforces (a row may + not post-date it), not an observation: a supplied envelope may legitimately + declare today while every close in it is yesterday's, and copying that + forward would put the summary ahead of every number it summarizes — the + same confusion at frame level that the per-ticker map exists to prevent. + ``market_data.to_price_feed_envelope`` already takes ``max(row date)`` for + exactly this reason; this is that definition, applied to the subset that + reached the answer. + + ``universe`` is what *this answer* needed priced — the held book, whatever + it could not value, and the premise's own ticker — the same set the + recovery kit is scoped to, read here rather than derived a second time. An + envelope may carry closes for instruments no number in this answer uses, + and dating those would owe the agent a sentence about a position the user + does not hold. + + Returns ``None`` when no observation reached this book, which is what keeps + the hard rule of #618 mechanical: an unpriced run grows no date at all, + rather than a null, a placeholder, or today's. + """ + by_ticker = {ticker: row["observed_date"] + for ticker, row in ((feed or {}).get("prices") or {}).items() + if ticker in set(universe or ()) and row.get("observed_date")} + if not by_ticker: + return None + return {"as_of": max(by_ticker.values()), + "by_ticker": {ticker: by_ticker[ticker] for ticker in sorted(by_ticker)}} + + def _profile_path(root): return os.path.join(root, "profile.json") @@ -6657,6 +6704,18 @@ def cmd_consider(args): if row["ticker"] in priced_universe}, fx=fx, supplied=feed, unavailable_declared=declared_unavailable, bundle=market_bundle) + # #618. Frozen onto the basis beside `valuation_basis`, where "was this + # priced" already lives, and from the same `priced_universe` the kit above + # is scoped to. Stamped here rather than inside `_consider_rows` because the + # observation is a property of the feed, not of which lane read the book — + # the CSV and ledger lanes would otherwise each need their own copy of it. + # + # Conditional, and that is the contract: `valuation_basis: "unpriced"` runs + # carry no key at all, so there is no placeholder for a reader to mistake + # for an observation. `_price_observation_record` returns None for them. + price_observations = _price_observation_record(feed, priced_universe) + if price_observations is not None: + basis["price_observations"] = price_observations # #629, the refusal. Scoped to the one state that produces the harm: nothing # current reached this book at all, so every weight above would be a share # of *cost*. A retrospective card's cost weights describe what the user diff --git a/skills/fomo-kernel/references/trade-consequence.md b/skills/fomo-kernel/references/trade-consequence.md index 25587c0a..9175f970 100644 --- a/skills/fomo-kernel/references/trade-consequence.md +++ b/skills/fomo-kernel/references/trade-consequence.md @@ -116,6 +116,14 @@ The CSV/FIFO path a review uses and the ledger reconstruction `consider` falls b `valuation_basis` says whether current prices reached this answer. `"priced"` needs nothing from you. `"unpriced"` means every weight above is a share of *cost*, not of market value — and for a trade the user has not placed yet, that is a different book's answer, not a rougher version of this one. +### Which market session priced it + +A `"priced"` basis also carries `price_observations`: `as_of`, the newest close this answer used, and `by_ticker`, the session each instrument's own close came from. It is absent — not null, not a placeholder, not today's date — whenever the basis is `unpriced`. + +This is a different fact from `basis.as_of`, which is the last row of the *record*. A user who asks the same question twice needs to tell a number that moved because the market moved from one that moved because their book changed, and only these two dates together answer that. Say the price day; [What the answer owes](#what-the-answer-owes) below makes it an owed fact rather than a habit. + +`by_ticker` exists because a single frame date cannot say which session a given instrument's number came from — one fresh close otherwise makes every stale one look same-day. Where an instrument's own session matches `as_of`, the summary already covers it; where it does not, say so beside the number it qualifies. + So the response also carries a `price_feed` block beside the evaluation, built by the same engine helper `prepare` uses: - `provenance` — where the prices came from, and the stable reason code for why retrieval failed. @@ -152,7 +160,9 @@ Read it as the floor of the answer, and read `SKILL.md` rule 8 with it. That rul | `case_required` | The floor for a two-sided case: at least one claim on each side. | | `required_coverage` | The mechanically enforced subset — what an `--agent-case` submission is *refused* for leaving out. | -`must_state` entries are facts, not sentences. Several belong in one sentence: the `basis` entries are one clause — *"computed on your recorded book as of the 20th, nine days old, and never reconciled against a broker view"* — not a bullet each. The order is the order the facts depend on each other (the basis first because every number after it is measured against that book, the disclosures last because they qualify what precedes them), not a script to read aloud. `basis.state_version` is the exception worth naming: it is the book's exact identity, present so a QA run can compare what the user saw against the frozen payload mechanically. Carry it, but do not recite a hash at someone mid-decision — *"this is your book as of the 20th"* is the same fact in the register the rest of the answer is in. +`must_state` entries are facts, not sentences. Several belong in one sentence: the `basis` and `price_basis` entries are one clause — *"computed on your recorded book as of the 20th, nine days old and never reconciled against a broker view, priced at Tuesday's closes"* — not a bullet each. The order is the order the facts depend on each other (the basis first because every number after it is measured against that book, the price session next because those numbers are measured at one, the disclosures last because they qualify what precedes them), not a script to read aloud. `basis.state_version` is the exception worth naming: it is the book's exact identity, present so a QA run can compare what the user saw against the frozen payload mechanically. Carry it, but do not recite a hash at someone mid-decision — *"this is your book as of the 20th"* is the same fact in the register the rest of the answer is in. + +`price_basis` is present only on a priced answer, and is normally one entry: the frame date every number came from. A per-instrument entry appears beside it only where that instrument's own session differs from the frame, and it carries the ticker in `detail` — say that one aloud, because it is the case a single date would have hidden. An unpriced answer carries no `price_basis` entry at all; the `cost_basis` disclosure is what speaks for it there. `anchor` is present on most entries and absent on a few. A dot-separated path cannot address a ticker that itself contains a dot, so `2330.TW`'s own weight arrives with its value and no `anchor`: the fact is still owed and still stated, it simply cannot be cited by path. Every anchor that *is* offered has already been resolved against the frozen record, so an anchor from this block is always one the case validator accepts. @@ -167,7 +177,7 @@ Read it as the floor of the answer, and read `SKILL.md` rule 8 with it. That rul | `evidence_delta` | Present when a decision context was supplied. Whether the stated why-now is genuinely new information or a price move that feels like one is a call the engine cannot make; label your own read of it as judgment. | | `evidence_refs_unverified` | Present when the user cited something. The engine did not fetch, date or believe any reference; it recorded that one was cited. | -`required_coverage` is deliberately a subset of `must_state`. It names every disclosure, the basis whenever it is stale or not a declared-complete book, and every rule this trade `would_breach` or is `already_over` on. It does **not** include `unjudged`/`unmapped` collisions: those must be *named in the answer* — an unevaluated rule presented as no issue tells the user something the engine never checked — but a book with eight behavioral rules would otherwise need eight claims saying nothing was measured, and an answer padded to satisfy a checker is worse than a short honest one. Its `path` is matched by prefix, so `basis` accepts any `basis.*` citation and `rule_collisions.` accepts either `.state` or `.worsens`. +`required_coverage` is deliberately a subset of `must_state`. It names every disclosure, the basis whenever it is stale or not a declared-complete book, the price session whenever the book was priced, and every rule this trade `would_breach` or is `already_over` on. It does **not** include `unjudged`/`unmapped` collisions: those must be *named in the answer* — an unevaluated rule presented as no issue tells the user something the engine never checked — but a book with eight behavioral rules would otherwise need eight claims saying nothing was measured, and an answer padded to satisfy a checker is worse than a short honest one. Its `path` is matched by prefix, so `basis` accepts any `basis.*` citation and `rule_collisions.` accepts either `.state` or `.worsens`. Where two paths nest, one claim pays the narrower one only: `basis.price_observations` sits under `basis`, so citing the price day covers the price day and leaves the staleness obligation still to be cited. The block is emitted, never stored: it is a pure function of the premise, basis, consequence, collisions and context the evaluation row already freezes, and it takes no part in the `evaluation_id`. A `--resolve` call carries none, because nothing new is being answered there. diff --git a/skills/fomo-kernel/schemas/evaluation-challenge.schema.json b/skills/fomo-kernel/schemas/evaluation-challenge.schema.json index ce60b213..9f06e07e 100644 --- a/skills/fomo-kernel/schemas/evaluation-challenge.schema.json +++ b/skills/fomo-kernel/schemas/evaluation-challenge.schema.json @@ -9,7 +9,7 @@ "properties": { "must_state": { "type": "array", - "description": "Ordered owed facts this answer must put in front of the user -- a floor on content, never a script for wording (SKILL.md rule 8: 'Brevity bounds what an answer produces, never which facts it owes'). Facts, not sentences: several entries commonly belong in one clause of prose (the basis entries read as one clause -- 'computed on your recorded book as of the 20th, nine days old' -- not four bullet points). Presentation order mirrors evaluation_challenge.TOPICS: basis first, because every number that follows is measured against that book, then position, concentration, cash, rule_collision, disclosure, and excluded_holding, the trailing topics qualifying the numbers stated earlier. Deliberately a SUPERSET of required_coverage, and the gap is intentional rather than an oversight: position, concentration, cash, and excluded_holding facts are always owed here in prose but never required to be cited by an anchored claim in --agent-case; and within rule_collision, this array also names a collision whose state is unjudged or unmapped -- silently omitting a rule the engine could not evaluate would read as a clean rule, so it must be named -- while required_coverage excludes exactly those two states, because a case claim can only truthfully cite a fact the engine actually judged, and forcing one for every unmeasurable rule would pad the case to satisfy a checker rather than to inform the user (the 'eval must not pin current wording' failure mode this repository has already shipped once).", + "description": "Ordered owed facts this answer must put in front of the user -- a floor on content, never a script for wording (SKILL.md rule 8: 'Brevity bounds what an answer produces, never which facts it owes'). Facts, not sentences: several entries commonly belong in one clause of prose (the basis entries read as one clause -- 'computed on your recorded book as of the 20th, nine days old' -- not four bullet points). Presentation order mirrors evaluation_challenge.TOPICS: basis first, because every number that follows is measured against that book, then price_basis, because those numbers are measured at a market session too, then position, concentration, cash, rule_collision, disclosure, and excluded_holding, the trailing topics qualifying the numbers stated earlier. Deliberately a SUPERSET of required_coverage, and the gap is intentional rather than an oversight: position, concentration, cash, and excluded_holding facts are always owed here in prose but never required to be cited by an anchored claim in --agent-case; and within rule_collision, this array also names a collision whose state is unjudged or unmapped -- silently omitting a rule the engine could not evaluate would read as a clean rule, so it must be named -- while required_coverage excludes exactly those two states, because a case claim can only truthfully cite a fact the engine actually judged, and forcing one for every unmeasurable rule would pad the case to satisfy a checker rather than to inform the user (the 'eval must not pin current wording' failure mode this repository has already shipped once).", "items": { "type": "object", "additionalProperties": false, @@ -17,8 +17,8 @@ "description": "One owed fact.", "properties": { "topic": { - "enum": ["basis", "position", "concentration", "cash", "rule_collision", "disclosure", "excluded_holding"], - "description": "Which family of fact this is. basis: which book answered, how current it was, and its exact identity -- unconditional, because a weight quoted without the book it was measured on is a number about an unknown denominator. position: what this trade does to the position the user actually asked about; its before-weight entry appears only when the ticker is already held, since a weight of zero for a name the user does not own is arithmetic rather than a fact worth a sentence. concentration: book concentration and driver overlap after the trade; ai_pct/max_sector_pct are omitted rather than stated as a false zero on a book with no sector/AI classification at all -- the separate unclassified_book disclosure covers that case, and unmapped_driver covers the narrower one where it is the premise's own ticker that cannot be classified. A PARTIALLY classified book is the case this omission does not reach: max_sector is present, so both readings are stated as measurements, and they are -- of the classified part of the book only. unclassified_book is what says so, and names the weight it was measured without (#598) -- and the two boolean triggers are stated only when true, never as an explicit false. cash: what the trade leaves in cash; whether that balance can be trusted rides the disclosure topic instead of being folded in here. rule_collision: every one of the user's own rules this trade would_breach or leaves already_over, plus every rule that could not be judged at all (unjudged/unmapped) -- clear is the only state that may pass in silence. disclosure: every limitation the engine attached to these numbers, addressed by the same index a required_coverage disclosure entry and an --agent-case anchor would use. excluded_holding: which held positions sat outside the denominator every percentage above was measured against (#515) -- partial_book (a disclosure entry) says THAT something was excluded; this says WHAT." + "enum": ["basis", "price_basis", "position", "concentration", "cash", "rule_collision", "disclosure", "excluded_holding"], + "description": "Which family of fact this is. basis: which book answered, how current it was, and its exact identity -- unconditional, because a weight quoted without the book it was measured on is a number about an unknown denominator. price_basis: which market session that book was valued at (#618), present only when the basis carries price observations and never manufactured for an unpriced answer -- basis.as_of is the last row of the RECORD (the last trade or the snapshot anchor) and says nothing about when the market was observed, while since #611 every weight below is a share of a current close, so the same premise re-asked returns different numbers with no attributable cause unless the answer says which close. The frame summary (basis.price_observations.as_of) is stated unconditionally and a per-instrument entry is added only where that instrument's own session differs from it: naming every ticker on a same-day frame would pad the floor with one entry per holding saying the same thing, while a frame date alone lets one fresh instrument stand in for a stale one (#583 section 2). position: what this trade does to the position the user actually asked about; its before-weight entry appears only when the ticker is already held, since a weight of zero for a name the user does not own is arithmetic rather than a fact worth a sentence. concentration: book concentration and driver overlap after the trade; ai_pct/max_sector_pct are omitted rather than stated as a false zero on a book with no sector/AI classification at all -- the separate unclassified_book disclosure covers that case, and unmapped_driver covers the narrower one where it is the premise's own ticker that cannot be classified. A PARTIALLY classified book is the case this omission does not reach: max_sector is present, so both readings are stated as measurements, and they are -- of the classified part of the book only. unclassified_book is what says so, and names the weight it was measured without (#598) -- and the two boolean triggers are stated only when true, never as an explicit false. cash: what the trade leaves in cash; whether that balance can be trusted rides the disclosure topic instead of being folded in here. rule_collision: every one of the user's own rules this trade would_breach or leaves already_over, plus every rule that could not be judged at all (unjudged/unmapped) -- clear is the only state that may pass in silence. disclosure: every limitation the engine attached to these numbers, addressed by the same index a required_coverage disclosure entry and an --agent-case anchor would use. excluded_holding: which held positions sat outside the denominator every percentage above was measured against (#515) -- partial_book (a disclosure entry) says THAT something was excluded; this says WHAT." }, "value": { "type": ["string", "number", "boolean"], @@ -33,8 +33,13 @@ "detail": { "type": "object", "additionalProperties": false, - "description": "Optional: topic-specific payload riding alongside a rule_collision or excluded_holding entry only; no other topic populates this key. A rule_collision entry carries rule_id/text/worsens -- the rule's own text rides along verbatim so the answer can quote what the user wrote rather than paraphrase it, and worsens rides along because an already_over line that this trade actually improves reads identically without it. An excluded_holding entry carries reason, why that holding could not be valued and folded into the denominator.", + "description": "Optional: topic-specific payload riding alongside a rule_collision, excluded_holding or price_basis entry only; no other topic populates this key. A rule_collision entry carries rule_id/text/worsens -- the rule's own text rides along verbatim so the answer can quote what the user wrote rather than paraphrase it, and worsens rides along because an already_over line that this trade actually improves reads identically without it. An excluded_holding entry carries reason, why that holding could not be valued and folded into the denominator. A per-instrument price_basis entry carries ticker, which is the only place the instrument's identity survives when its anchor cannot be built.", "properties": { + "ticker": { + "type": "string", + "minLength": 1, + "description": "Which instrument this price_basis entry dates. Present on a per-instrument price_basis entry only. Load-bearing rather than redundant with the anchor: a ticker whose own name contains a dot (2330.TW) cannot be addressed by a dotted path, so the entry keeps its date and loses its anchor -- and without this key the answer would carry a market session with no instrument attached to it." + }, "rule_id": { "type": ["string", "null"], "description": "The colliding rule's own identity, mirroring trade-evaluation.schema.json's rule_collisions[].rule_id. null on the defensively-allowed rule row with no id -- the same row whose must_state entry above carries no anchor." @@ -100,7 +105,7 @@ }, "required_coverage": { "type": "array", - "description": "The mechanically enforced SUBSET of must_state: exactly what a --agent-case submission is refused for leaving uncited. Imported from answer_provenance.required_coverage, the same function validate_agent_case calls internally before a row is ever written, so the facts this block tells the agent it owes and the facts the gate actually refuses on are one list, read twice, and cannot drift apart. Only three of must_state's seven topics ever contribute an entry here -- basis (at most one entry, covering whichever of stale_days/completeness triggered it), rule_collision (only the would_breach/already_over states, never unjudged/unmapped -- see must_state's own description for why those two are named but not required), and disclosure (every disclosure that was stated is also required, the one topic where the two arrays name exactly the same facts). position, concentration, cash, and excluded_holding facts are always owed in must_state's prose but never appear here: requiring an --agent-case claim for every cash balance or every excluded ticker would inflate the case to satisfy a checker rather than to inform the user (the 'eval must not pin current wording' failure mode this repository has already shipped once). Empty when nothing on this evaluation must be covered -- a fresh, non-stale, fully declared book with no rule_collision in a covered state and no disclosures at all.", + "description": "The mechanically enforced SUBSET of must_state: exactly what a --agent-case submission is refused for leaving uncited. Imported from answer_provenance.required_coverage, the same function validate_agent_case calls internally before a row is ever written, so the facts this block tells the agent it owes and the facts the gate actually refuses on are one list, read twice, and cannot drift apart. Only four of must_state's eight topics ever contribute an entry here -- basis (at most one entry, covering whichever of stale_days/completeness triggered it), price_basis (at most one entry, present exactly when the basis carries price observations, and never for an unpriced answer), rule_collision (only the would_breach/already_over states, never unjudged/unmapped -- see must_state's own description for why those two are named but not required), and disclosure (every disclosure that was stated is also required, the one topic where the two arrays name exactly the same facts). position, concentration, cash, and excluded_holding facts are always owed in must_state's prose but never appear here: requiring an --agent-case claim for every cash balance or every excluded ticker would inflate the case to satisfy a checker rather than to inform the user (the 'eval must not pin current wording' failure mode this repository has already shipped once). Empty when nothing on this evaluation must be covered -- a fresh, non-stale, fully declared book with no rule_collision in a covered state and no disclosures at all.", "items": { "type": "object", "additionalProperties": false, @@ -110,18 +115,19 @@ "path": { "type": "string", "minLength": 1, - "description": "Matched by PREFIX, not by exact equality alone: covered when some engine_fact claim's own anchor equals this path, or sits under it (anchor == path, or anchor starts with path + '.'). For owes:basis (path is always the bare string 'basis') and owes:rule_collision (path is 'rule_collisions.'), this path names a container that never itself resolves through answer_provenance.resolve_anchor -- the sub-fact an engine_fact claim actually anchors at sits one level further in, at basis.stale_days or basis.completeness, or at rule_collisions..state or .worsens, so only the startswith branch of the match can ever satisfy these two. For owes:disclosure the path (consequence.disclosures.) already IS the exact resolvable anchor the matching must_state entry carries -- one scalar fact with nothing beneath it to prefix-match against, so only the exact-equality branch can satisfy it." + "description": "Matched by PREFIX, not by exact equality alone: covered when some engine_fact claim's own anchor equals this path, or sits under it (anchor == path, or anchor starts with path + '.'). For owes:basis (path is always the bare string 'basis'), owes:price_basis (path is always 'basis.price_observations') and owes:rule_collision (path is 'rule_collisions.'), this path names a container that never itself resolves through answer_provenance.resolve_anchor -- the sub-fact an engine_fact claim actually anchors at sits one level further in, at basis.stale_days or basis.completeness, at basis.price_observations.as_of or .by_ticker., or at rule_collisions..state or .worsens, so only the startswith branch of the match can ever satisfy these three. For owes:disclosure the path (consequence.disclosures.) already IS the exact resolvable anchor the matching must_state entry carries -- one scalar fact with nothing beneath it to prefix-match against, so only the exact-equality branch can satisfy it. WHERE TWO PATHS NEST, one claim pays the narrower one only: 'basis.price_observations' sits under 'basis', so a claim citing the price day discharges the price-day obligation and leaves the staleness obligation open (answer_provenance._paid_path). Prefix matching alone would have let a single price-day citation silently cover both." }, "owes": { - "enum": ["disclosure", "basis", "rule_collision"], - "description": "Which family of obligation this is, mirroring must_state's topic vocabulary but never its full width. disclosure: one of consequence.disclosures must be cited by anchor. basis: the book's own staleness or incompleteness must be cited by some basis.* anchor, once, regardless of which basis field the citing claim names. rule_collision: a rule this trade would_breach or leaves already_over must be cited by a rule_collisions..state or .worsens anchor -- never an unjudged or unmapped one, which must_state still names in prose but which required_coverage deliberately excludes." + "enum": ["disclosure", "basis", "price_basis", "rule_collision"], + "description": "Which family of obligation this is, mirroring must_state's topic vocabulary but never its full width. disclosure: one of consequence.disclosures must be cited by anchor. basis: the book's own staleness or incompleteness must be cited by some basis.* anchor, once, regardless of which basis field the citing claim names -- except one under basis.price_observations, which pays the narrower obligation below instead. price_basis: which market session valued this book must be cited by a basis.price_observations.* anchor (#618). Present only when the basis carries price observations at all, so an unpriced answer owes nothing here and there is no obligation to cite a date that does not exist. rule_collision: a rule this trade would_breach or leaves already_over must be cited by a rule_collisions..state or .worsens anchor -- never an unjudged or unmapped one, which must_state still names in prose but which required_coverage deliberately excludes." }, "key": { "enum": ["cost_basis", "cash_unreliable", "unmapped_driver", "unclassified_book", "etf_not_decomposed", "partial_book", "stale", "unverified", "stale_and_unverified", + "price_observed", "would_breach", "already_over"], - "description": "Which specific instance within the owes family. Mirrors consequence.DISCLOSURES exactly when owes is disclosure; one of answer_provenance.py's own three literals when owes is basis (stale: only stale_days > 0; unverified: only basis.completeness != declared_complete; stale_and_unverified: both at once -- and a basis entry never fires for any other reason, so exactly one of these three appears whenever owes is basis); the colliding rule_collisions[].state when owes is rule_collision, which by construction is always would_breach or already_over here." + "description": "Which specific instance within the owes family. Mirrors consequence.DISCLOSURES exactly when owes is disclosure; one of answer_provenance.py's own three literals when owes is basis (stale: only stale_days > 0; unverified: only basis.completeness != declared_complete; stale_and_unverified: both at once -- and a basis entry never fires for any other reason, so exactly one of these three appears whenever owes is basis); always the single literal price_observed when owes is price_basis, which has exactly one instance per evaluation and needs no discriminator -- the date itself is the fact and belongs in must_state's value, not restated here where a closed enum could not hold it; the colliding rule_collisions[].state when owes is rule_collision, which by construction is always would_breach or already_over here." } } } diff --git a/skills/fomo-kernel/schemas/trade-evaluation.schema.json b/skills/fomo-kernel/schemas/trade-evaluation.schema.json index f2dcb702..437d989e 100644 --- a/skills/fomo-kernel/schemas/trade-evaluation.schema.json +++ b/skills/fomo-kernel/schemas/trade-evaluation.schema.json @@ -85,6 +85,25 @@ "currencies": {"type": "array", "items": {"type": "string", "pattern": "^[A-Z]{3}$"}} }, "description": "Canonical sizing projection coverage for a ledger-backed current-book evaluation. This makes mixed price/cost fallback visible; absent on the separate CSV compatibility lane." + }, + "price_observations": { + "type": "object", + "additionalProperties": false, + "required": ["as_of", "by_ticker"], + "properties": { + "as_of": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "description": "The newest per-ticker observation this answer actually used -- max(by_ticker), computed from the map below rather than copied from the price envelope's own declared as_of. The envelope's frame date is an upper bound price_feed.parse enforces on its rows, not an observation: a supplied envelope may declare today while every close in it is yesterday's, and copying it forward would put the summary ahead of every number it summarizes. market_data.to_price_feed_envelope already defines its own as_of as max(row date) for the same reason." + }, + "by_ticker": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}, + "description": "Per instrument, the market session its close was observed on -- price_feed.parse's own observed_date, never re-derived. Per ticker rather than one frame date because a frame-level as_of cannot say which session a given instrument's number came from, and one fresh instrument then makes every stale one look same-day (#583 section 2). Scoped to what this answer needed priced -- the held book, whatever it could not value, and the premise's own ticker -- the same universe the price_feed recovery kit is built over, so an envelope carrying closes for instruments no number here uses does not date them." + } + }, + "description": "Which market session each number in this answer was valued at (#618). Absent -- never null, never a placeholder, never today's date -- whenever valuation_basis is unpriced, which is the whole point: a run that priced nothing must not grow a date, and the cost_basis disclosure is what speaks for that answer instead. Frozen beside valuation_basis because that is where the was-this-priced fact already lives. Before #611 a consider weight was a share of cost or of whatever the last review froze, so the same premise re-asked returned the same weights; it is now a share of the current close, and without this the user can see that the numbers moved with no way to tell whether the market moved or their own book did." } } }, diff --git a/skills/fomo-kernel/tools/ux_receipt.py b/skills/fomo-kernel/tools/ux_receipt.py index 5ca3116e..ca9d03b8 100644 --- a/skills/fomo-kernel/tools/ux_receipt.py +++ b/skills/fomo-kernel/tools/ux_receipt.py @@ -507,7 +507,11 @@ def _grounding_fidelity(path: str | None) -> dict: # `basis` numbers are deliberately NOT here: `stale_days` is contractually # presentable in words ("nine days old" is references/trade-consequence.md's # own example sentence), so failing a worded form would put this gate at war -# with the reference it enforces. +# with the reference it enforces. `price_basis` (#618) is out for the same +# reason and not by omission: its values are dates, and the reference's own +# example sentence states one as "priced at Tuesday's closes". Whether the +# market session actually reached the user is the `comprehension` verdict's +# call, exactly like every other engine-vocabulary string here. NUMERIC_FACT_TOPICS = ("position", "concentration", "cash") NUMBER_TOKEN = re.compile(r"\d+(?:,\d{3})*(?:\.\d+)?") diff --git a/tests/test_consider.py b/tests/test_consider.py index 6f02b815..e1ace6c5 100644 --- a/tests/test_consider.py +++ b/tests/test_consider.py @@ -152,12 +152,26 @@ def _check_evaluation_shape(row): assert set(basis_schema["required"]) <= set(row["basis"]) assert row["basis"]["source"] in basis_schema["properties"]["source"]["enum"] assert isinstance(row["basis"]["stale_days"], int) and row["basis"]["stale_days"] >= 0 + assert set(row["basis"]) <= set(basis_schema["properties"]), ( + f"basis carries undeclared fields: {set(row['basis']) - set(basis_schema['properties'])}") if "valuation_coverage" in row["basis"]: coverage_schema = basis_schema["properties"]["valuation_coverage"] coverage = row["basis"]["valuation_coverage"] assert set(coverage) == set(coverage_schema["properties"]) assert coverage["scope"] in coverage_schema["properties"]["scope"]["enum"] assert coverage["currencies"] == sorted(coverage["currencies"]) + # #618. Absent on an unpriced row and, when present, a real per-instrument + # record whose summary is the newest observation in it -- never a frame + # date declared over instruments it does not describe. + observed = row["basis"].get("price_observations") + assert not (observed is not None and row["basis"]["valuation_basis"] == "unpriced"), ( + f"an unpriced book grew a market session it never observed: {observed}") + if observed is not None: + assert set(observed) == set(basis_schema["properties"]["price_observations"]["properties"]) + assert observed["by_ticker"], "an empty observation map is an absent one" + assert observed["as_of"] == max(observed["by_ticker"].values()) + for day in observed["by_ticker"].values(): + assert isinstance(day, str) and len(day) == 10 and day[4] == day[7] == "-" consequence_schema = EVALUATION_SCHEMA["properties"]["consequence"] assert set(consequence_schema["required"]) <= set(row["consequence"]) @@ -2354,6 +2368,125 @@ def test_the_challenge_is_absent_from_a_resolution(): assert "challenge" not in resolved +# ───── M2. which market session the answer was valued at (#618) ───── +# +# Before #611 a `consider` weight was a share of cost, or of whatever the last +# review froze, so the same premise re-asked returned the same weights. It is +# now a share of the current close — which is the point, and which makes every +# number a function of a market day the row never named. A user who asks twice +# could see that the numbers moved with no way to tell whether the market moved +# or their own book did. +# +# What is proved here, and only here, is that the fact survives the real CLI in +# both directions: an offline run grows no date at all, and a priced run freezes +# one the block then names and the gate then enforces. + +_PRICE_DAY = "2026-07-30" +_EARLIER_DAY = "2026-07-29" + + +def _priced_collision_root(tmp): + """`_collision_root`'s book (AAA/BBB/CCC) with a supplied envelope that + prices it on a deliberately MIXED frame: two instruments on the frame's + own newest session, one a day earlier, and one close for an instrument the + book has never held — which must not be dated, because no number in this + answer used it.""" + premise = _collision_root(tmp) + feed = os.path.join(tmp, "px.json") + rows = [("AAA", 130.0, _PRICE_DAY), ("BBB", 90.0, _PRICE_DAY), + ("CCC", 110.0, _EARLIER_DAY), ("ZZZ", 44.0, _PRICE_DAY)] + with open(feed, "w", encoding="utf-8") as handle: + json.dump({"as_of": _PRICE_DAY, "source": "fixture", + "prices": [{"ticker": t, "close": c, "date": d, "currency": "USD"} + for t, c, d in rows]}, handle) + return premise, feed + + +def test_an_offline_answer_carries_no_price_day_at_all(): + """The hard rule of #618, on the production path. Nothing retrieved means + nothing observed, and an answer that manufactures a date there — a null, a + placeholder, or today's — would tell the user the numbers came from a + market session that never priced this book.""" + with tempfile.TemporaryDirectory() as tmp: + premise = _collision_root(tmp) + payload = _ok(_run_env(_offline_env(), "consider", "--root", tmp, "--premise", premise)) + basis = payload["evaluation"]["basis"] + assert basis["valuation_basis"] == "unpriced" + assert "price_observations" not in basis, ( + f"an offline consider grew a price observation: {basis['price_observations']}") + _check_evaluation_shape(payload["evaluation"]) + + challenge = payload["challenge"] + assert not [e for e in challenge["must_state"] if e["topic"] == "price_basis"], ( + "an unpriced answer was told to state a market session it never had") + assert not [r for r in challenge["required_coverage"] if r["owes"] == "price_basis"], ( + "an unpriced answer cannot cite a price day, so it must not be refused for not citing one") + _check_challenge_shape(challenge) + + +def test_a_priced_answer_freezes_the_session_each_number_was_valued_at(): + with tempfile.TemporaryDirectory() as tmp: + premise, feed = _priced_collision_root(tmp) + payload = _ok(_run_env(_offline_env(), "consider", "--root", tmp, + "--prices", feed, "--premise", premise)) + basis = payload["evaluation"]["basis"] + assert basis["valuation_basis"] == "priced" + assert basis["price_observations"] == { + "as_of": _PRICE_DAY, + "by_ticker": {"AAA": _PRICE_DAY, "BBB": _PRICE_DAY, "CCC": _EARLIER_DAY}}, ( + "the frozen observations must be per instrument, scoped to what this answer " + f"needed priced, and summarized by their own newest: {basis['price_observations']}") + _check_evaluation_shape(payload["evaluation"]) + assert _read_evaluations(tmp)[0]["basis"] == basis, ( + "the stored row must carry the same observations the caller was handed") + + +def test_the_priced_answer_owes_the_session_and_names_the_instrument_off_it(): + with tempfile.TemporaryDirectory() as tmp: + premise, feed = _priced_collision_root(tmp) + challenge = _ok(_run_env(_offline_env(), "consider", "--root", tmp, + "--prices", feed, "--premise", premise))["challenge"] + _check_challenge_shape(challenge) + entries = [e for e in challenge["must_state"] if e["topic"] == "price_basis"] + assert [e["value"] for e in entries] == [_PRICE_DAY, _EARLIER_DAY], ( + f"the frame session and the one instrument off it are both owed: {entries}") + assert entries[0]["anchor"] == "basis.price_observations.as_of" + assert entries[1]["detail"] == {"ticker": "CCC"}, ( + "the instrument the frame summary does not describe must be named") + assert any(r["owes"] == "price_basis" and r["path"] == "basis.price_observations" + for r in challenge["required_coverage"]), ( + "stating the price day is enforced, not merely announced") + + +def test_a_case_silent_about_the_price_day_is_refused_on_the_production_path(): + """The other direction of #479 Wave B's one-list rule, for #618's fact: + what the block says is owed is what a case is refused for dropping.""" + with tempfile.TemporaryDirectory() as tmp: + premise, feed = _priced_collision_root(tmp) + payload = _ok(_run_env(_offline_env(), "consider", "--root", tmp, + "--prices", feed, "--premise", premise)) + challenge, collisions = payload["challenge"], payload["evaluation"]["rule_collisions"] + + case_path = os.path.join(tmp, "case.json") + with open(case_path, "w", encoding="utf-8") as f: + json.dump(_case_from_challenge(challenge, collisions, + skip="basis.price_observations"), f) + run = _run_env(_offline_env(), "consider", "--root", tmp, "--prices", feed, + "--premise", premise, "--agent-case", case_path) + _fails(run, "basis.price_observations") + assert len(_read_evaluations(tmp)) == 1, ( + "the refused attempt appended a row; the gate must fail closed") + + # ... and the same case with the price day put back is accepted, so + # the refusal above is about that one fact and not about the shape of + # a case built this way. + with open(case_path, "w", encoding="utf-8") as f: + json.dump(_case_from_challenge(challenge, collisions), f) + accepted = _ok(_run_env(_offline_env(), "consider", "--root", tmp, "--prices", feed, + "--premise", premise, "--agent-case", case_path)) + assert "agent_case" in accepted["evaluation"] + + # ───── N. automatic market-data resolution (#605 section E) ───── # # `consider` may now retrieve current market facts when no `--prices` is handed diff --git a/tests/test_evaluation_challenge.py b/tests/test_evaluation_challenge.py index f8b94d25..62b6294d 100644 --- a/tests/test_evaluation_challenge.py +++ b/tests/test_evaluation_challenge.py @@ -52,6 +52,29 @@ red) -> test_consider's whole section M, headed by test_a_considered_trade_puts_the_whole_challenge_in_front_of_the_caller + 9. `_price_basis_entries` returns [] so the market session never reaches + must_state (#618) + -> test_a_priced_answer_owes_the_market_session_it_was_valued_at, + test_an_instrument_off_the_frame_date_is_named_and_a_matching_one_is_not, + test_a_dotted_ticker_off_the_frame_date_keeps_its_fact_and_its_identity, + test_every_topic_appears_and_in_the_declared_order, + test_every_required_coverage_path_is_reachable_from_must_state, + and test_consider's the_priced_answer_owes_the_session_and_names_the_ + instrument_off_it + 10. `review._price_observation_record` defaults an unpriced run to today's + date instead of returning None + -> test_consider's an_offline_answer_carries_no_price_day_at_all, plus + eleven more through `_check_evaluation_shape` + 11. `answer_provenance.required_coverage` stops emitting the price_basis + entry, so the block states an obligation the gate does not enforce + -> test_a_stale_and_unverified_basis_is_reported_as_both and + test_a_price_day_citation_does_not_also_pay_the_staleness_obligation, + plus test_consider's a_case_silent_about_the_price_day_is_refused_on_ + the_production_path + 12. `_paid_path` takes the broadest matching path rather than the narrowest, + which is what plain prefix matching did before #618 + -> test_a_case_covering_exactly_what_the_challenge_asked_for_is_accepted + and test_a_price_day_citation_does_not_also_pay_the_staleness_obligation """ import copy import json @@ -87,13 +110,30 @@ def _basis(**overrides): base = { "source": "snapshot_anchor", "as_of": "2026-01-01", "stale_days": 5, "completeness": "declared_complete", "cost_basis": "average_cost", - "valuation_basis": "unpriced", "reconciliation_ref": None, + # Priced, on a deliberately MIXED frame (#618): NVDA printed on the + # frame's own newest session and PLTR a day earlier, so both halves of + # the price_basis topic have something real to exercise -- the frame + # summary every priced answer owes, and the per-instrument exception a + # single frame date would have hidden (#583 section 2). + "valuation_basis": "priced", "reconciliation_ref": None, + "price_observations": {"as_of": "2026-01-06", + "by_ticker": {"NVDA": "2026-01-06", + "PLTR": "2026-01-05"}}, "state_version": "pb-v1:" + "0" * 64, } base.update(overrides) return base +def _unpriced_basis(**overrides): + """The other lane: no current price reached this book, so it carries no + price observation at all -- not a null, not today's date.""" + base = _basis(valuation_basis="unpriced") + base.pop("price_observations") + base.update(overrides) + return base + + def _consequence(**overrides): base = { "before": {"max_pct": 0.20, "weights": {"NVDA": 0.20}, "top3": 0.55, @@ -207,32 +247,39 @@ def test_a_case_covering_exactly_what_the_challenge_asked_for_is_accepted(): this is where it shows up -- the case here is generated from the block, never hand-written to match it.""" collisions = _rule_collisions() - challenge = _build(rule_collisions=collisions) - answer_provenance.validate_agent_case( - _case_covering(challenge, collisions), basis=_basis(), - consequence=_consequence(), rule_collisions=collisions, user_statements=()) + for basis in (_basis(), _unpriced_basis()): + challenge = _build(basis=basis, rule_collisions=collisions) + answer_provenance.validate_agent_case( + _case_covering(challenge, collisions), basis=basis, + consequence=_consequence(), rule_collisions=collisions, user_statements=()) def test_dropping_any_single_entry_the_challenge_named_is_refused(): """The floor is a floor. Every entry the block named is load-bearing: removing exactly one and leaving the rest intact must be refused, so - the block cannot list an obligation nothing enforces.""" + the block cannot list an obligation nothing enforces. + + Run over both valuation lanes (#618). The priced one is what proves the + price day is enforced and not merely announced; the unpriced one is what + proves nothing was quietly made mandatory on a book that never had a + price observation to cite.""" collisions = _rule_collisions() - challenge = _build(rule_collisions=collisions) - assert challenge["required_coverage"], "fixture must exercise at least one obligation" - for required in challenge["required_coverage"]: - case = _case_covering(challenge, collisions, skip=required["path"]) - try: - answer_provenance.validate_agent_case( - case, basis=_basis(), consequence=_consequence(), - rule_collisions=collisions, user_statements=()) - except answer_provenance.AnswerProvenanceError as exc: - assert required["path"] in str(exc), ( - f"refused, but the message never names the dropped {required['path']}: {exc}") - continue - raise AssertionError( - f"the challenge named {required['path']} as owed, but a case dropping it " - "was accepted -- the block is listing an obligation nothing enforces") + for basis in (_basis(), _unpriced_basis()): + challenge = _build(basis=basis, rule_collisions=collisions) + assert challenge["required_coverage"], "fixture must exercise at least one obligation" + for required in challenge["required_coverage"]: + case = _case_covering(challenge, collisions, skip=required["path"]) + try: + answer_provenance.validate_agent_case( + case, basis=basis, consequence=_consequence(), + rule_collisions=collisions, user_statements=()) + except answer_provenance.AnswerProvenanceError as exc: + assert required["path"] in str(exc), ( + f"refused, but the message never names the dropped {required['path']}: {exc}") + continue + raise AssertionError( + f"the challenge named {required['path']} as owed, but a case dropping it " + "was accepted -- the block is listing an obligation nothing enforces") def test_every_required_coverage_path_is_reachable_from_must_state(): @@ -320,6 +367,95 @@ def test_a_collision_row_with_no_rule_id_is_stated_without_an_anchor(): "an uncitable collision must not be required, or no case on this book is submittable") +# ─────────── 2b. which market session priced the book (#618) ─────────── +# +# Since #611 a `consider` weight is a share of the current close rather than +# of cost, so the same premise re-asked returns different numbers. Without +# the price day in the answer the user cannot tell whether the market moved +# or their own book did -- and "the numbers moved" with no attributable cause +# is the #429-class question that comes back as a dogfood finding. + + +def test_a_priced_answer_owes_the_market_session_it_was_valued_at(): + challenge = _build() + priced = [e for e in challenge["must_state"] if e["topic"] == "price_basis"] + assert priced, "a priced answer that never says which close it used" + frame = priced[0] + assert frame["value"] == "2026-01-06" + assert frame["anchor"] == "basis.price_observations.as_of" + # ... and it is a different fact from the record's own as_of, which is the + # whole reason this exists: one is the last trade, the other is the market. + basis_dates = {e["value"] for e in challenge["must_state"] + if e["topic"] == "basis" and e["anchor"] == "basis.as_of"} + assert basis_dates == {"2026-01-01"} and frame["value"] not in basis_dates + + +def test_an_unpriced_answer_owes_no_price_day_and_is_given_none(): + """The hard rule. An offline answer must not grow a date -- not a null, + not a placeholder, not today's -- and must not be told it owes one.""" + challenge = _build(basis=_unpriced_basis()) + assert not [e for e in challenge["must_state"] if e["topic"] == "price_basis"], ( + "an unpriced book was handed a market session it never observed") + assert not [r for r in challenge["required_coverage"] if r["owes"] == "price_basis"], ( + "an unpriced answer cannot cite a price day, so it must not be required to") + + +def test_an_instrument_off_the_frame_date_is_named_and_a_matching_one_is_not(): + """#583 section 2, one surface over. A frame date alone lets one fresh + close stand in for a stale one, and a date per holding on a same-day + frame is one entry per position saying the same thing. The exceptions + are exactly the instruments the summary does not describe.""" + challenge = _build() + named = {e["detail"]["ticker"]: e["value"] for e in challenge["must_state"] + if e["topic"] == "price_basis" and "detail" in e} + assert named == {"PLTR": "2026-01-05"}, ( + f"only the instrument off the frame date is owed its own entry; got {named}") + same_day = _basis(price_observations={"as_of": "2026-01-06", + "by_ticker": {"NVDA": "2026-01-06", + "PLTR": "2026-01-06"}}) + entries = [e for e in _build(basis=same_day)["must_state"] if e["topic"] == "price_basis"] + assert len(entries) == 1 and "detail" not in entries[0], ( + "a same-day frame owes one sentence, not one per holding") + + +def test_a_dotted_ticker_off_the_frame_date_keeps_its_fact_and_its_identity(): + """`basis.price_observations.by_ticker.2330.TW` walks as by_ticker -> + '2330' -> 'TW' and resolves to nothing. The date is still owed, and the + ticker rides in `detail` -- without it the answer would carry a market + session with no instrument attached.""" + basis = _basis(price_observations={"as_of": "2026-01-06", + "by_ticker": {"NVDA": "2026-01-06", + "2330.TW": "2026-01-05"}}) + entries = [e for e in _build(basis=basis)["must_state"] if e["topic"] == "price_basis"] + dotted = [e for e in entries if e.get("detail", {}).get("ticker") == "2330.TW"] + assert len(dotted) == 1, f"the dotted instrument's session was dropped: {entries}" + assert dotted[0]["value"] == "2026-01-05" + assert "anchor" not in dotted[0], "a path that cannot resolve must not be offered" + + +def test_a_price_day_citation_does_not_also_pay_the_staleness_obligation(): + """One claim, one debt. `basis.price_observations` is the first required + path to sit under another (`basis`), and plain prefix matching let a + single price-day citation discharge staleness too -- a case that never + mentioned the stale record accepted, the whole suite green.""" + basis, consequence = _basis(), _consequence() + case = {"for": [{"claim": "Conviction is intact.", "provenance": "agent_judgment"}], + "against": [{"claim": "Priced at the sixth's closes.", "provenance": "engine_fact", + "anchor": "basis.price_observations.as_of"}]} + try: + answer_provenance.validate_agent_case( + case, basis=basis, consequence=consequence, rule_collisions=(), + user_statements=()) + except answer_provenance.AnswerProvenanceError as exc: + assert "basis" in str(exc), exc + assert "basis.price_observations" not in str(exc), ( + f"the price day WAS cited; only staleness is still owed: {exc}") + return + raise AssertionError( + "a case citing only the price day was accepted on a stale book -- the price " + "citation paid the staleness obligation it never addressed") + + # ─────────── 3. what the gate cannot enforce is still stated ─────────── def test_the_users_own_words_are_carried_verbatim(): @@ -485,9 +621,16 @@ def test_a_stale_and_unverified_basis_is_reported_as_both(): _basis(stale_days=0, completeness="unverified"), _consequence(), ()) assert [r for r in fresh_only if r["owes"] == "basis"][0]["key"] == "unverified" exempt = answer_provenance.required_coverage( - _basis(stale_days=0, completeness="declared_complete"), + _unpriced_basis(stale_days=0, completeness="declared_complete"), _consequence(disclosures=[]), ()) assert exempt == (), "a fresh, declared-complete book with no disclosures owes nothing" + # ... and pricing that same exempt book owes exactly one thing: which + # session it was priced at (#618). Nothing else about the basis moved. + priced = answer_provenance.required_coverage( + _basis(stale_days=0, completeness="declared_complete"), + _consequence(disclosures=[]), ()) + assert [dict(r) for r in priced] == [ + {"path": "basis.price_observations", "owes": "price_basis", "key": "price_observed"}], priced # ─────────── 5. the module's vocabularies and the schema's ─────────── @@ -531,8 +674,17 @@ def test_required_coverage_vocabulary_matches_the_schemas_enums(): answer_provenance's too -- the schema publishes it and nothing else checks that the two still describe the same set.""" schema = _challenge_schema()["properties"]["required_coverage"]["items"]["properties"] - assert set(schema["owes"]["enum"]) == {"disclosure", "basis", "rule_collision"} + assert set(schema["owes"]["enum"]) == {"disclosure", "basis", "price_basis", + "rule_collision"} keys = set(schema["key"]["enum"]) + # #618's own literal. Named here rather than left to the behavioral tests + # because an enum value no code path emits and a code path no enum admits + # are both invisible to them. + assert "price_observed" in keys, ( + "the price-day obligation's key is missing from the schema's key enum") + emitted = {entry["key"] for entry in answer_provenance.required_coverage( + _basis(), _consequence(), _rule_collisions())} + assert emitted <= keys, f"required_coverage emits a key the schema does not publish: {emitted - keys}" assert set(answer_provenance._COVERED_STATES) <= keys, ( "a collision state the gate enforces is missing from the schema's key enum") import consequence as consequence_engine