Cost instrumentation: per-role spend capture, the reference cost ledger, and a per-experiment rollup (#343) - #346
Open
ealt wants to merge 5 commits into
Open
Cost instrumentation: per-role spend capture, the reference cost ledger, and a per-experiment rollup (#343)#346ealt wants to merge 5 commits into
ealt wants to merge 5 commits into
Conversation
Closes the blindness #343 describes: EDEN could report a run's duration and throughput but not what it cost, so the R2 report hand-estimated ~$35-45 and the live R3 run has no better answer. The executor's Claude Code agent logs already emit `total_cost_usd` per attempt; the platform dropped it. All three of #343's milestones, in one PR because the second two are small deltas on the first's surface. Where cost lives, and why not on the Variant. spec/v0 has no home for spend, verified rather than assumed (#343 asked whether the eval-payload exact-key-match constraint also binds the execution path): on the evaluation path an undeclared key is rejected loudly; on the execution path there is no key validation AND no free-form field, so an extra `cost` key is silently dropped by `submission_from_payload`. Different mechanisms, same conclusion — no smuggling route, and a normative field would mean amending spec MUSTs + conformance, which the balloon-guard on this work put out of scope (the spec-change plan is a comment on #343). So: a non-normative ledger behind the `/_reference/` surface chapter 7 §5 sanctions. `CostLedger` protocol with record/list, a `cost_entry` table on all three backends (SQLite v10 + mirrored Postgres migration), `POST`/`GET /_reference/experiments/{E}/cost` with a matching `StoreClient` pair so a host writes cost identically in-process or across the wire. Kept off the `Store` protocol on purpose (the `ArtifactStore` precedent): a reference extension does not belong in the interface a conforming implementation is measured against. Two ledger properties are load-bearing. First-write-wins on `entry_id` so a re-record after a transport failure cannot double the reported spend — and the key is per-ATTEMPT, not per-task, because a reclaimed and rerun task really did spend twice. Attribution, not aggregation: one row per spend event with the role/task/variant/idea, so every rollup is a read-time reduction. Cost rows carry no event; like artifact metadata they are not bound to any transition, so the chapter-5 §2 invariant has nothing to pair them with. Capture. A host never talks to an LLM itself, so the user's `*_command` reports via two optional keys on the outcome JSON the host already reads (`agent_log`, a stream-json log the host parses; or `cost`, normalized figures) — for the R3-shaped experiment that is one added key naming a log it already writes. The ideator's arrives on its JSON-line terminator, honored on `ideation-error` too since a failed attempt still burned tokens. Recording happens inside the worktree's lifetime and regardless of how the attempt terminalizes. Every failure mode is a no-op, never an error, including an unreachable ledger. Rollup. `summarize()` is a pure reduction into per-role and per-variant totals — not a stored aggregate and not a server-side endpoint, so it cannot drift from the ledger. `cost_report` prints it joined to each variant's status + evaluation payload, which is what makes DCI-per- dollar local instead of a two-source join every consumer rewrites. `entries_missing_cost_usd` keeps a partial total from reading as a complete one. Two refactors the size gate forced, both worth having: `_execute_and_validate` split at the run/validate seam, and the Postgres ledger primitives moved to a `_postgres_cost.py` sibling (the `_postgres_schema.py` precedent) — which also puts the whole non-normative extension behind one labeled file. No slop-allow added. Deferred: cost in checkpoints (#344, needs a normative format bump), a `cost_entry_unpacked` Postgres view (#345). Propose-only on #343: a normative home for cost, AWS cost-allocation tags, an orchestrator budget cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8jE6s96VcMT8MxxPvnxD3
Two failures the CI typecheck caught (and I mis-reported as clean — I read a truncated pyright tail, not the error-count line): - `sql.SQL(...) + Composed` is not typed as addable in psycopg's stubs. Build the query with `sql.Composed(list[Composable])` instead, which is both well-typed and the shape psycopg documents for assembling fragments. - `test_cost_ledger.py` calls the ledger through the `make_store` fixture, which is typed `Store` — and `CostLedger` is deliberately not on that Protocol, so every call is an unknown attribute. Widen with the file-level pragma the repo already uses for exactly this (tests/test_no_op_variant.py), with a comment naming why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8jE6s96VcMT8MxxPvnxD3
Three review additions, all inside the same non-normative surface (no spec change was needed for any of them). Per-idea attribution. `CostEntry.idea_id` existed but only the executor populated it and the rollup had no per-idea bucket, so the ideation-efficiency question — which expensive ideas produced nothing — was unanswerable. The evaluator stamps it from `variant.idea_id`; the ideator stamps it when a dispatch produced exactly ONE idea, because a dispatch that emitted three spent one indivisible gateway call on all three and both picking one and splitting three ways would be inventions. Those entries stay at role/task level and `by_idea` reports how many it excluded. Landing it meant restructuring `handle_ideation_task` so the single record happens in a `finally` after `_persist_ideas` mints the ids: recording last risks losing a cost row on a crash, recording first risked losing the submission, which is worse. Per-model splits preserved. `modelUsage` was collapsed to one label and dropped entirely for multi-model runs. The whole map is now a `models` list on the entry — per-attempt cost stays per-attempt, the split is structure the read-time reduction slices into `by_model` — deliberately without `num_turns` / `duration_ms`, which belong to the attempt and whose per-model share would be fabricated. `by_model` also covers single-model attempts by synthesizing their one slice, which is exact rather than an allocation. Cache writes are additionally captured per TTL tier from `usage.cache_creation`, which is what makes correct cache pricing possible at all. Derived dollars, labelled as derived. `pricing.py` prices tokens against an operator-supplied rate table for attempts no provider priced. Rates are per token class per model (input / cache write 5m / cache write 1h / cache read / output) in USD per Mtok, because cache reads run ~10x cheaper than fresh input and 1h writes materially dearer than 5m — one blended rate can be wrong by a large multiple. Three properties are enforced, not just documented: nothing writes a computed figure into the ledger (derivation is read-time; `total_cost_usd` keeps meaning "the provider said so"), a table MUST carry `source` + `as_of` and both travel into the report so a figure is auditable against the rates that made it, and an unpriced class is a reported gap never a zero. The shipped template has every rate null so it prices nothing until filled in — no unsourced numbers are baked in. Two structural moves: the rollup got its own `rollup.py` (records -> pricing -> rollup is a clean three-layer stack; inlining pricing would have been an import cycle), and `render_table` split into per-section helpers at the length gate. Two bugs worth naming. `models` as a `tuple` was unbuildable from a JSON array under `strict=True` — it made every multi-model entry both unrecordable and un-POSTable. And a per-model row whose basis said `derived` showed $0 until `DerivedCost.per_model` credited each model its own tokens' cost; "derived, $0.0000" reads as "this model was free". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8jE6s96VcMT8MxxPvnxD3
/codex-review on this chunk never ran: Codex's bwrap sandbox cannot create a mount namespace in this container, so every command — including a read-only pwd — failed before execution and it read nothing. Eric elected to ship without the review. Committing the record rather than deleting it, for two reasons. The absence of a codex-review on a planless chunk should be explicit rather than left for a reviewer to wonder about. And the diagnosis is worth more than the failed round: bare bwrap fails the same way with the harness sandbox off, this shell shares PID 1's namespaces, the project is config-trusted, and there is no seccomp filter — so it is none of the usual suspects. It is the container's capability set (no CAP_SYS_ADMIN, so making / slave returns EPERM), and the leading hypothesis is that installing system bubblewrap flipped Codex off a bundled fallback that had been working. BLOCKED.md carries the verbatim errors, the eliminations, the fix options, and a two-second preflight the skill should adopt so a future session fails fast instead of burning a round that reads nothing. The brief (0.md) is complete and accurate; a retry needs only a working sandbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8jE6s96VcMT8MxxPvnxD3
Eric measured the premise and it does not hold: `which -a bwrap` returns /usr/bin/bwrap and /bin/bwrap (same inode), and there is no bwrap in ~/.local/bin at all — so nothing was shadowed, PATH order never mattered, and removing the bubblewrap install would fix nothing. That was the one action the hypothesis recommended, which is why this is a correction in place rather than a deletion. Two things the measurements replaced it with, both sharper: - "This pod cannot be sandboxed" was too strong. The kernel offers Landlock ABI v7 (errno 0), which needs no CAP_SYS_ADMIN. The accurate statement is that *Codex* can no longer reach the sandbox this pod can provide: `use_linux_sandbox_bwrap` is removed and `use_legacy_landlock` deprecated, and the decisive test on that escape hatch produced no file and reported the sandbox failing before execution. - Codex on this pod has been observed both failing honestly and burning 15,303 tokens, replying DONE, and running nothing. So a round's validity cannot be read off its prose: zero tool-execution events means void, not passing. Recorded as a postflight check alongside the existing preflight suggestion. The correction notes what the original inference got right (the string evidence) and what it skipped (testing the displacement claim before writing it down), and marks the replacement version-change explanation as inference rather than measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8jE6s96VcMT8MxxPvnxD3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
total_cost_usdper attempt the whole time and the platform dropped it. This wires up all three milestones of Cost instrumentation: persist per-role token/$ so runs can report what they cost #343: capture in every worker host, a cost ledger to hold it, and a per-experiment rollup to read it./_reference/ledger, not on theVariant— because giving it a normative home means amendingspec/v0MUSTs + conformance, which this work was explicitly scoped away from. The scoped spec-change plan is a comment on #343 instead.Companion producer PR (this repo can't see what a worker spent): ealt/eden-experiments#11.
Follow-up round (commit
09c2514), after review: per-idea attribution, per-model token splits preserved instead of collapsed, and dollars derivable from tokens × an operator-supplied rate table — with derived figures labelled so they can never pass for reported ones. No spec change was needed for any of the three; the ledger stays in/_reference/. Details in the section below.Milestone coverage
total_cost_usdagent_cost.pyparses the terminal{"type":"result"}record of a Claude Code--output-format stream-jsonlog; executor and evaluator hosts record through one shared helper.cost.py/_ops/cost.py/cost_entrytable on all three backends /POST+GET /_reference/experiments/{E}/cost/StoreClientpair.ideation-doneandideation-error). Bridge half is in the eden-experiments PR.summarize()(pure reduction) +uv run python -m eden_service_common.cost_report, joined to each variant's status + evaluation payload so DCI-per-dollar is a local computation. Operator docs:docs/observability.md§2.10.idea_idfrom its variant; ideator stamps it when a dispatch produced exactly one idea. Newby_idearollup bucket + report section pairing each idea's spend with the variants it produced.modelUsagepreserved as amodelslist on the entry (was collapsed to a label, and dropped entirely for multi-model runs) + cache writes captured per TTL tier. Newby_modelbucket.pricing.py+--price-table: tokens × per-class-per-model rates for attempts no provider priced, withbasislabelling and gap reporting throughout.One PR rather than three because M2 and M3 are ~200 lines of delta on M1's surface and touch the same doc sections; the commit is milestone-ordered internally and the CHANGELOG entry separates them.
The design decision worth reviewing
spec/v0has no home for spend. That was verified rather than assumed — #343 named the eval-payload exact-key-match constraint as the known obstacle and asked whether it also binds the execution path. Probed against a real store:InvalidPrecondition: evaluation key 'total_cost_usd' is not in the experiment's evaluation_schema).submission_from_payloadreads named keys only, so an extracostkey is silently dropped. No error, no persistence.Different mechanisms, same conclusion: no smuggling route, and the execution path's silence means an implementation that tried would look like it worked. Hence a ledger behind the
/_reference/surface chapter 7 §5 sanctions for extensions, kept off theStoreprotocol (theArtifactStoreprecedent — a reference extension does not belong in the interface a conforming implementation is measured against; the routerscastexactly like the §16 artifact router).Two ledger properties are load-bearing and worth a second opinion:
entry_id, keyed per attempt. A re-record after a transport failure must not double the reported spend; and the key must identify an attempt rather than a task, because a reclaimed-and-rerun task really did spend twice. Executor keys on its freshly-mintedvariant_id, evaluator on(task_id, variant_id), ideator on a per-dispatch nonce (an ideation dispatch has no stable per-attempt id; nothing retries that call, so one-row-per-dispatch holds by construction rather than by key).Follow-up round: the three design calls worth reviewing
Per-idea attribution stops where it would become invention. The executor and evaluator both know the idea behind their variant, so those attribute cleanly. An ideation dispatch that produced several ideas spent one indivisible gateway call on all of them — so it is attributed to none, because picking one or splitting N ways are both fabrications.
by_ideais therefore a documented partial partition, and reports how many entries it excluded (unattributed.by_idea) so "no idea cost anything" can't be confused with "attribution wasn't available". Landing this required moving the ideator's single cost-record into afinallyafter_persist_ideasmints the ids; that trades one failure window for a better one (a crash between submit and record loses a cost row; the old order would have lost the submission).Per-model splits are kept as structure, not a label.
modelUsageis now preserved whole as amodelslist on the entry — cost stays attributed per attempt, and the split is structure the read-time reduction slices.by_modeldeliberately carries nonum_turns/duration_ms(they belong to the attempt; a per-model share would be fabricated), and it does cover single-model attempts by synthesizing their one slice — attributing a single-model attempt's whole usage to its one model is exact, not an allocation. Cache writes are additionally captured per TTL tier fromusage.cache_creation, which is the precondition for pricing them at all.Derived dollars are labelled, never blended. Three enforced properties, each because its absence is a specific way to mislead:
CostEntry.total_cost_usdkeeps meaning "the provider said so"; derivation is read-time, lands in separatereported_cost_usd/derived_cost_usdfields, and every bucket carries abasisofreported/derived/mixed/unpriced. The table render saysDERIVEDin words.source+as_of, both echoed into the report so a figure is auditable against the rates that produced it — not against whatever the table says later. An unfilled table prices nothing and is markedUNFILLEDin the output.Two bugs from this round worth naming, both caught by driving the real path:
modelsas atupleis unbuildable from a JSON array understrict=True(it made every multi-model entry both unrecordable and un-POSTable), and a per-model row whosebasissaidderiveddisplayed$0.0000untilDerivedCost.per_modelcredited each model its own tokens' cost — "derived, $0.0000" reads as "this model was free", which is worse than no row.Two refactors the complexity gate forced (both worth having)
_execute_and_validatecrossed the 100-line threshold → phases 2d–2e split into_validated_commit_from_outcome.postgres.pycrossed 800 SLOC → the ledger primitives moved to a_postgres_cost.pysibling (the_postgres_schema.py/_postgres_views.pyprecedent), which also puts the whole non-normative extension behind one clearly-labeled file. In the follow-up roundrender_tablecrossed the length gate → split into per-section helpers, and the rollup moved into its ownrollup.py(records → pricing → rollup is a clean three-layer stack; keeping the reduction incost.pywould have been a pricing↔cost import cycle). No# slop-allowannotations added.What this does NOT cover
smoke-checkpoint.shpassed while Checkpoint archives carry an empty git bundle under Compose (no --repo-path on task-store-server) #294 existed for exactly this reason).cost_entry_unpackedPostgres view forEDEN_READONLY_STORE_URLanalysis consumers, so direct-SQL readers cast JSON by hand → Add a cost_entry_unpacked Postgres view for readonly analysis consumers (#343 deferral) #345.Variantfield or first-class record). Four options + a recommendation are on Cost instrumentation: persist per-role token/$ so runs can report what they cost #343; the short version is "first-class record, timed with av1lineage, because the checkpoint format bump argues for a lineage boundary".termination_policykind) is a closed enum in the normative config schema, so it needs either spec surgery or a deployment-level CLI flag; the cap necessarily overshoots by the in-flight work; and unreported cost forces a fail-open-vs-fail-closed decision (recommendation: fail-open with a loud warning, because fail-closed halts every uninstrumented run).resultrecord, so its spend is unrecoverable from the log — the attempt shows in the event log but not the ledger. Attempts that errored but finished do record. Documented indocs/observability.md§2.10 rather than papered over; recovering it would need per-turn accumulation, which disagrees with theresulttotals.reference/pricing/price-table.example.jsonhas every ratenullandas_of: "unset", and in that state it derives nothing. Deliberate per the guard on this work: a number I can't source would be wrong for someone (list vs negotiated, direct vs Bedrock, region) and a wrong rate that looks authoritative is worse than an explicit gap. This is the one thing left for you: supply the rates you actually pay (and their source + date) and every unpriced ideator attempt becomes a dollar figure.usage.cache_creationcarries the 5m/1h breakdown at attempt level (the real capture shows this run's writes were all 1-hour), butmodelUsagereports only an aggregate per model — so cache writes on a multi-model attempt are reported as a gap rather than priced at a guessed tier. Same for any source that reports only the aggregate.EDEN_IDEAS_PER_IDEATION > 1will see those entries underunattributed.by_idea.CostEntryisextra="forbid"on both sides; a new host POSTingmodelsat a pre-follow-up server would 400. Not an issue for Compose/Helm (one image), worth knowing for a partial rollout.modellabel. More than one entry inmodelUsagemeans no single honest label; the aggregatetotal_cost_usdand token counts are still exact. Per-model cost splitting is out of scope (cost attribution here is per attempt).conformance/may depend on it (that would codify a reference-impl quirk as contract). The suite stays green — 260 passed.postgresparametrizations skip. CI'spython-test-postgresjob covers them. The one structural risk the_postgres_cost.pyextraction introduced (MRO position — a bases reorder would silently route to_StoreCore'sNotImplementedErrorstubs) is covered by a server-free guard test.compose-smoke/compose-smoke-subprocess.Fresh-operator walkthrough
The report CLI is a new operator-facing surface, so it was driven end-to-end against a real
task-store-server(in-memory store, fixture experiment config, random port) rather than only unit-tested:StoreClient, then re-recorded one entry to exercise idempotency over the wire: 3 writes → 2 rows.--format table:--role executor(JSON): filter forwarded to the wire,by_variantcarriesstatus+idea_id+evaluation, absent figures absent rather than null.$0.0000, which reads as "this role was free" when the truth is "this role reported tokens but no dollar figure". Added the per-role unpriced column — the header caveat alone wasn't enough, because the per-role rows are what a reader actually scans.Follow-up round walkthrough (re-run against a live server with two ideas, a variant, one provider-priced executor attempt carrying a two-model split, and two token-only gateway attempts) — three passes: no table, the shipped unfilled template, and a filled one.
idea-dudcost $3.30 and produced zero variants — the row the whole per-idea bucket exists to produce. Two more findings fixed from this pass: idea rows had nobasiscolumn (a$0.0000unpriced idea read as free, the same trap as the role rows), and passing the unfilled template printed its placeholdersourcewithout saying it was doing nothing (now markedUNFILLED, nothing derived, plus a stderr warning).Test plan
uv run ruff check .— cleanuv run pyright— 0 errors (after the follow-up commit; the first push failed CI'spython-typecheckon two real errors I had mis-reported as clean by reading a truncated pyright tail rather than its error-count line — a typed-psycopg.sqlcomposition issue and a fixture-attribute widening in the ledger tests)uv run pytest -q— 2506 passed, 264 skipped (follow-up round; 2448 before it) (skips = thepostgresparametrizations + docker-marked tests)uv run pytest -q conformance/ -n auto— 260 passed, 14 skipped (unchanged: nothing in the suite may depend on a non-normative surface)uv run python conformance/src/conformance/tools/check_citations.py— 273 scenarios cite valid MUSTsnpx markdownlint-cli2@0.14.0 "**/*.md" …— 0 errors (121 files)python3 scripts/spec-xref-check.py— all 1271 §-references resolvepython3 scripts/check-rename-discipline.py— clean (it caught two real hits during development: "Promoting" tripped the retiredpromotesynonym, and a backtickedexecutein a comment)python3 scripts/check-complexity.py— 0 blockingEDEN_TEST_POSTGRES_DSNrows (no Postgres server here) and the Compose / Helm smokes (no Docker daemon here) — CI covers both; called out under "does NOT cover"./codex-review— attempted, environment-blocked, shipped without it (operator's call). Codex'sbwrapsandbox cannot create a mount namespace in this container, so round 0 failed before reading a single file. The record is committed atdocs/plans/review/issue-343-cost-instrumentation/impl/20260730T185542/— the brief is complete and accurate, andBLOCKED.mdcarries the verbatim errors plus the diagnosis — not the skill or the invocation, but Codex being unable to reach the sandbox this pod can provide (bwrapneedsCAP_SYS_ADMINthis container lacks; the Landlock backend that needs none is deprecated and non-functional in 0.145.0). The review is being run off-pod instead. So this PR has had no independent peer review — the gates above are all it has.Follow-up-round tests:
test_cost_pricing.pycovers the rate table as configuration (provenance required, per-token unit rejected, malformed table raises rather than silently reading as "no table", unfilled template prices nothing, alias resolution), the per-class arithmetic including both cache tiers priced differently and cache reads far cheaper than fresh input, every gap path (untiered cache writes, missing class rate, unknown model, no model label), multi-model derivation with one unknown model not voiding the others, and the rollup's reported/derived/mixed/unpriced basis + per-model credit. Host-level: single-idea dispatch attributes, multi-idea dispatch attributes to none, failed dispatch records without an idea, evaluator'sidea_idcomes from its variant. Parser: the real capture's per-model split and its 5m/1h tiers verbatim, multi-model preservation, malformedmodelUsageentries skipped not fatal.Original-round tests: ledger semantics parametrized across all three backends (idempotency, cross-backend read order, no-events, experiment-id mismatch, partial entries, validation); the stream-json parser against a real captured log from an eden-experiments belief-state-recovery execution task (reduced to one line per record type, prose/session-ids/hook-output redacted, every
resultnumber verbatim) plus synthesized degradation cases (killed agent, interleaved stderr, truncated final line, tail-cap boundary in both directions,NaN/Infinity, multi-model); wire round-trip incl. self-gated bearer auth on both cost routes; host-level tests driving the real_handle_onefor all three roles — including the relative-agent_logcase, which is the only way to catch a regression that moved extraction after worktree cleanup.Related issues
cost_entry_unpackedPostgres view (filed with this PR)🤖 Generated with Claude Code
https://claude.ai/code/session_01Y8jE6s96VcMT8MxxPvnxD3