Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/maintainer-guide.md

Large diffs are not rendered by default.

54 changes: 48 additions & 6 deletions skills/fomo-kernel/engine/answer_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,8 @@ def required_coverage(basis, consequence, rule_collisions=()):
so `basis` accepts any `basis.*` citation and
`rule_collisions.<id>` 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 ()):
Expand All @@ -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
Expand All @@ -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.<i>` and `rule_collisions.<id>` 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: "
Expand Down
57 changes: 52 additions & 5 deletions skills/fomo-kernel/engine/evaluation_challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
59 changes: 59 additions & 0 deletions skills/fomo-kernel/engine/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions skills/fomo-kernel/references/trade-consequence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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.<id>` 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.<id>` 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.

Expand Down
Loading
Loading