Efficiency ledger
The efficiency ledger measures and reports engineering efficiency across landed pull requests and milestone summaries. It aggregates metrics from three optional, bounded sources: forge records, agent session transcripts, and gateway spend-log exports.
This is Slice 1 of the module specified in
#870 (epic
#879, milestone "1.0 — Measure and route cheap").
It provides the praetorctl efficiency command (alias praetorctl efficiency-ledger), the strict
manifest configuration schema, and table/JSON renderers.
Quick start
Declare the efficiency section in .standards.yaml. Every source is optional, and manifest paths
must be repository-relative; a session directory outside the repository (for example under the
home directory) is passed with --transcripts-dir.
efficiency:
sources:
forge:
path: ".workingdir/forge_prs.json"
transcripts:
dir: ".workingdir/sessions"
spend_log:
path: ".workingdir/spend-export.jsonl"
transcripts_via_gateway: false
frontier_models: ["claude-opus-", "claude-sonnet-"]
frontier_classes: ["reasoning"]
light_classes: ["light"]
local_models: ["local", "ollama"]
Every list is optional; an omitted list uses the default below. transcripts accepts dir or
path (the same setting); setting both to different values is a manifest error.
transcripts_via_gateway (bool, default false) controls request counting when both session
transcripts and gateway spend logs join the same unit.
praetorctl efficiency
praetorctl efficiency --milestone="1.0 — Measure and route cheap" --limit=20
praetorctl efficiency --json
praetorctl efficiency --transcripts-dir="$HOME/.claude/projects/<project>" --spend-log=spend.jsonl
--milestone matches the exact milestone title.
Command reference
| Command / Flag | Description | On failure |
|---|---|---|
praetorctl efficiency |
Evaluates merged pull requests and renders table output | unreadable source, manifest error, limit overflow |
--format=table\|json |
Sets the output serialization format (default table) |
invalid format name |
--json |
Convenience alias for --format=json |
|
--milestone=<title> |
Restricts output to landed pull requests under the milestone; applied before --limit |
|
--limit=<N> |
Upper bound on pull requests reported (default 20); a cut is printed under Notes | |
--path=<dir> |
Repository root where .standards.yaml lives (default .) |
|
--manifest=<file> |
Manifest to read instead of <path>/.standards.yaml |
file unreadable or invalid |
--forge-records=<file> |
Overrides forge records source with a specific JSON file | file unreadable, invalid JSON, unknown or repeated key, row without its epoch or a labelled vector field |
--transcripts-dir=<dir> |
Overrides agent session transcripts directory | directory unreadable, limit overflow |
--spend-log=<file> |
Overrides gateway spend-log export file | file unreadable, invalid row, limit overflow |
Sources and bounds
Every source is read under HISS-02 bounds. A bound that is exceeded fails the run with the source and the bound named; nothing is cut off silently.
| Source | Bound | Value |
|---|---|---|
| Transcripts directory | files | 20000 |
| Transcript or spend-log file | lines (records) | 1000000 |
| Transcript or spend-log file | bytes read | 256 MiB |
| Transcript line | bytes | 8 MiB (the harvester line cap) |
| Forge listing | closed pull requests scanned | 2000 (a hit is printed under Notes) |
| Forge listing | closing-issue lookups | 200 (a hit is printed under Notes) |
- Forge records: Pull request records loaded from a local JSON file (
sources.forge.pathor--forge-records) or queried live through the manifest's forge kind and the shared token resolver.
A records file is a JSON array of rows, or an object whose only member is units holding that
array. It is read strictly (forge.ParseMergedPullRequests): an unknown or repeated key, a
second document, null, or an object without units fails the run. Each row carries:
number(or its aliaspull_request_number),head_branch,titleandmilestone. A row without a number, or whose two number fields name different pull requests, fails the run.created_atandmerged_atas RFC 3339 timestamps, andclosing_issueswith theirnumberandcreated_at.disposition: exactly one ofqualified,offered,rejected,abandoned,revertedortimed_out. A row without one, or with any other spelling, fails the run.lane: a lane identifier such asclaude-codeoragy:flash, counted per lane.metric_epoch: the schema epoch tag, currently2026-10-10. A row without it, or a ledger that mixes epochs, fails the run.- All six vector fields, each as
{"value": <v>, "provenance": "<label>"}, ornullwhen the field was not measured. A row that omits a vector field fails the run and names the field.
{"units": [{"number": 12, "head_branch": "feat/x", "title": "Add x",
"created_at": "2026-10-01T10:00:00Z", "merged_at": "2026-10-01T12:00:00Z",
"disposition": "qualified", "lane": "claude-code", "metric_epoch": "2026-10-10",
"tokens_by_provider": {"value": {"anthropic": 1200}, "provenance": "measured"},
"wall_seconds": {"value": 7200, "provenance": "measured"},
"review_rounds": {"value": 1, "provenance": "measured"},
"retries": {"value": 2, "provenance": "interval", "low": 1, "high": 3},
"operator_minutes": {"value": 5, "provenance": "modeled"},
"escaped_defects": null}]}
Revert rule: a qualified row titled Revert "<title>" (the git and GitHub form) or
revert: <title> (the conventional-commit form) marks the latest qualified unit titled
<title> that merged before it as reverted; the revert itself stays qualified. A revert
that is not qualified reverted nothing, a target with another disposition keeps it, a later
re-land under the same title stays qualified, and any other title such as Revert <title>
names no target. Reverts are read from every loaded record, so --milestone and --limit
do not hide them (applyRevertDispositions in internal/efficiency/collector_unit.go).
Live listing: the GitHub listing scans closed pull requests and keeps the merged ones, so a
live run sees only merged units; rejected, abandoned and timed_out units need a records
file. A live row carries no ledger fields of its own, so the collector stamps them
(stampLiveUnit in internal/efficiency/collector_unit.go) and states each choice under
Notes:
metric_epochis the current epoch, because the collector builds the row under the current schema.dispositionisqualified, orrevertedunder the revert rule. Check and review results are not read, and the revert rule sees only the listed pull requests, so a revert outside--limitor--milestoneis missed.wall_secondsismeasured: the pull request's creation to its merge.tokens_by_provider,review_rounds,retries,operator_minutesandescaped_defectsare not measured, because the listing carries no such data. Pass a records file to report them.
The live listing filters by milestone first, sorts by merge time and keeps the newest
--limit. Closing issues come from Closes/Fixes/Resolves #N in the pull request body
(references qualified with a repository are ignored); the issue's created_at is fetched
with a bounded read. Without a token the forge is not measured and the reason is printed
under Notes.
2. Agent session transcripts: A directory of agent session JSONL files, read through the
harvester's line reader and record decoder (8 MiB lines, UTF-8, surrogate and duplicate-key
checks). Subagent sessions below the directory are read; hidden directories are skipped. Responses join to a branch after all their streamed content blocks have been read:
- A non-default
gitBranchkeeps the explicit branch join. The default comes fromrepository.default_branch, then the localorigin/HEAD; when neither is available, the reader treatsmainas default and reports that choice under Notes. - Otherwise, tool inputs supply paths: Bash
cd,git -Cand literal absolute paths; Read/Edit/Writefile_path; NotebookEditfile_pathornotebook_path; and EnterWorktreepath. Relative literal paths use the line'scwd; shell variables and expansions are never evaluated. A path matches the longest known worktree root at a directory boundary, so/w/adoes not match/w/ab. - Root-to-branch evidence comes first from
git worktree list --porcelainunder--path, using the shared worktree reader, then from Bash inputs recordinggit worktree add [<options>] <path> [<commit-ish>]. Before the path, the reader skips-q/--quiet,-f/--force,--lock,--checkout/--no-checkout,--track/--no-trackand--guess-remote, and takes the branch value of-b,-Bor--orphan.--ends option parsing; an unknown option rejects the creation.--detach/-drecords the root without a branch. This retains removed worktrees. A missing branch, conflicting recorded branches, or a commit expression leaves the root unresolved; live branch evidence takes precedence. Recorded creations are considered across all files, regardless of line order. An unavailable live source is named under Notes. - Exactly one resolved branch receives the response usage. Zero or several resolved
branches leave the whole response unattributed. Usage retains the existing dedupe key
(
message.idplusrequestId) and maximum counts across streamed/replayed lines. At most 64 KiB of distinct tool input JSON is scanned per response; overflow fails the read, preserving ambiguity instead of attributing a truncated input.
Branches join to pull requests only by exact head-branch match, through the existing
branch-owner join. When a head branch name was reused, the sessions belong to the pull
request merged last. Table and JSON output carry the token measurement reason for an
unmeasured pull request: worktree never resolved when neither live worktrees nor
recorded creations resolved its branch; ambiguous when responses named its worktrees
only together with other branches; otherwise no transcript response joined the resolved
branch. Ambiguous usage stays unattributed. The transcript_coverage field carries total responses/tokens and unattributed
responses/tokens. Tokens in this coverage include input, cache creation, cache read and
output for every model, rather than only frontier usage. A unit's denominator is its
attributed usage plus unattributed usage in sessions mentioning its branch. Such unresolved
usage may appear beside several units; these per-unit shares must not be summed. The
report-level total counts every response once, including sessions that mention no unit.
Coverage describes branch attribution, independent of gateway deduplication and PR filters;
an attributed branch without a listed pull request is still included in this total.
Notes name the join sources and counts of roots with unknown branches and paths that could
not be resolved. The planted replay is TestEfficiencyTranscriptToolInputTwoWorktrees in
internal/efficiency/transcript_join_test.go. Lines that fail the decoder checks (for
example an unpaired surrogate) are listed under Notes with file and line; I/O errors and bound
overflows fail the run.
3. Gateway spend-log export: Rows of the LiteLLM LiteLLM_SpendLogs table as JSON lines, a JSON
array or CSV (by extension, else by content). Accepted fields:
| Field | Use |
|---|---|
request_id |
repeated ids count once |
model, model_group |
classification (the group, i.e. router class, first) |
spend |
spend; rows with zero spend still count as requests |
total_tokens, prompt_tokens, completion_tokens |
tokens: total_tokens, else prompt plus completion (prompt includes cached input) |
request_tags, metadata.tags |
attribution tags: branch:<name>, a bare branch name, pr:<n>, pull_request:<n> |
metadata.branch, metadata.pull_request, metadata.pr |
attribution |
branch, pull_request, pr, tags |
the same attribution as flat columns |
startTime, call_type, other columns |
accepted, not used |
request_tags and metadata may be JSON values or JSON text (CSV exports). An entry
attributes to a pull request only when a tag or field names its branch or number; every
other entry is unattributed spend.
Measured metrics
For each landed pull request unit the ledger computes:
- Issue-to-merge time: From the earliest closing issue's creation to the merge. A unit without a
fetched closing-issue creation time prints
not measuredand is left out of the average. The pull request's own age is never substituted. The summary says how many units were measured. - Operator touches: User records with
origin.kindhuman. Meta records, compact summaries, sidechains, task notifications, peer messages, hook output and tool results are not touches. - Frontier tokens: Input, cache creation, cache read and output tokens of frontier requests.
A response written as several lines (one per content block) with the same
message.idandrequestIdcounts once, with its final usage. - Spend: Attributed spend from the spend log;
not measuredwhen no entry names the unit. - Prompt-cache hit rate:
cache_read / (input + cache_creation + cache_read)from transcripts. - Local-first ratio: Local requests over all requests of the unit.
- Fact-hit ratio and checks-before-reviews:
not measured (see #897). - Run identity and estimate error: Unit rows report the resolved physical model
(
resolved_model), full run identity (identity: physical model, harness, version, prompt digest, context digest, context bytes, tool-set digest and count, prior rounds, retries, cost estimate), deterministic run key (identity_key), and reconciled estimate error (estimate_error/estimate_error_amount). The error sums only the unit's runs that carry both a cost estimate and an actual cost; with none,estimate_errorreportsnot measured. Outcomes come from--outcomes, which must exist, or the default.workingdir/routing/outcomes.jsonl, which may be absent; a log that cannot be read fails the report. - Sources: Sources that counted for requests and tokens:
transcripts+gateway,gateway,transcripts, ornot measured.
Requests, local-first and frontier tokens join across configured sources. By default
(sources.transcripts_via_gateway: false), transcript requests and gateway entries both count in
full (they represent distinct requests, for example when an agent calls its provider directly
without routing through the gateway). When sources.transcripts_via_gateway: true, the gateway
counts; transcript usage for joined units is not added (they represent the same requests).
Transcript-only metrics (operator touches, prompt-cache hit rate when the gateway carries no cache
fields) still come from transcripts. Every unit row and JSON output name the sources that counted;
when transcripts_via_gateway: true, any unadded transcript requests are reported under Notes
and in the JSON output.
Model classes
A request is frontier when its router class (the model_group, last path segment) is a frontier
class, or, when the class is neither frontier nor light, when its model matches a frontier family.
A light class and any model name carrying a cheap-tier marker are never frontier. A model is local
when its class or model matches a local entry. Families are matched as prefixes of the provider
model ID with an optional provider path stripped.
The default model lists below are family prefixes of current provider IDs, so
gpt-5 matches gpt-5 and gpt-5.1-codex but not gpt-5-mini, and gemini-3 matches the pro
models but not flash or flash-lite.
Default frontier_models: claude-opus-, claude-sonnet-, claude-fable-, gpt-5, gpt-6, gemini-3, gemini-2.5-pro
Default frontier_classes: reasoning, coding
Default light_classes: light
Default local_models: local, cluster, ollama, vllm, gpu-local
Default cheap markers: mini, nano, flash, lite, haiku
Missing sources rule
When a source is omitted, a metric that needs it prints not measured, never zero; the same
holds for a unit that no session or spend entry joins, and for a vector field that is null or
has no live source. Any rate with a zero denominator prints undefined, never zero.
Vector fields and provenance
Cost is a vector of six fields, never one composite score:
| Field | Meaning |
|---|---|
tokens_by_provider |
tokens spent, keyed by provider |
wall_seconds |
wall-clock seconds to landing; a live row measures pull request creation to merge |
review_rounds |
review cycles before landing |
retries |
retry attempts and test re-runs |
operator_minutes |
human operator time in minutes |
escaped_defects |
defects found after landing, in later gates or production |
Every measured field carries one provenance label: measured, modeled, cited or interval.
An interval field also carries low and high with low <= value <= high, over the same
providers as its value; no other label carries bounds. A field without a label, with an unknown
label, with a negative value or with broken bounds fails the run (ValidateRow in
internal/efficiency/vector.go). internal/efficiency/vector.go is the one reader of a vector
field; the forge package hands the field over as raw JSON.
Summary rules
The denominator is the qualified unit: a landed unit whose gates and review passed. Offered,
rejected, abandoned, reverted and timed-out units stay visible in the lane counts, one count per
disposition plus the total over every unit (2 qualified, 1 reverted, 1 offered, 4 total). Each
summary figure follows one of three rules, and its label and text name the rule:
| Rule | Figures | Computation |
|---|---|---|
| Resource per qualified unit | the six vector fields, operator touches, frontier tokens, attributed spend | total over every unit, failures included, divided by the qualified-unit count |
| Latency, qualified mean | issue-to-merge | mean over the qualified units that measured it |
| Ratio, all usage | prompt-cache hit rate, local-first ratio | numerator and denominator summed over the usage of every unit, then divided |
The resource rule keeps the cost of failed units in the numerator, so a lane that lands one unit
out of three pays for all three. The figure reads, for example,
1.50 per qualified unit (total 3 over 3 units / 2 qualified) [measured 2, modeled 1]:
- Provenance mix: the bracket counts the units behind the figure per label, so a total that adds measured and modeled values says so.
- Intervals: when an
intervalfield contributed, the rate and the total print their bounds,3m00s [2m35s, 3m50s], and the JSON output carriestotal_boundsandper_qualified_unit_bounds. - Partial coverage: when only some units measured a figure, the total bounds the cost from
below and prints as
>= ... (lower bound: total ... over k of n units measured ...); the JSON output setslower_bound. - No qualified unit: every per-qualified-unit figure prints
undefined, never0; an empty ledger printsundefinedfor every rate. - Zero-failure claims: escaped defects that every unit measured (provenance
measured) and that total zero print the rule-of-three bound per qualified unit and its basis,0 (rule-of-three bound <= 1.50 per qualified unit; n=2 qualified units, 3 units observed). With no failure in the observed units the expected count is at most 3 (95 %), so at most 3/n per qualified unit. A modeled, cited or interval zero prints as an ordinary rate.
Milestone summary
The summary prints the units, the qualified units, the lane counts in total and per lane, and then:
| Label | Rule |
|---|---|
| Issue-to-Merge (Qualified Mean) | latency |
| Wall Seconds, Review Rounds, Retries, Operator Minutes, Escaped Defects, Tokens by Provider per Qualified Unit | resource |
| Operator Touches, Frontier Tokens, Spend per Qualified Unit | resource |
| Prompt-Cache Hit Rate (All Usage), Local-First Ratio (All Usage) | ratio |
followed by the spend split:
- Attributed: spend of the listed units.
- Other units: spend attributed to pull requests that were loaded but are not listed.
- Unattributed: spend that names no pull request.
- Total: every positive spend in the log.
The Notes section lists everything the run could not do completely: a forge listing cut by a
bound or by --limit, closing issues that could not be fetched, skipped transcript lines, a
missing token and repeated spend-log requests.
Cost ledger
praetorctl efficiency costs --input <file> reads a declared cost ledger and reports what each
session cost by category. Shared overhead is split across sessions by a stated rule, and the
same split is rerun under a second rule to show how much the result depends on the rule. Each
mechanism (a rule, gate, hook, skill or cache) gets its break-even point. This slice reads a
JSON file that you write; nothing yet fills it from transcripts or the gateway spend log.
{
"schema_version": 1,
"allocation": {"rule": "by_tokens", "sensitivity_rule": "by_sessions"},
"entries": [
{"id": "e1", "session": "s1", "category": "task", "tokens": 1200, "spend": 0.04, "provenance": "measured"},
{"id": "e9", "category": "rule_context", "tokens": 8000, "spend": 0.2, "provenance": "measured", "shared": true}
],
"mechanisms": [
{"id": "hook:commit-msg", "kind": "hook", "fixed_cost_tokens": 400, "saving_per_use_tokens": 120, "uses": 10,
"calls_delta": -3, "cost_delta": 0, "latency_delta_s": 2.0}
]
}
| Command / Flag | Description | On failure |
|---|---|---|
praetorctl efficiency costs |
Validates the ledger and renders the cost report | any validation error below, exit 1 |
--input=<file> |
The cost ledger to read | file unreadable, invalid JSON, unknown field |
--format=table\|json |
Output format (default table) |
invalid format name |
--json |
Alias for --format=json |
Categories form a closed list: task, review, retry, orchestration, memory_recall,
mcp_tool_schemas and rule_context. The ledger is refused, naming the entry, when:
- an entry has no category or an unknown one (unassigned cost);
- two entries share an id, or the same session, category, tokens and spend appear under two ids (double-counted cost; both ids are named);
- a shared entry names a session, or an entry that is not shared names none;
- tokens or spend are negative, or provenance is unknown;
- shared entries are present without an allocation rule or sensitivity rule, a rule is unknown, or both rules are the same.
Exact figures. Spend is read as an exact decimal into integer micro-units, so totals never
need a rounding tolerance. Plain and exponent forms are accepted (0.00001 and 1e-05); more
than six decimal places is refused. The report's grand total, and the session totals plus the
shared total, are checked against an independent sum over the entries (checkTotals).
Allocation rules: by_tokens splits shared overhead by each session's own tokens, by_spend
by its own spend, and by_sessions equally. Shares are whole tokens and whole micro-units by the
largest-remainder method, with ties going to the session name first in order. They therefore sum
exactly to the shared total under both rules. Each session shows its share under the rule, its
share under the sensitivity rule, and the difference.
Break-even per mechanism is N* = ceil(fixed_cost_tokens / saving_per_use_tokens); the
mechanism has reached it when uses >= N*. A saving of zero or less prints no break-even. A
mechanism whose calls went down while its cost and latency did not (calls_delta < 0,
cost_delta >= 0, latency_delta_s >= 0) is flagged as a cosmetic saving.
Bounds: at most 10000 entries and 1000 mechanisms, 1e12 tokens and 1e8 currency units per entry. A spend number may be at most 40 bytes, with an exponent of at most 20 either way. The bounds keep every sum inside a 64-bit integer.
Tests: TestCostLedger_Positive, TestCostLedger_Negative_ValidationErrors and
TestCostLedger_Boundary in internal/efficiency/costledger/costledger_test.go;
TestAllocateSharesHamilton, TestReportOverflowBoundary and TestCheckTotals in
internal/efficiency/costledger/allocation_test.go.