Skip to content

ledger

A ledger of experiment runs, read from artifacts already on disk.

Deliberately NOT an experiment tracker. It does not wrap training, does not ask for log_metric() calls, and requires no change to how anything is run. Most research projects already leave structured artifacts behind -- config files, per-eval JSON dumps, benchmark results -- and the numbers that end up in a README were transcribed from them by hand. This reads those artifacts so the numbers become derivable, and re-derivable when they change.

Adoption cost is the design constraint. A tool that requires new discipline gets used for a week; one that reads what is already there keeps working while you forget it exists.

Two rules keep it honest:

Record what is unambiguous, refuse to guess the rest. A config file is a specification with no result attached, so it is stored with no metrics rather than an invented one. An unrecognised file shape yields no run rather than a wrong one.

Never rank a metric whose direction is undeclared. WER 0.043 -> 0.053 is a regression; accuracy 0.90 -> 0.94 is an improvement. A comparison that guesses will confidently order an ablation backwards, which is worse than refusing.

Metric dataclass

One final value read from a run's artifacts -- not a logged curve; see the module docstring's per-tracker conventions for what "final" means per adapter. step/split disambiguate a metric a run recorded more than once (a checkpoint, a train vs. eval split).

RunRecord dataclass

One experiment run, as discovered on disk.

adapter_caveats(adapters)

Caveats for the readers that produced a set of runs, deduplicated.

Takes adapter names rather than rows so it is callable from anywhere and testable without a database. Unknown and NULL adapters contribute nothing: NULL means "recorded before this was tracked", and inventing a caveat for a reader we cannot name would be worse than saying nothing.

collapse_to_last(metrics)

A step series collapsed to its last row per metric NAME, first-appearance order kept. Keyed on the name alone: on the live worst case split carried the sweep coordinate, so keying on (metric, split) collapsed nothing and 4 of 33 names survived a row cap. Truncation is the caller's budget, not this function's.

compare(conn, family, metric=None, project=None)

Rank every arm of an ablation family by a metric, within ONE project.

The question this exists for: a sweep of N named config variants is a designed experiment, and which arm won usually lives only in filenames and memory. Arms with no value for the metric are listed in without_metric rather than dropped -- an arm that was never evaluated is a finding.

A family name is unique per PROJECT, not globally: families() and runs.list both present (project, family) as the unit. This selected WHERE family = ? alone, so an English-ASR sweep and a Mandarin-ASR sweep that both called their arms asr_baseline/asr_biglm were pooled into one ranking with a named winner -- and, worse, _corpus_agreement keys on run name, so the collision ERASED the disagreement and the tool reported "all arms on aishell-1" for arms half of which ran on librispeech.

count_runs(conn, project=None, family=None)

How many runs match, ignoring any limit.

list_runs truncates and the caller could not tell: 212 of 222 runs vanished from a runs.list response whose message read "10 run(s)", while the families truncation in the same response was reported exactly.

detail(conn, project, name)

One run in full: its row, decoded config, every recorded metric, and the caveat (if any) for the adapter that read it -- None if no such run exists rather than an empty dict, so a caller can tell "not found" from "found, nothing to report".

families(conn, project=None)

Every named run family and its run count, largest first -- what attest runs list prints below the individual runs so a reader knows what is comparable with runs compare.

list_runs(conn, project=None, family=None, limit=20)

Runs in the ledger, optionally filtered by project/family. Ordered project, family, name -- grouped for display so related runs sit together, not started DESC, so this is not a "most recent runs" query; a caller wanting recency order does its own sort on the result.

metric_direction_path()

Public wrapper for _metric_direction_path, so a non-domain caller can find the same file unknown_direction_message names -- mcp/ provenance.py's runs.record merges declared directions into it and may not import the private name directly (see tests/ test_architecture.py::test_no_mcp_module_imports_a_private_domain_name).

metric_directions()

Built-in METRIC_DIRECTION, overlaid with a user's TOML file if present.

Read fresh on every call (not cached at import time) so tests and a user editing the file are both respected, matching db.embed_dims()'s pattern.

nested_arms(values)

Group a run's metric rows by nested split key, when it has several.

Real artifacts record the arms of an experiment INSIDE one file, under keys like arms.Baseline / arms.Treatment_Eigen, or lm-eval-harness's results.mmlu_marketing. The ledger's unit is the file, so all of those collapse into one run -- and _split_rank returns the same rank for every one of them, so _best_step takes the max across siblings.

Measured on a real corpus: a run reporting Baseline 0.000, Control_RAG 0.644, Oracle_Post 0.988 and Treatment_Eigen 0.655 scored 0.988. Every arm was credited with its own oracle upper bound, and on ~/nota's lm-eval output each model was ranked on its own easiest subtask, which reordered the bottom half of the table.

Returns {} when there is nothing to fan out -- one nested group, or none.

sample_runs(conn, project=None, family=None, limit=10)

Runs to show for a listing: spread across projects when not filtered.

list_runs orders by (project, family, name), so a LIMIT let the alphabetically-first project fill the whole answer -- the live ledger has 18 projects and 858 runs, and a default listing returned 10, all ablation. The count said "10 of 858" and not that they were one project, so an honest count made a partial answer look complete.

A filtered call is already narrowed by the caller and passes straight through.

scan(conn, root, project=None)

Read artifacts under a workspace root into the ledger. Idempotent.

Every subdirectory of root is treated as a project and read by the convention-based adapter -- no project needs to be known in advance. A directory yielding no recognisable runs is reported in empty rather than silently omitted, so "found nothing" is never mistaken for "nothing there".

Replaces a project's rows wholesale rather than merging: the artifacts on disk are the source of truth, so a run that vanished there vanishes here.

unknown_direction_message(metric)

The refusal compare() raises for one named metric with no declared direction -- factored out so attest runs record can print the exact same sentence rather than a paraphrase when a --direction was owed and not given. _no_direction_message is the sibling refusal for "nothing in this family has a known direction"; this one is for a single named metric.

workspace_root(explicit=None)

Where the projects live. Explicit argument, then RESEARCH_ROOT, else None.

Deliberately no default. Guessing a directory would either scan something the user did not mean or silently find nothing; returning None lets the caller say "set RESEARCH_ROOT" instead of reporting an empty success.