spec: 0029 run-registry — index & compare a sweep family
Cycle D of the milestone "The World — parameter-space & sweep" (#33). A persistent, append-only run registry: each run's (manifest, metrics) is a record in runs/runs.jsonl; a read surface lists all records and ranks them best-first by a named metric — C18's "compare experiments over time", which has no home in git or Gitea. First cut = persist + list + rank; promotion/ status, lineage, run-diff, on-demand re-derivation, run_id, and Aura.toml runs-dir wiring are deferred. Contract-level change: C16's blanket "zero-external-dependency by commitment" is amended to a considered, per-case dependency policy (scrutinize what enters the frozen artifact; standard vetted crates like serde/rayon pass that review; never hand-roll what they do). The engine/project split + three-tier node reuse of C16 are unchanged. This admits serde/serde_json — the typed read-path ranking needs, and which closes #17 (typed RunReport handle). Shape: serde derives on RunManifest/RunMetrics/RunReport + Timestamp; SweepPoint carries a full RunReport (closing the manifest-per-point gap cycle C deferred to #33); new crate aura-registry (open/append/load/rank_by); CLI gains aura runs list / aura runs rank <metric> and aura sweep persists each point. Three iterations, first planner handoff = iteration 1 (foundation). Sign-off: spec_auto_sign was enabled for this cycle; the five-lens spec-skeptic panel returned 3 SOUND + 2 BLOCK (rank-ordering semantics; iteration-cut compile-ordering + Scalar->f64 coercion). Both BLOCKs were genuine defects, fixed before sign-off (rank is now best-first per-metric; the closure-type change lands with its callers in one green iteration; the coercion is shown). Non-unanimous panel routed the spec to human sign-off, granted by the user. refs #33
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
# Run Registry — index & compare a sweep family — Design Spec
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** orchestrator + Claude
|
||||
|
||||
## Goal
|
||||
|
||||
Cycle D of the milestone *The World — parameter-space & sweep* (#33). A sweep
|
||||
(cycle C) already produces a family of disjoint runs. This cycle makes that
|
||||
family — and runs across separate invocations — **persistent, listable, and
|
||||
comparable over time**: the substance of C18's run registry, which has no home
|
||||
in git or Gitea.
|
||||
|
||||
First cut (this cycle): **persist + list + rank**. Each run's `(manifest,
|
||||
metrics)` is appended as one record to a `runs/` store; a read surface lists all
|
||||
stored records and ranks them by a named metric. Determinism (C1/C12) makes the
|
||||
record a faithful, reproducible descriptor of its run.
|
||||
|
||||
Deferred to later iterations / follow-up issues (named here so the cut is
|
||||
honest): promotion/status labels, the lineage graph (composite←signals,
|
||||
run←inputs), run-diff, on-demand re-derivation of full results, a `run_id`
|
||||
content digest, and `Aura.toml` runs-dir wiring.
|
||||
|
||||
This cycle also makes a **contract-level change**: it amends C16 (see
|
||||
*Architecture → C16 amendment*).
|
||||
|
||||
## Architecture
|
||||
|
||||
### C16 amendment (load-bearing, user-ratified)
|
||||
|
||||
C16 today carries a blanket clause: the engine workspace is
|
||||
*"zero-external-dependency by commitment"*, with `aura-ingest` as the sole
|
||||
*"external-dependency firewall"*. That blanket is **struck** and replaced by a
|
||||
**considered, per-case dependency policy**:
|
||||
|
||||
> Dependencies are admitted by deliberate, per-case review — what a crate pulls
|
||||
> in is weighed against what it buys — with **particular scrutiny for anything
|
||||
> that enters the frozen deploy artifact** (C13: this bot = this commit).
|
||||
> Well-established standard crates (`serde`, `rayon`, …) pass that review and are
|
||||
> used wherever they do the job, including in the bot. There is **no blanket
|
||||
> zero-dependency commitment** and **no blanket admission**; hand-rolling what a
|
||||
> vetted standard crate already does is the anti-pattern, not the dependency.
|
||||
|
||||
What of C16 is **unchanged**: the engine/project split and the three-tier node
|
||||
reuse (`aura-std` / shared node crates / project-local `nodes/`). `aura-ingest`
|
||||
remains the data-source ingestion edge — it is simply no longer framed as the
|
||||
*only* place an external crate may appear.
|
||||
|
||||
C14's *"hand-rolled JSON"* rationale falls with the zero-dep clause. This cycle
|
||||
keeps the `RunReport::to_json` writer (it now drives **all** stdout); `serde` is
|
||||
added *alongside* it for the registry's storage/parse layer, and the
|
||||
stdout→serde unification (one shape for stored and printed records) is a
|
||||
follow-up. Two stdout consequences:
|
||||
|
||||
- `aura run` stdout is **byte-stable** (still `run_sample().to_json()`).
|
||||
- `aura sweep` stdout **changes shape**: the per-point hand-rolled
|
||||
`sweep_point_to_json` (`{params, metrics}`) is **retired** in favour of each
|
||||
point's `RunReport::to_json` (`{manifest, metrics}`) — a consistency win
|
||||
(`aura run`, `aura sweep`, `aura runs` now all print the same RunReport shape)
|
||||
and the natural consequence of `SweepPoint` carrying a full report. The
|
||||
cycle-C params-only golden is updated accordingly.
|
||||
|
||||
**Per-case justification for the dependencies this cycle admits** (the policy
|
||||
requires one, sharper where it reaches the frozen artifact):
|
||||
|
||||
- `serde` + `serde_json` (derive feature). *Buys:* a typed read-path for the
|
||||
registry record (parse a stored line back into a `RunReport`) and idempotent
|
||||
serialization — the exact capability ranking/comparison needs, and the thing
|
||||
C14's writer-only JSON could never give. *Pulls in:* `serde`, `serde_derive`,
|
||||
`serde_json`, `itoa`, `ryu`, `memchr` — a tiny, ubiquitous, deterministic,
|
||||
heavily-audited closure. *Frozen-artifact reach:* the derives land on
|
||||
`RunReport`/`RunManifest`/`RunMetrics` (in `aura-engine`) and `Timestamp` (in
|
||||
`aura-core`), both linked by the frozen bot. Justified: `serde` is the
|
||||
single most-vetted crate in the ecosystem and its output is deterministic
|
||||
(C1-safe).
|
||||
|
||||
### Where the registry lives
|
||||
|
||||
A **new crate `aura-registry`** (recommended over an `aura-cli` module): C18
|
||||
calls the run registry a first-class concern. A dedicated crate keeps
|
||||
`aura-engine` pure compute (no filesystem I/O in the run-loop crate) and is the
|
||||
natural home as the registry grows (lineage, promotion/status, run-diff). It
|
||||
depends on `aura-engine` for `RunReport` and on `serde_json` for storage/parse.
|
||||
|
||||
### The family becomes self-describing
|
||||
|
||||
`SweepPoint` today carries only `RunMetrics`. To index a family the registry
|
||||
needs each point's full `(manifest, metrics)`. So `SweepPoint` carries a full
|
||||
**`RunReport`** — closing the manifest-per-point gap cycle C explicitly deferred
|
||||
to #33. The sweep's per-point closure returns `RunReport` instead of
|
||||
`RunMetrics`; the engine builds nothing new (the manifest is caller-supplied, as
|
||||
report.rs already documents).
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### The user-facing program (acceptance-criterion evidence)
|
||||
|
||||
```text
|
||||
$ aura sweep
|
||||
{"manifest":{"commit":"unknown","params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=0.0001)"},"metrics":{"total_pips":-0.13,"max_drawdown":0.17,"exposure_sign_flips":1}}
|
||||
… three more points …
|
||||
# each point now prints its full RunReport (manifest+metrics, like `aura run`)
|
||||
# — cycle C printed {params,metrics}; AND each point is appended to runs/runs.jsonl
|
||||
|
||||
$ aura runs list
|
||||
# every record ever stored, across invocations, one per line (to_json shape)
|
||||
{"manifest":{…fast=2,slow=4…},"metrics":{"total_pips":-0.13,…}}
|
||||
…
|
||||
|
||||
$ aura runs rank total_pips
|
||||
# the substance of #33 — the family compared, BEST first (total_pips: higher-is-better → desc)
|
||||
{"manifest":{…fast=3,slow=4…},"metrics":{"total_pips":-0.09,…}}
|
||||
{"manifest":{…fast=2,slow=5…},"metrics":{"total_pips":-0.112,…}}
|
||||
…
|
||||
|
||||
$ aura runs rank max_drawdown
|
||||
# best-first is per-metric: max_drawdown is lower-is-better → ascending (shallowest drawdown first)
|
||||
{"manifest":{…fast=3,slow=4…},"metrics":{…"max_drawdown":0.09,…}}
|
||||
…
|
||||
|
||||
$ aura runs rank bogus_metric
|
||||
aura: unknown metric 'bogus_metric' (known: total_pips, max_drawdown, exposure_sign_flips)
|
||||
# exit code 2 — strict, like every other aura arg error
|
||||
```
|
||||
|
||||
### Implementation shapes (secondary)
|
||||
|
||||
**serde derives (before → after).** In `crates/aura-core/src/scalar.rs`:
|
||||
|
||||
```rust
|
||||
// before
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Timestamp(pub i64);
|
||||
// after — newtype struct serializes transparently as its inner i64,
|
||||
// matching to_json's [from,to] window shape
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Timestamp(pub i64);
|
||||
```
|
||||
|
||||
In `crates/aura-engine/src/report.rs`, the same two derives are added to
|
||||
`RunMetrics`, `RunManifest`, and `RunReport` (no field changes). `Scalar` is
|
||||
**not** derived — the registry serializes `RunReport`, never a raw `Scalar`.
|
||||
|
||||
**`SweepPoint` carries a report (before → after).** In
|
||||
`crates/aura-engine/src/sweep.rs`:
|
||||
|
||||
```rust
|
||||
// before
|
||||
pub struct SweepPoint { pub params: Vec<Scalar>, pub metrics: RunMetrics }
|
||||
pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
|
||||
where F: Fn(&[Scalar]) -> RunMetrics + Sync { … }
|
||||
|
||||
// after — the point keeps its typed enumeration coordinate AND the full result
|
||||
pub struct SweepPoint { pub params: Vec<Scalar>, pub report: RunReport }
|
||||
pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
|
||||
where F: Fn(&[Scalar]) -> RunReport + Sync { … }
|
||||
```
|
||||
|
||||
The thread-fan-out internals are untouched except the collected element type
|
||||
(`(usize, RunMetrics)` → `(usize, RunReport)`). `SweepFamily` is unchanged in
|
||||
shape (`{ points: Vec<SweepPoint> }`).
|
||||
|
||||
**The registry (new `crates/aura-registry/src/lib.rs`).**
|
||||
|
||||
```rust
|
||||
use std::path::{Path, PathBuf};
|
||||
use aura_engine::RunReport;
|
||||
|
||||
/// An append-only run registry over a JSONL file: one serde_json line per
|
||||
/// RunReport. The stored shape is serde's; display is the caller's concern
|
||||
/// (the CLI prints via RunReport::to_json for stdout consistency).
|
||||
pub struct Registry { path: PathBuf }
|
||||
|
||||
impl Registry {
|
||||
/// Bind to a JSONL path. No I/O; the file is created lazily on first append.
|
||||
pub fn open(path: impl AsRef<Path>) -> Registry { Registry { path: path.as_ref().to_path_buf() } }
|
||||
|
||||
/// Append one record as a single JSON line. Creates the file (and parent
|
||||
/// dir) if absent.
|
||||
pub fn append(&self, report: &RunReport) -> std::io::Result<()> { /* serde_json::to_string + writeln in append mode */ }
|
||||
|
||||
/// Parse every non-empty line back into a typed RunReport, in file order.
|
||||
/// A missing file is an empty registry (Ok(vec![])), not an error.
|
||||
pub fn load(&self) -> Result<Vec<RunReport>, RegistryError> { /* read; serde_json::from_str per line */ }
|
||||
}
|
||||
|
||||
/// Sort reports **best-first** by a named metric. "Best" is per-metric, fixed by
|
||||
/// each metric's meaning (spec 0009): `total_pips` higher-is-better → descending;
|
||||
/// `max_drawdown` (a positive magnitude) and `exposure_sign_flips` (a churn
|
||||
/// count) lower-is-better → ascending. So `aura runs rank <metric>` always puts
|
||||
/// the genuinely best run first, whichever metric. Ties keep file (insertion)
|
||||
/// order — a stable sort over `load()`'s file-order vector. `f64` keys use
|
||||
/// `partial_cmp` (total, since metrics are finite by construction). Unknown
|
||||
/// metric is an error the CLI maps to exit 2.
|
||||
pub fn rank_by(reports: Vec<RunReport>, metric: &str) -> Result<Vec<RunReport>, RegistryError> { … }
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RegistryError {
|
||||
Io(std::io::Error),
|
||||
Parse { line: usize, source: serde_json::Error },
|
||||
UnknownMetric(String),
|
||||
}
|
||||
```
|
||||
|
||||
**CLI dispatch (before → after)** in `crates/aura-cli/src/main.rs`:
|
||||
|
||||
```rust
|
||||
// before
|
||||
["sweep"] => print!("{}", sweep_report()),
|
||||
// after — sweep_report now also persists each point; new read subcommands
|
||||
["sweep"] => print!("{}", sweep_report()), // persists as a side effect
|
||||
["runs", "list"] => print!("{}", runs_list(&default_registry())),
|
||||
["runs", "rank", m] => match runs_rank(&default_registry(), m) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(e) => { eprintln!("aura: {e}"); std::process::exit(2); }
|
||||
},
|
||||
```
|
||||
|
||||
The per-point sweep closure returns a `RunReport`. The manifest's
|
||||
`params: Vec<(String, f64)>` is built by zipping the `param_space()` names onto
|
||||
the point's `Vec<Scalar>` values, **coercing each `Scalar` to `f64`** — the
|
||||
manifest's params are `f64`-typed, but a grid point carries `Scalar::I64`
|
||||
(SMA lengths) and `Scalar::F64` (the scale):
|
||||
|
||||
```rust
|
||||
fn scalar_as_param_f64(s: &Scalar) -> f64 {
|
||||
match s {
|
||||
Scalar::I64(n) => *n as f64, // fast=2 -> 2.0
|
||||
Scalar::F64(f) => *f, // scale=0.5 -> 0.5
|
||||
// numeric tuning params only; Bool/Ts never appear in a tuning grid
|
||||
other => unreachable!("non-numeric sweep param: {other:?}"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The fixed manifest facts — `commit`/`window`/`seed`/`broker` — are exactly those
|
||||
`run_sample` already builds. `sweep_report` (after iteration 3) appends each
|
||||
`point.report` to `default_registry()` and prints it via `RunReport::to_json`
|
||||
(the hand-rolled `sweep_point_to_json` is retired — the report renders itself).
|
||||
`default_registry()` binds `runs/runs.jsonl` under cwd.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Crate / file | Change |
|
||||
|---|---|---|
|
||||
| C16 amendment | `docs/design/INDEX.md` | strike blanket zero-dep; record per-case policy |
|
||||
| workspace deps | `Cargo.toml` | add `[workspace.dependencies]` with `serde` (derive) + `serde_json` |
|
||||
| `Timestamp` serde | `crates/aura-core/src/scalar.rs` | add `Serialize`/`Deserialize` derives; `serde` dep |
|
||||
| report serde | `crates/aura-engine/src/report.rs` | derives on `RunMetrics`/`RunManifest`/`RunReport`; `serde` dep |
|
||||
| `SweepPoint` | `crates/aura-engine/src/sweep.rs` | `metrics: RunMetrics` → `report: RunReport`; closure `-> RunReport` |
|
||||
| `aura-registry` | `crates/aura-registry/` (new) | `Registry::{open, append, load}` + `rank_by` + `RegistryError` |
|
||||
| CLI subcommands | `crates/aura-cli/src/main.rs` | `sweep` persists; `runs list`; `runs rank <metric>`; USAGE |
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
aura sweep
|
||||
→ GridSpace enumerates N points
|
||||
→ sweep(): per point, fresh blueprint → bootstrap_with_params → run
|
||||
→ drain sinks → summarize → RunReport{manifest, metrics}
|
||||
→ SweepFamily{ points: [SweepPoint{params, report}, …] }
|
||||
→ for each point: Registry::open("runs/runs.jsonl").append(&point.report) [side effect]
|
||||
→ stdout: each point.report.to_json() [full RunReport shape; cycle-C params-only golden updated]
|
||||
|
||||
aura runs rank total_pips
|
||||
→ Registry::open("runs/runs.jsonl").load() -> Vec<RunReport> (serde_json::from_str per line)
|
||||
→ rank_by(reports, "total_pips") -> sorted desc
|
||||
→ stdout: each report.to_json()
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Append:** creates the file and its parent dir on first write; an I/O failure
|
||||
surfaces as `RegistryError::Io` → the CLI prints `aura: <err>` and exits 2.
|
||||
- **Missing registry on read:** `load()` returns `Ok(vec![])` — an empty
|
||||
registry is not an error; `aura runs list` on a fresh tree prints nothing and
|
||||
exits 0.
|
||||
- **Malformed stored line:** `load()` returns `RegistryError::Parse { line, … }`
|
||||
naming the 1-based line — a corrupt record is loud, never silently skipped.
|
||||
- **Unknown metric:** `aura runs rank bogus` → `RegistryError::UnknownMetric` →
|
||||
`aura: unknown metric 'bogus' (known: …)` and exit 2.
|
||||
- **Non-finite f64:** `RunMetrics`/manifest params are finite by construction
|
||||
(report.rs); `serde_json` would error on NaN/∞, which cannot occur here.
|
||||
- **Strict argv:** `aura runs`, `aura runs rank` (no metric), `aura sweep extra`,
|
||||
etc. are usage errors → `aura: <USAGE>` and exit 2, consistent with the
|
||||
existing whole-vector match.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **`aura-core`:** a round-trip unit test — `Timestamp` serde survives
|
||||
`to_string`→`from_str`.
|
||||
- **`aura-engine`:** `RunReport` serde round-trip (a built report → JSON → back,
|
||||
equal). `sweep.rs` tests updated for `point.report` (the closure now returns
|
||||
`RunReport`); the existing determinism/odometer/distinct-metrics properties are
|
||||
preserved, reading `point.report.metrics`.
|
||||
- **`aura-registry`:** `append` then `load` round-trips N reports in order;
|
||||
`load` on a missing path is `Ok(empty)`; a hand-written corrupt line makes
|
||||
`load` return `Parse{line}`; `rank_by` orders by each known metric and errors
|
||||
on an unknown one. Tests use a temp-dir path (no shared cwd state).
|
||||
- **`aura-cli`:** process-level goldens with cwd set to a temp dir —
|
||||
`aura sweep` writes 4 lines to `runs/runs.jsonl` and prints 4 odometer lines,
|
||||
each now a full `RunReport` (`{"manifest":…,"metrics":…}`); the cycle-C
|
||||
params-only `aura sweep` golden is updated to the RunReport shape. A second
|
||||
`aura sweep` then `aura runs list` shows 8 records; `aura runs rank
|
||||
total_pips` prints them highest-first; `aura runs rank bogus` exits 2.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Against aura's feature-acceptance (CLAUDE.md): the audience naturally reaches for
|
||||
it, it measurably improves the loop, and it reintroduces no failure the core
|
||||
constraints forbid.
|
||||
|
||||
1. **Audience reaches for it.** Comparing experiments over time is the heart of
|
||||
the research loop (C18). `aura runs rank total_pips` is the first time a
|
||||
researcher can ask "which point in my sweep — or across my sweeps — was
|
||||
best?" without re-running anything. The worked program above is that ask.
|
||||
2. **Measurable improvement / removed redundancy.** Closes the manifest-per-point
|
||||
gap cycle C deferred (the family becomes self-describing) and the typed
|
||||
read-path gap (#17 — `serde` Deserialize *is* the typed handle). Retires the
|
||||
hand-rolled `sweep_point_to_json`.
|
||||
3. **No reintroduced failure class.** Determinism (C1) holds — the registry
|
||||
stores a reproducible manifest; `serde_json` output is deterministic. The
|
||||
sweep stays disjoint-parallel. The new dependency is admitted under the
|
||||
amended C16's per-case review, justified above, not by a blanket waiver.
|
||||
|
||||
**Recommended iteration cut** (first planner handoff = iteration 1):
|
||||
|
||||
- **Iteration 1 — foundation.** C16 amendment in the ledger; `[workspace.dependencies]`
|
||||
serde/serde_json; serde derives on the report types + `Timestamp`; round-trip
|
||||
tests. No behaviour change to the CLI. Workspace green.
|
||||
- **Iteration 2 — engine + registry + CLI closure rethread (lands together).**
|
||||
`SweepPoint` carries `RunReport` (closure bound `-> RunReport`). Changing that
|
||||
bound breaks its **only non-test caller** — the CLI's `sweep_report` closure
|
||||
(main.rs:280-289) — and the `sweep.rs` test callers; per the planner's
|
||||
caller-threading rule, **all of them are rethreaded in this same iteration** so
|
||||
`cargo test --workspace` stays green: the CLI closure now returns `RunReport`
|
||||
and prints via `RunReport::to_json` (retiring `sweep_point_to_json`), and the
|
||||
cycle-C `aura sweep` golden is updated to the RunReport shape here. New
|
||||
`aura-registry` crate (`open`/`append`/`load`/`rank_by`) with unit tests. No
|
||||
persistence side-effect yet, no new subcommands. Workspace green.
|
||||
- **Iteration 3 — CLI persistence + read surface.** `aura sweep` appends each
|
||||
point's report to `runs/runs.jsonl`; `aura runs list` / `aura runs rank
|
||||
<metric>`; USAGE; process goldens (persist + list + rank + bogus-metric exit
|
||||
2). Workspace green.
|
||||
|
||||
Each iteration leaves `cargo build/test --workspace` green: the signature-change
|
||||
iteration (2) threads every caller it breaks, never deferring one past its own
|
||||
build gate.
|
||||
|
||||
**Out of scope** (follow-up issues / later iterations): promotion/status,
|
||||
lineage graph, run-diff, on-demand re-derivation, `run_id` digest, `Aura.toml`
|
||||
runs-dir wiring, and the `aura run`/`aura sweep` stdout→serde unification.
|
||||
Reference in New Issue
Block a user