Skip to content

feat(consider): an answer names the market session its numbers came from, and an unpriced one names none (closes #618) - #643

Merged
atomchung merged 2 commits into
mainfrom
claude/issue-618-price-observation-date
Jul 30, 2026
Merged

feat(consider): an answer names the market session its numbers came from, and an unpriced one names none (closes #618)#643
atomchung merged 2 commits into
mainfrom
claude/issue-618-price-observation-date

Conversation

@atomchung

Copy link
Copy Markdown
Owner

A user who asks the same question twice can now tell whether the numbers moved because the market moved or because their book changed.

closes #618. Base main@6723db1 (#629/PR #641, its stated precondition). origin/main had not moved at push time, so the tree tested below is the merged combination.

What the row carries now

basis.price_observations, frozen beside valuation_basis where the was-this-priced fact already lives:

"price_observations": {
  "as_of": "2026-07-30",
  "by_ticker": {"AAA": "2026-07-30", "BBB": "2026-07-29"}
}

Absent — not null, not a placeholder, not today's — whenever valuation_basis is unpriced.

review._price_observation_record builds it from the parsed feed's own observed_date, scoped to the same priced_universe the #629 recovery kit is built over. Both the supplied --prices lane and the resolved lane land on one feed, so it is stamped once in cmd_consider rather than in each of _consider_rows' two book lanes.

Per-ticker vs frame, and why as_of is not the envelope's

Per-ticker, with a frame summary — the issue's preferred option, taken. A frame-level date cannot say which session a given instrument's number came from, which is #583 §2 exactly: one fresh instrument made every stale one look same-day. price_feed.parse already stores observed_date per row, so nothing is derived a second time.

The summary is max(by_ticker) and not feed["as_of"]. That was the one real design finding here. The envelope's declared as_of is an upper bound the parser enforces on its rows, not an observation: a supplied envelope may legitimately declare today while every close in it is yesterday's, and copying it 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 its own as_of for this reason; this is that definition applied to the subset that reached the answer.

must_state follows the same shape: the frame session always, plus a per-instrument entry only where that instrument is off the frame. On a same-day frame that is one sentence, not one per holding; on a mixed frame the exceptions are exactly the instruments the summary does not describe. A dotted ticker (2330.TW) keeps its date and loses its anchor like every other unaddressable fact, so the ticker rides in detail — otherwise the answer would carry a market session with no instrument attached.

Staleness: checked, and nothing added

The issue asked whether a stale quote needs more than a date. It does not, and I added nothing. The reasons already in the tree: market_data.to_price_feed_envelope drops an instrument whose close predates a split (#611) and records a symbol_unpriced gap; trade_recap.price_observations refuses a close more than ten days off the frame's end, so it never becomes a price at all; and price_feed.basis_conflicts refuses the whole call when the supplied closes and the book's share counts are on different split bases. What survives all three is a quote a few sessions old — which a date describes completely. A second staleness concept here would be a threshold nobody has set, on the surface basis.stale_days already establishes the "disclose, never gate" precedent for.

required_coverage, and the latent defect that came with it

The fact is in required_coverage, per the issue's acceptance and #479 Wave B's rule that the two lists are one. Justification for the entry being its own rather than folded into basis: the basis entry is satisfiable by any basis.* citation, so folding it in would let a claim about basis.source discharge the price-day obligation — an obligation nothing enforces, the exact failure the two-directional test exists to catch.

Making it a separate entry surfaced a defect that was already there:

basis.price_observations is the first required path ever to sit under another. Plain prefix matching meant a single price-day citation also covered basis, so a case that never mentioned the stale record was accepted. Nothing caught it, because until now no two required paths nested.

answer_provenance._paid_path is the repair — one claim pays the narrowest obligation it matches — and it is a byte-for-byte no-op wherever the required paths are disjoint, which is every case that existed before this PR (consequence.disclosures.<i> and rule_collisions.<id> never nest). Mutation D below is the proof it is load-bearing. This is the one thing outside the issue's three-change scope, and it is not optional: without it, the acceptance criterion "dropping it from an --agent-case is refused, both directions" cannot hold.

The two CLI observations

Same fictional two-position root, same premise, isolated --root, TR_OFFLINE=1.

Offline / unpriced — no date appears anywhere on the row:

evaluation_id: eval-57eba3f7905fb86b
valuation_basis: unpriced
price_observations present on row: False
basis keys: [as_of, completeness, cost_basis, reconciliation_ref, source,
             stale_days, state_version, valuation_basis, valuation_coverage]
price_basis entries in must_state: []
price_basis in required_coverage: []
every date-shaped string in the whole payload: 2026-01-02 (ledger anchor),
             2026-01-03 (premise date), 2026-07-31 (created)

With a --prices envelope — the date is on the row and in must_state. The envelope also prices CCC, which the book does not hold; it is correctly not dated.

evaluation_id: eval-10be32e0f6cc35c9
valuation_basis: priced
price_observations: {"as_of": "2026-07-30",
                     "by_ticker": {"AAA": "2026-07-30", "BBB": "2026-07-29"}}

must_state price_basis:
  {"topic": "price_basis", "value": "2026-07-30",
   "anchor": "basis.price_observations.as_of"}
  {"topic": "price_basis", "value": "2026-07-29",
   "anchor": "basis.price_observations.by_ticker.BBB", "detail": {"ticker": "BBB"}}

required_coverage (tail):
  {"path": "basis", "owes": "basis", "key": "stale"}
  {"path": "basis.price_observations", "owes": "price_basis", "key": "price_observed"}

evaluation_id: which moved, which did not

Call Before After Right because
Context-free, unpriced eval-57eba3f7905fb86b eval-57eba3f7905fb86b The basis is byte-identical; nothing was added to an answer that observed nothing. An existing user's plain re-ask still converges on the row already on disk.
Same premise, --prices eval-c2f80a5ba7b666a3 eval-10be32e0f6cc35c9 The id seeds on the frozen answer, and the frozen answer genuinely differs — it now carries the sessions its numbers came from.
_evaluation_id(_PINNED_*) eval-a8d8c02f625ce105 eval-a8d8c02f625ce105 The literal digest that may never move. _PINNED_BASIS is unpriced and gains no key; _evaluation_id synthesizes nothing.

Mutation table

Backed up and restored with cp, __pycache__ cleared between runs, exit codes read rather than output grepped. All three engine files verified byte-identical to their backups afterwards.

# What I broke Reddened Restored
A _price_basis_entries returns [] — the field never reaches must_state 7 tests: test_a_priced_answer_owes_the_market_session_it_was_valued_at, ..._an_instrument_off_the_frame_date_is_named..., ..._a_dotted_ticker_off_the_frame_date..., test_every_topic_appears_and_in_the_declared_order, test_every_required_coverage_path_is_reachable_from_must_state, test_a_case_covering_exactly_what_the_challenge_asked_for_is_accepted, test_dropping_any_single_entry_the_challenge_named_is_refused; plus test_consider::..._the_priced_answer_owes_the_session_and_names_the_instrument_off_it and ..._a_case_silent_about_the_price_day_is_refused... 26/26
B _price_observation_record defaults an unpriced run to today's date over the requested universe 14 test_consider tests, headed by test_an_offline_answer_carries_no_price_day_at_all (an offline consider grew a price observation: {'as_of': '2026-07-31', ...}); the rest through _check_evaluation_shape's no-fake-date assertion 114/114
C required_coverage stops emitting the price_basis entry — stated, never enforced test_consider::..._a_case_silent_about_the_price_day_is_refused_on_the_production_path (expected failure, got exit 0 — the refusal stopped firing), ..._the_priced_answer_owes_the_session..., and 2 challenge tests 26/26
D _paid_path takes the broadest matching path — i.e. the prefix matching that existed before this PR test_a_price_day_citation_does_not_also_pay_the_staleness_obligation, test_a_case_covering_exactly_what_the_challenge_asked_for_is_accepted, and test_consider::..._a_case_silent_about_the_price_day... 26/26

No mutation stayed green.

Test gate

PASS  Persona sweep (personas x locales x reviews x variants)  (tests/persona_sweep.py)

PASS: all 49 suites passed.

python3 tests/run_all.py, real exit code 0 (captured directly — piping to tail masks it, which is how an earlier red run in this session first read as green).

Notes for review

🤖 Generated with Claude Code

…rom, and an unpriced one names none (closes #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 made
every number a function of a market day the answer never stated. A user who
asked twice could see that the numbers moved with no way to tell whether
the market moved or their own book did.

The basis now freezes `price_observations` beside `valuation_basis`, where
the was-this-priced fact already lives: `by_ticker`, each instrument's own
observation session, and `as_of`, the newest of them. Per instrument rather
than one frame date, because a frame-level date cannot say which session a
given instrument's number came from and one fresh close then makes every
stale one look same-day (#583 §2); `price_feed.parse` already stores each
row's `observed_date`, so nothing is derived twice. The summary is
`max(by_ticker)` and not the envelope's declared `as_of`, which is an upper
bound the parser enforces on its rows rather than an observation — a
supplied envelope may declare today over yesterday's closes, and copying
that forward would put the summary ahead of every number it summarizes.
Scoped to the same `priced_universe` the recovery kit is built over, so an
envelope carrying a close for an instrument no number here uses does not
date it.

An unpriced run grows nothing: no null, no placeholder, no today's date.
`_price_observation_record` returns None and the key is simply absent.

The obligation moves through the existing mechanism in both directions, per
#479 Wave B's rule that what the agent is told it owes and what a case is
refused for dropping are one list. `build_challenge` states it under a new
`price_basis` topic — the frame session always, plus a per-instrument entry
only where that instrument is off the frame, which is the case a single
date would have hidden — and `required_coverage` enforces it, so a
`--agent-case` that never cites the price day is refused before anything is
stored.

That widening exposed a latent defect and carries its repair.
`basis.price_observations` is the first required path ever to sit under
another, and plain prefix matching let one price-day citation discharge the
`basis` staleness obligation too: a case that never mentioned the stale
record was accepted, whole suite green. `_paid_path` is the fix — one claim
pays the narrowest obligation it matches — and it is a no-op wherever the
required paths are disjoint, which is every case that existed before this.

A priced call's `evaluation_id` moves, correctly: the id seeds on the
frozen answer and the answer genuinely differs. A context-free unpriced
call's does not, and the literal digest
`test_a_context_free_evaluation_id_is_exactly_what_it_was_before_context_existed`
pins is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@atomchung

Copy link
Copy Markdown
Owner Author

Review — verified independently, merging

Merged the just-landed #546 in and ran the combination with the exit code read directly (not through a pipe — see your own note below): PASS: all 49 suites passed, exit 0.

The identity invariant, checked by running both trees

Same offline call on main@ad4289e and on this branch:

main   : eval-f874dc4d637a0f3a | price_observations present: False
branch : eval-f874dc4d637a0f3a | price_observations present: False

Byte-identical, and the unpriced row grows no key at all — not a null, not a placeholder. Priced, the same premise gives eval-552c21e4e33b3fb9, which is correct: the id seeds on the frozen consequence and the answer genuinely differs.

"price_observations": {"as_of": "2026-07-30",
                       "by_ticker": {"AMD": "2026-07-30", "MRVL": "2026-07-30", "NVDA": "2026-07-30"}}
must_state:         {"anchor": "basis.price_observations.as_of", "topic": "price_basis", "value": "2026-07-30"}
required_coverage:  {"path": "basis.price_observations", "owes": "price_basis", "key": "price_observed"}

The bundled _paid_path fix — probed directly, and your reading is exact

This is the part I looked hardest at, because it changes a primitive every required_coverage check goes through. Ran it against both path sets:

1. disjoint paths (every pre-#618 case) — no-op confirmed
   consequence.disclosures.0.text  -> consequence.disclosures.0
   rule_collisions.r1.state        -> rule_collisions.r1
   premise.qty                     -> None

2. nested paths (#618)
   basis.price_observations.as_of  -> basis.price_observations
   basis.as_of                     -> basis

3. the regression it prevents, obligations = [basis, basis.price_observations],
   agent cites ONLY the price day:
   OLD prefix rule leaves unmet: []          <-- staleness silently discharged
   NEW rule leaves unmet:        ["basis"]

So the claim holds exactly: without this, #618 would have weakened an existing gate rather than added one. That makes it a regression this change introduces, not adjacent cleanup — the right call, and flagging it explicitly instead of folding it in silently is what let me check it in three commands rather than finding it later.

The frame-summary decision is the better call, and better than the issue's

max(by_ticker) rather than feed["as_of"] is right for a reason the issue did not anticipate: the envelope's declared as_of is an upper bound the parser enforces on rows, not an observation, so a supplied envelope can declare today over yesterday's closes. Copying it forward would put the summary ahead of every number it summarizes#583 §2 at frame level, which is the exact failure the per-ticker decision exists to avoid. Naming the frame always and a per-instrument entry only where an instrument is off it is the right shape: one sentence on a same-day frame, an explicit exception on a mixed one.

Staleness: agreed, nothing to add

market_data drops a close predating a split, price_observations refuses one more than ten days off the frame, and basis_conflicts refuses a mismatched split basis. What survives is a quote a few sessions old, which a date describes completely. A second staleness concept would be an unset threshold on a surface where basis.stale_days already settled "disclose, never gate".

Your two notes

run_all.py | tail masking the exit code is a real trap and worth more than a footnote — it is the same family as the assertions in this repository's memory about suites that print passed while red. Worth its own hygiene issue if it can be caught mechanically; the runner could refuse to print its own PASS line when any suite failed, so the text and the exit code cannot disagree.

PR #614's docs/maintainer-guide.md overlap noted — your rows are 139/141, its edit is ~134, semantically disjoint. Whichever lands second rebases.

Merging.

@atomchung

Copy link
Copy Markdown
Owner Author

Correction to my own note above, before it sends anyone to build something that exists: run_all.py already prints an unambiguous verdict — FAIL: {n}/{m} suites failed. Do not merge or push. on failure, PASS: all {n} suites passed. on success, and it returns 1 if failed else 0. The text and the exit code cannot disagree.

So the trap is narrower than I made it: it is $? after a pipe picking up tail's status, which is plain shell semantics, not a runner defect. Reading the last line of the summary is reliable and needs no change. Not worth an issue.

@atomchung
atomchung merged commit 3e174d9 into main Jul 30, 2026
3 checks passed
@atomchung
atomchung deleted the claude/issue-618-price-observation-date branch July 30, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant