Commit Graph

38 Commits

Author SHA1 Message Date
claude 34ff539143 feat: measurement run verb — shape-dispatch aura run on blueprint output
Phase 3 of the Stratification milestone (contract C28). `aura run` now
shape-dispatches on the loaded blueprint: a `bias` output takes the
existing strategy path (wrap_r + R evaluation), byte-identical; a
blueprint that declares taps but exposes no `bias` runs bare via the new
`run_measurement` (bootstrap, run, drain the declared taps) with no
SimBroker, no risk executor, and no per-cycle equity/exposure/r
recorders; a blueprint exposing neither is refused. This gives the
measurement layer its own run verb and removes the O(cycles) recording
retention (the measured ~2 GB peak RSS over 12y UK100 M1) the mandatory
R scaffold imposed on a measurement-shaped run.

A new `MeasurementReport { manifest, taps }` (aura-engine::report, beside
RunReport) is the stdout/record shape — no R metrics, since a bare run
has no broker. The manifest's `broker` carries an interim "measurement"
sentinel; the honest `Option<String>` model is deferred to the #147
metric-genericity block, keeping this phase #147-free and the
strategy-path C18 goldens byte-identical.

`run_signal_r` is untouched (the tap machinery is duplicated into
`run_measurement`, not extracted). The measurement arm's closed-blueprint
guard uses `signal.param_space()` rather than `blueprint_axis_probe`,
which welds wrap_r and would panic on a no-`bias` signal.

refs #286
2026-07-19 16:09:51 +02:00
claude 17e125a90a feat(engine): FlatTap type + FlatGraph.taps field (#282)
Second slice: the resolved-tap carrier. FlatTap { name, node, field } is the
output-side twin of SourceSpec — its name survives compile, load-bearing for
by-name binding (#275). FlatGraph gains a taps field, threaded (empty) through
every construction site the compiler enumerates. Bootstrap ignores it (taps
materialize as real recorder nodes/edges before bootstrap in a later slice).
Purely additive: the inert-producer pin and full suite stay green.

refs #282
2026-07-17 22:52:21 +02:00
claude 0b620e1f26 feat(engine): bind ingestion sources by role key — SourceSpec.role + bind_sources/run_bound
refs #275

The engine-side half of by-name source binding. `SourceSpec` carries a load-bearing
`role: Option<String>` — `Some(name)` for a spec lowered from a bound `Role` (carried
through the blueprint lowering, previously dropped at compile), `None` for a
hand-built raw-index spec. A pure `bind_sources(specs, keyed_supply)` resolves a
`Vec<(role, Box<dyn Source>)>` into the positional `Vec` `run` consumes, in
`SourceSpec` declaration order, erroring named (`SourceBindError`:
Missing/Extra/Duplicate/Unnamed feed) on a mis-bind. `Harness::run_bound` is the
ergonomic method the by-name production path calls.

The raw-index `run(Vec)` primitive and the k-way merge / per-cycle event loop are
untouched: a positional run stays byte-identical (C1, pinned by
`run_bound_out_of_order_matches_positional_run`), and every existing `run(Vec)` caller
keeps working (it ignores `role`). A `BTreeMap` index makes the Duplicate/Extra
diagnostics deterministic.

C4's tie-break guarantee ("source declaration order") is unchanged — `bind_sources`
emits in `SourceSpec` order, so the merge's source-index tie-break resolves on
declaration order regardless of the caller's supply order. The narrow C23 amendment
(a `SourceSpec.role` is load-bearing for source binding, the rest of the flat graph
stays raw-index) is deferred to the ledger note.

The four error-path RED tests assert via `.map(|_| ()).unwrap_err()`: the `Ok` arm
`Vec<Box<dyn Source>>` is not `Debug` (`Source` has no `Debug` supertrait), so a bare
`.unwrap_err()` would not compile — a plan-byte defect corrected here.

Also sweeps the 45 raw-index `SourceSpec { .. }` literals to `SourceSpec::raw(..)`
across the workspace (mechanical, compiler-enumerated) and pins that a bound root
role's name survives lowering.

Verification: `cargo test --workspace` green (0 failed; incl. 5 `bind_sources` cases,
the `run_bound` byte-identity test, the role-survives-lowering pin, and the four
`.sources`-equality twins under the now-stricter `role` equality);
`cargo build --workspace --all-targets` clean; `cargo clippy -p aura-engine
--all-targets` clean.
2026-07-15 18:46:25 +02:00
claude 1ebb94c1b8 feat(engine,cli): run manifests stamp untouched bound defaults (closes #249)
RunManifest gains defaults: Vec<(String, Scalar)> — the wrap-prefixed
bound_param_space() of the signal, read after axis reopening, so a
bound param an axis overrode has already left the space and flows
through params instead (disjoint by construction; verified end to end:
a sweep member's overridden fast.length sits in params while
slow.length/bias.scale sit in defaults). params keeps its "what varied"
semantics and stays the reproduce input. One-directional serde
widening (#[serde(default)]) per the selection/instrument/topology_hash
idiom — old records deserialize with an empty defaults; unlike the
Option fields it always serializes, mirroring params. ~20
struct-literal sites across five crates gained the field
(compile-mandated breadth, no behaviour change at those sites).

The C14 ledger records the underlying decision (2026-07-13): generated
outcome records spend redundancy on direct readability (single writer,
cannot drift); authored intent artifacts admit none (every redundancy
is a drift site) — so the fix lands in the manifest, never the
blueprint.

Verification: RED test run_manifest_stamps_untouched_bound_defaults
green; cargo build --workspace; cargo test --workspace green; clippy
-D warnings on the touched crates; binary-level sweep exclusivity
check.
2026-07-13 14:20:23 +02:00
claude 1f6b55a8e1 feat(cli,engine): data-only project tier at the load boundary (#241 T1-T2)
- ProjectProvenance namespace/dylib_sha256 widen to Option (Tier-1
  additive serde): a data-only run stamps commit-only provenance; old
  string-field manifests keep parsing.
- The load boundary tier-selects: a [nodes] pointer list in Aura.toml
  (one entry honored; MultiCrate / pointer-context refusals) loads the
  pointed-at crate; a root Cargo.toml stays the pre-#241 native project,
  unchanged; neither means data-only (std vocabulary, project-local
  runs/, both-tier provenance).
- ProjectEnv splits into directory facts + Option<NativeEnv>; the crate
  pipeline (metadata -> artifact -> staleness -> ABI -> charter)
  relocates byte-identical into load_crate.
- e2e fixtures: dataonly-project, multicrate-project,
  nested-nodes-project (a pointer crate resolves and stamps its
  namespace end to end).

refs #241
2026-07-12 14:42:57 +02:00
Brummel 4928e289f7 feat(project): the project-as-crate load boundary (cycle 0102)
A research project is now a loadable external cdylib crate. Inside a
directory whose ancestry holds an Aura.toml, aura discovers the project
root cargo-style, locates the compiled dylib via cargo metadata (debug
default, --release opt-in), loads it load-and-hold, and refuses
mismatches before trusting anything: the AURA_PROJECT descriptor
(aura-core::project, #[repr(C)]) carries a C-ABI stamp prefix (rustc +
aura-core version, baked per consuming build by the new aura-core
build.rs) validated before any Rust-ABI field is read. The vocabulary
charter gates the merged resolution: project type ids are ::-namespaced
(std stays bare), duplicates refuse, and the enumerable type-id list
must agree with the resolver, so introspection can never silently omit
a project type.

All blueprint verbs resolve through the merged project + std vocabulary
via a per-invocation Env threaded through the dispatch chains;
registry, trace-store, and data paths anchor at the project runs root
(Aura.toml [paths], paths-only by design — instrument geometry stays
the recorded sidecar, C15). RunManifest gains the Tier-1 project
provenance field (namespace + dylib sha256 + best-effort commit),
stamped beside topology_hash on the blueprint-run paths; pre-0102
registry lines load unchanged. Default node names strip the namespace,
so :: never reaches the param-path address space.

Proven by the demo-project fixture (built by the e2e via cargo,
path-dep on this workspace): run twice bit-identical, provenance
recorded, introspection lists demo::* beside std, registry anchors at
the discovered root from a subdirectory; the badcharter fixture proves
the charter refusal through the real libloading path; a never-built
project refuses with a cargo-build hint. Outside a project every path
collapses to the previous literals — goldens and manifest pins
byte-identical.

Verification: cargo build --workspace clean; cargo test --workspace 862
passed / 0 failed (incl. 7 project_load e2e); clippy -D warnings clean
(one precedent-matching allow(too_many_arguments) on run_oos_blueprint,
whose arity the Env threading raised to 8); doc build unchanged.
Docs/ledger aligned: Aura.toml field lists are paths-only in
project-layout.md, glossary, C16/C17; new C13 realization note records
the per-invocation-reload reading and the load-and-hold one-shot scope
boundary.

New deps, per-case review (aura-cli leaf binary only, never the frozen
artifact): libloading, toml.

refs #180
2026-07-02 18:13:37 +02:00
Brummel ff4a1b3d4a feat(0092): run-from-blueprint infrastructure — restructure + topology_hash + shared seam (Tasks 1-3)
Cycle-1 infrastructure for #165 (World/C21). Behaviour-preserving + workspace
green; the CLI arm + tests (Tasks 4-5) follow.

Task 1 — restructured stage1_r_graph into stage1_signal() (the serializable
signal leg: SMA-cross -> Bias, a `price` input-role + a `bias` output) +
wrap_stage1r(signal, ...) (broker/sinks/exec/cost around a nested signal), with
stage1_r_graph() = wrap_stage1r(stage1_signal(...)). DEVIATION from the plan's
"callers untouched" claim, verified behaviour-preserving: nesting the signal
prefixes its param-space names (stage1_signal.fast.length), which broke the
sweep's bare .axis("fast.length"); fixed exactly as the #137 stop-knob machinery
does — FAST_LENGTH_SUFFIX/SLOW_LENGTH_SUFFIX suffix-resolution + stage1_r_friendly_name
arms mapping the prefixed names back to the bare manifest names. All observable
behaviour (manifest names, member keys, metrics, traces) byte-identical; the flat
graph and every metric unchanged (full suite green, incl. sweep/walkforward/MC).

Task 2 — RunManifest.topology_hash: Option<String> (Tier-1 optional, serde
default/skip — old manifests byte-identical, #156), threaded None into all 24
literal sites across 7 crates. Also fixed the aura-registry RunManifestRead
read-mirror to carry topology_hash (it was hardcoding None on load) — else a
stored hash would silently drop on the registry round-trip once Task 3 stores a
real one.

Task 3 — the shared run_signal_stage1r seam (hash the signal, wrap, compile with
params, bootstrap, run, manifest with params from param_space + topology_hash) +
the sha256_hex topology_hash helper (sha2 in aura-cli, off the frozen engine,
invariant 8). run_stage1_r now carries its topology_hash too (every run becomes
self-identifying, #158). The seam is unwired until Task 4 (transient
#[allow(dead_code)], retired there); RED-first seam tests added.

Verified: cargo test --workspace green (51 suites, 0 failures); cargo clippy
--workspace --all-targets -D warnings clean.

refs #165
2026-06-30 20:23:42 +02:00
Brummel 04c581a441 refactor(0080): relocate FamilySelection/SelectionMode to aura-analysis (refs #136)
The last trading-domain types still defined in aura-engine's report module —
the selection-provenance types FamilySelection and SelectionMode — move to
aura-analysis, finishing the #136 goal of getting the trading types out of the
engine. aura-analysis is the only non-cyclic home that removes them from the
engine: RunManifest.selection: Option<FamilySelection> needs the type and the
engine already depends on aura-analysis; aura-registry and aura-cli reach them
unchanged via aura-engine's re-export (report.rs pub-re-exports them, lib.rs
untouched). Their tests stay in report.rs (one round-trips through RunManifest,
which stays in the engine) and reach the types via the re-export.

Behaviour-preserving (C1): full suite 665/0 unchanged incl. the C18 goldens,
clippy clean, only report.rs and aura-analysis/src/lib.rs change.

Staying in the engine (trace-coupled, deferred to the World/C21 layer, not
#136): RunManifest, RunReport, summarize. The aura-registry Metric/metric_cmp
vocabulary stays in the registry by design — the registry is the trading
selection layer, not the domain-free engine (the deflation statistics branch on
metric identity, irreducibly domain). See the #136 thread for the rationale.
2026-06-27 00:33:10 +02:00
Brummel 94c88eb7e1 refactor(0079): extract the trading-analysis leaf into aura-analysis (refs #136)
aura-engine is documented (C16) and named as the reusable, domain-agnostic
reactive SoA substrate, yet `report.rs` concentrated the trading-domain
analysis layer inside it. This cycle relocates the pure-domain leaf into a new
`aura-analysis` crate (deps: aura-core + serde only), erecting the
engine/domain seam before the Stage-2 broker milestone deposits more
currency/position-event code into the same module. Behaviour-preserving (C1):
the full workspace suite stays green unchanged (665 passed), including the C18
on-disk byte-identity goldens (cli_run, the registry legacy-line loads).

Moved to aura-analysis (bodies verbatim — no float expression re-associated):
RunMetrics, RMetrics (+ its hand-written PartialEq), the private r_col column
indices + SQN_CAP, summarize_r, r_metrics_from_rs, derive_position_events,
PositionAction (+ From/TryFrom<i64>), PositionEvent, and the deflation stats
helpers inv_norm_cdf / expected_max_of_normals. Their unit tests and private
helpers (r_row / r_row_full / pm_row) relocate with them — tests live with
their subject (the r_col helpers reach the private module directly, which a
re-export cannot bridge).

Stays in aura-engine for this cycle: the generic trace plumbing (ColumnarTrace,
join_on_ts, JoinedRow, f64_field), RunManifest, RunReport, and summarize (it
bridges recorded trace columns into RunMetrics, so it is trace-coupled). The
mis-placed orchestration types FamilySelection / SelectionMode also stay
(their relocation is entangled with the registry-vocabulary fork, deferred).

report.rs pub-re-exports the moved symbols, so aura-engine's lib.rs re-export
block and every `crate::report::X` / `aura_engine::X` consumer
(aura-registry, aura-cli, aura-composites, aura-ingest) compile byte-unchanged;
only the new crate, the workspace member list, aura-engine's Cargo.toml dep,
and report.rs itself change.

Deferred to later cycles (genuine design forks, route through specify when
reached): making RunReport generic over a metric type M and re-bounding
sweep/mc/walkforward; relocating the aura-registry Metric / metric_cmp / rank
vocabulary; relocating FamilySelection / SelectionMode. Fork decisions logged
on #136.
2026-06-26 23:49:03 +02:00
Brummel 0a06afed29 feat(0078): cross-instrument generalization as a validation step
Last piece of the Inferential-validation milestone (#146): a new
`aura generalize` subcommand grades how consistently a single candidate
holds across a set of instruments — the across-instrument axis of the
same anti-false-discovery concern as trials-deflation (#144, within one
family) and plateau-over-peak (#145, within one instrument's surface).

Aggregator/validator, not a third selector (user-ratified): it runs a
brought candidate (a single-cell stage1-r grid — --fast/--slow/--stop-
length/--stop-k pinned to single values) across an instrument list
(--real SYM1,SYM2,...), collects per-instrument R-metrics, and reduces
them to a worst-case floor (min over instruments) + a sign-agreement
count + the per-instrument breakdown. R-only (C10 — R is the only
account/instrument-agnostic unit); the worst-case min is the cross-
instrument sibling of PlateauMode::Worst and satisfies the non-
domination constraint by construction (a strong instrument cannot lift
a min). Rejected: a cross-instrument t-stat mean/std*sqrt(M), which
reintroduces the pooled mean the milestone warns against.

- RunManifest gains a first-class `instrument` lineage field (serde-
  widened, C14/C18 — legacy lines load as None, existing manifest bytes
  unchanged); the compat read-mirror threads it in lockstep.
- FamilyKind::CrossInstrument (C12 comparison axis); the M per-instrument
  runs persist as a CrossInstrument family, each member self-identifying
  via its stamped instrument. The aggregate is recomputable from the
  members (McAggregate precedent), printed live.
- The generalization reduction lives registry-side beside optimize_
  deflated/optimize_plateau (C9), reusing resolve_metric/metric_value/
  is_r_metric; check_r_metric is the data-free pre-check the CLI runs
  before evaluating any instrument.
- Additive: existing sweep/walkforward/mc/standalone paths stay byte-
  identical (C23 — instrument omitted when None via skip_serializing_if).

Verified by the orchestrator: cargo test --workspace green (0 failed —
the new generalization_*, check_r_metric, parse_generalize_*, and the
gated cross-instrument E2E all pass; the E2E ran the real GER40/USDJPY
path on this host, exit 0); cargo clippy --workspace --all-targets
-D warnings clean.

closes #146
2026-06-26 19:04:53 +02:00
Brummel d50a4828ad audit(0077): cycle close — drift addressed; legacy-load fixture + ledger sync; drop ephemera
Architect drift review over 9c6b9c7..cb32658 returned drift_found (3 items);
all resolved here. What holds (architect): C23 default-argmax byte-identical,
C9 separation (selector in aura-registry, walk_forward selection-agnostic),
C1/C2 (plateau pure/deterministic/in-sample), one higher_is_better direction
source.

Resolutions:
- [medium] Backfilled the pre-0077 FamilySelection legacy-load fixture
  (report.rs family_selection_loads_a_pre_0077_legacy_line). The feat commit
  and the struct doc-comment claimed "a legacy line still loads (C14/C18)" as
  landed, but no test pinned the field-level mapping: bare deflation scalars ->
  Some(...), absent plateau keys -> None. families.jsonl/runs.jsonl are
  append-only, so a winner stamped pre-0077 must read back; now pinned.
- [low] Synced the ledger's inferential-validation open thread
  (docs/design/INDEX.md): #145 plateau-over-peak landed (cycle 0077), selection
  is now a pluggable objective (bare argmax -> trials-deflated -> plateau) on
  the manifest carrier; only #146 (cross-instrument generalization) remains of
  the milestone's three pieces.
- [medium-high] Removed the cycle ephemera (git rm docs/specs/0077-*,
  docs/plans/0077-*) per the cycle-close convention.

Regression: full workspace suite green and clippy --all-targets -D warnings
clean (verified at feat-review time). No baseline scripts configured; the test
+ clippy gates are the regression gate. Cycle 0077 tidy (clean) — drift-clean,
not a milestone close (the milestone fieldtest is a separate deliberate gate).
2026-06-26 16:42:49 +02:00
Brummel cb32658fd1 feat(0077): prefer a robust parameter plateau over the in-sample peak
Add an opt-in plateau selection objective for walk-forward, recorded on the
same RunManifest.selection carrier #144 introduced. Default argmax is
byte-identical (C23); plateau is reached only via a new --select flag.

What landed:
- FamilySelection / SelectionMode reshape (report.rs): the selection RULE
  (mode) and its ANNOTATION are orthogonal — deflation fields become Option
  (present iff Argmax), plus PlateauMean/PlateauWorst variants and
  neighbourhood_score/n_neighbours (present iff Plateau*). Legacy lines still
  load: the deflation scalars deserialize from bare values via serde default
  (C14/C18). compat.rs embeds FamilySelection by value — reshape flows through
  untouched.
- GridSpace::axis_lens() + SweepBinder::sweep_with_lattice (engine): the grid
  radixes in param_space()/odometer order. sweep() delegates to
  sweep_with_lattice with the lattice dropped, so every existing .sweep()
  caller is byte-unchanged.
- optimize_plateau + PlateauMode + closed_neighbourhood (aura-registry, C9):
  each member scores as the mean/worst of its closed mixed-radix grid
  neighbourhood's metric_value; the winner is the smoothed argmax by the
  metric's direction (earliest-odometer tie, as optimize). Pure, no RNG (C1);
  in-sample only (C2). A higher_is_better helper now sources the per-metric
  direction once, shared by metric_cmp and optimize_plateau.
- CLI --select <argmax|plateau:mean|plateau:worst> (default argmax; unknown
  token exits 2). walkforward_family dispatches via a select_winner helper;
  sweep_over/stage1_r_sweep_over carry the lattice as Option<Vec<usize>>.
  runs-family display gains a plateau(<mode>)=<score> over <n> cells line.

RandomSpace-refuse: walkforward has no --random producer, so the
plateau-without-lattice guard is structural — select_winner returns the
refuse on a None lattice (the caller prints the message and exits 2),
unit-tested at the helper, not via a --random E2E (decided + recorded on #145
comment 1974, with the lattice-seam fork).

Tests: plateau-picks-plateau-not-spike, mean-vs-worst, lower-is-better
direction-flip, closed-neighbourhood golden, single-member degeneracy, C1
determinism; CLI select-parse, select_winner refuse; E2E argmax-byte-identical
(C23), plateau:mean and plateau:worst provenance. Full workspace suite green;
clippy --all-targets -D warnings clean.

closes #145
2026-06-26 16:38:18 +02:00
Brummel a2959050a4 feat(0076): deflate the sweep winner's metric for the number of trials
Adds optimize_deflated beside optimize in aura-registry: the in-sample
walk-forward selection now records, on each OOS winner's manifest, how much
the winner's metric is inflated by the size of the search it won — a deflated
score and (R arm) an overfit probability — without changing which member wins
(additive, C23; a regression test pins optimize_deflated's winner == optimize's).

- R arm (sqn_normalized/expectancy_r/...): a centred moving-block reality-check.
  Each member's per-trade R series is mean-subtracted to impose the no-edge null,
  resampled via the r_bootstrap kernel (extracted to a shared resample_block,
  byte-identical), and the best-of-K null-max gives
  overfit_probability = (count(null >= raw)+1)/(n+1) and
  deflated_score = raw - p95(null). Deterministic given the seed (C1).
- total_pips arm: a closed-form expected-max-of-K dispersion floor
  (inv_norm_cdf + expected_max_of_normals), no probability.

The record (FamilySelection on RunManifest) rides the proven
serde(default, skip_serializing_if) widening + a compat.rs mirror, so legacy
runs.jsonl/families.jsonl lines load unchanged and unstamped sweep/mc/standalone
manifests stay byte-identical (C14/C18). The SelectionMode enum reserves a
Plateau slot for #145. CLI: walk-forward stamps both arms; runs family <id> rank
surfaces a human-readable deflated/overfit line.

Quality review (re-dispatched after the implement-loop's quality gate exhausted)
caught a real defect: on the R arm with positive resamples but no member carrying
trade_rs (a family loaded from disk — trade_rs is serde-skipped), the null was
all-NEG_INFINITY, turning deflated_score into +inf. Fixed by collapsing "no usable
null" (zero resamples OR no trade_rs) into one degenerate floor (deflated == raw,
overfit == 1.0); pinned by optimize_deflated_no_trade_rs_floors_instead_of_infinity.

Verified: cargo test --workspace and cargo clippy --workspace --all-targets
-D warnings both green. Frictionless Stage-1 R; n_eff advisory and reload-time
recompute deferred (spec 0076 Out of scope).

closes #144
2026-06-26 14:53:32 +02:00
Brummel c192dfd344 audit(0075): cycle close — drift-clean; doc-link + trade_rs tripwire tidy; drop ephemera
Architect drift review (c99e7b8..2e77ab1) against the design ledger +
CLAUDE.md invariants: drift-clean, debt only — no contract or spec
violation. Verified intact: C18 wire shape (trade_rs is #[serde(skip)] +
excluded from the hand-written PartialEq; runs.jsonl/families.jsonl bytes
and digests unmoved; mc_r_bootstrap is a printed line, never a record),
C1 determinism (seeded r_bootstrap + disjoint walkforward windows, both
pinned by byte-identical-rerun e2e tests), C7 frictionless Stage-1
(round_trip_cost stays 0.0), C8/C12 streaming reduction (IS sweep folds
O(trades)/member, OOS run non-reduce). The verbatim summarize_r /
r_metrics_from_rs float duplication is guarded by the cross-reducer
equality test — sound.

Tidy applied this close:
- doc-link drift: the new E[R] notation in r_bootstrap / RBootstrap /
  run_mc_r_bootstrap doc-comments was parsed by rustdoc as an intra-doc
  link `[R]` (4 warnings). Wrapped in backticks; doc build now clean.
- coverage micro-gap [low, architect]: added a tripwire asserting a
  populated trade_rs is absent from the serialized JSON (the serde(skip)
  C18-preservation property had no direct test — only Vec::new() was
  serialized before).

Carry-on (benign debt, not fixed): the mc usage literal carries a `usage:`
prefix the sibling sweep/walkforward usages omit — required because the
new `["mc", rest @ ..]` arm now handles the `--real` rejection and the
preserved golden asserts stderr.contains("usage"); churning the golden to
drop the prefix is not worth it.

Removed the cycle's ephemeral spec + plans (docs/specs/0075,
docs/plans/0075, docs/plans/0076) per the lifecycle rule.

Verified: cargo test --workspace 610 passed / 0 failed, clippy -D warnings
clean, cargo doc --no-deps clean.

closes #139
2026-06-26 12:19:07 +02:00
Brummel f286bb85f7 feat(0075): walk-forward strategy-selectable + R-reporting (iter 1)
Make `aura walkforward --strategy stage1-r [--real <SYM>]` roll IS->OOS
windows, sweep the stage1-r grid in-sample, pick the winner by an R metric
(sqn_normalized), run it out-of-sample, and report per-window + pooled OOS
R-metrics. The bare SMA walkforward path is byte-identical (its dispatch arm
is verbatim today's body; the pooled `oos_r` block is emitted only when a
window carries an `r` block).

Engine:
- RMetrics gains an in-memory `trade_rs: Vec<f64>` (realised R per closed
  trade), excluded from serde (`#[serde(skip)]`) and from a hand-written
  PartialEq, so the C18 wire shape and every existing equality assertion /
  round-trip stay unchanged. summarize_r now retains the per-trade R vector
  it used to drop.
- New `r_metrics_from_rs(&[f64])` reduces a flat (pooled across-window) R
  series to RMetrics. Its R-distribution arithmetic is copied verbatim from
  summarize_r (the byte-pinned floats must not be algebraically refactored);
  the two copies are guarded in lockstep by a cross-reducer equality test.
  net_expectancy_r = expectancy_r (exact at the Stage-1 cost=0 invariant);
  conviction_terciles_r = [0,0,0] (per-trade conviction is not pooled).

CLI:
- walkforward gains --strategy + the four stage1-r grid flags (reusing
  parse_csv_list / Stage1RGrid); walkforward_family is strategy-dispatched.
- Windowed stage1-r helpers: stage1_r_sweep_over (reduce-mode folded IS
  sweep, O(trades)/member) and run_oos_r (non-reduce OOS run, for the
  stitched pip-equity curve), plus stage1_r_space.

Frictionless Stage-1 R (costs are Stage-2). Verified: cargo build clean,
full `cargo test --workspace` green (SMA walkforward, synthetic mc,
stage1_r_single_run_output_golden, and the C18 round-trips all preserved),
clippy -D warnings clean. Monte-Carlo R-bootstrap is iter 2.

refs #139
2026-06-26 11:21:27 +02:00
Brummel 7e4b91ab5f feat(0074): source real-path pip from the geometry sidecar; drop the authored floor
The data-server now ships per-symbol geometry sidecars, so aura no longer
hard-codes instrument geometry. A consumption grep showed production reads
exactly one `InstrumentSpec` field — `pip_size` — at the real-data path; the
other eight (incl. `instrument_id`) were constructed but never read.

- aura-cli resolves the real-path pip from the recorded sidecar
  (`instrument_geometry` -> `InstrumentGeometry.pip_size`), refusing (exit 2)
  on absent geometry. The authored table no longer gates: any symbol with a
  sidecar + bars now runs (new test: USDJPY, never vetted, resolves its JPY
  pip 0.01 from the sidecar).
- Removed the redundant authored floor: `InstrumentSpec`, `instrument_spec`,
  `VETTED_SYMBOLS`, and the table-only tests. The `InstrumentGeometry` seam
  (the surviving pip source) and its nameability guard are kept.
- `instrument_id` (the "find out"): no production consumer. The position-event
  table's `instrument_id` is a decoupled caller parameter; the engine never
  imports an instrument spec. Dropped with the struct.
- The example `pip_size_of` is re-sourced to the sidecar; the pure-structural
  blueprint tests use a literal pip to stay archive-independent.

Speculative deploy-grade fields and the #124 override/floor tiers are deferred
to the Stage-2 broker that will consume them, read from the sidecar geometry
then. Ledger (C8/C15/#22/#106) updated; #124 collapses to "recorded sidecar
geometry -> refuse" in the meantime.

Verified: cargo test --workspace (all green), clippy -D warnings clean, cargo
doc clean, with the local archive present so the sidecar paths run.

refs #124
2026-06-25 19:06:12 +02:00
Brummel cfe7bad0ff feat(0070): streaming sink reductions — finalize hook + folding sinks (#138)
M3 machinery for BLOCKER #138: sink reductions can now fold online instead of
buffering every per-cycle row to an unbounded channel.

- aura-core: additive `Node::finalize` end-of-stream hook (default no-op) +
  `SeriesFold` online accumulator (last / max_drawdown / sign_flips).
- aura-engine: `Harness::run` calls `finalize` once per node in topo order after
  the source loop; `summarize` refactored to drive `SeriesFold` (byte-identical),
  the now-dead `sign0` removed.
- aura-std: `GatedRecorder` (emits only gated rows + the final row on finalize)
  and `SeriesReducer` (folds one f64 series, emits one summary row on finalize),
  both emitting the `Recorder` channel type — no aura-engine dependency.
- Integration tests: folded reductions are byte-for-byte equal to the
  raw-recorder path; the R-reduction's peak memory is O(trades), independent of
  cycle count.

Ratified two off-plan fixes the implementer made in the tests (both plan defects,
not code defects): the GatedRecorder correctly flushes the final non-gated row on
finalize, so the retained count is K_TRADES + 1; and the heterogeneous PM record
needs kind-correct zero scalars (a blanket f64 zero is rejected by the kind-typed
AnyColumn::push).

Wiring these reducers into the stage1-r sweep (the actual O(cycles)->O(trades)
win) follows next. refs #138
2026-06-25 11:00:02 +02:00
Brummel 7aaa3e5d5c feat(0068): position-event derive — book first-difference table (#115)
Add `derive_position_events(record, instrument_id) -> Vec<PositionEvent>` to
aura-engine (report.rs), the broker-independent Stage-2 audit layer of C10: the
first difference of the executed book derived from a PositionManagement dense
record. A pure post-run reduction, sibling of summarize_r — same positional,
type-erased Scalar read of the 14-column record, no in-graph node (the hot path
stays domain-free, C14). A reversal emits Close then the opposite open at one
event_ts (close first); a window-end open position emits its open with no
synthetic Close (the table records actual executed events — unlike summarize_r,
which force-closes for the R metric). instrument_id is a caller-supplied scalar
(aura-engine depends only on aura-core; it cannot import InstrumentSpec).

Two r_col indices added (DIRECTION=4, SIZE=10), the lockstep guard in
stage1_r_e2e.rs extended to name-pin `direction`, and an agreement E2E folds both
summarize_r and derive_position_events over one recorded ledger (one Close per
closed round-trip; every open closed-or-open-at-end; referential integrity).

Scope: the derive only. Fixed-fractional / currency / equity-feedback sizing is
#116 (it owns the equity->Sizer z^-1 register per C10) — deferring it here avoids
a circular dependency. Supersedes the rolled-back 0064 exposure-integral derive
(abandoned per #117): this derives from the executed book, never exposure deltas.

Verified by the orchestrator: cargo build + clippy --all-targets -D warnings
clean; full workspace suite 0 failed; aura-engine lib 214 pass (incl. the 6
derive_* unit tests); stage1_r_e2e 11 pass (incl. the new E2E + extended guard).
Fork decisions recorded on #115.

closes #115
2026-06-24 20:19:31 +02:00
Brummel 89d967a940 feat(0067): SQN100 metric + stage1-r sweep --trace
Two additive follow-ups to the cycle-0065/0066 R-sweep surface.

Part A (#130) — n-normalized SQN (SQN100): RMetrics gains sqn_normalized =
(mean_R/stdev_R)·√(min(n,100)), a turnover-robust ranking objective comparable
across sweep members with different trade counts. The raw sqn is kept
byte-identical (computed sqrt(n)*mean/sd, NOT via a factored shared ratio, so
the existing metric does not drift even by 1 ULP). The field carries
#[serde(default)] for C18 back-compat (a pre-0067 r: block deserialises with
sqn_normalized = 0.0). The registry learns Metric::SqnNormalized (opt-in; the
default ranker and raw sqn are unchanged), so `runs family rank sqn_normalized`
works. The 0066 single-run golden is re-baselined (the r: block gains the
field; for n=3 ≤ 100 the cap is inactive so sqn_normalized == sqn).

Part B (#135) — wire --trace for the stage1-r sweep: stage1_r_sweep_family now
honours --trace, persisting each member's equity/exposure/r_equity under
runs/traces/<n>/<member_key>/ via the existing persist_traces_r (mirroring
momentum_sweep_family); the interim run_sweep refusal guard is dropped. --trace
is now symmetric across all three sweep strategies and a swept member is
chartable (chart <n>/<member> --tap r_equity).

Tests: SQN100 cap / below-cap / empty + serde back-compat (engine); rank-by
sqn_normalized + unknown-metric message (registry); an E2E guard that both
SQN values fold from recorded records and agree below the cap (the cross-crate
column seam); inverted stage1-r --trace persistence + a CLI rank-by-
sqn_normalized integration. Full workspace suite green (527 passed, 0 failed);
clippy --all-targets -D warnings clean.

Derived fork decisions logged on #130 and #135 (cap = fixed const 100; opt-in
metric, default ranker unchanged; serde(default); Part B mirrors
momentum_sweep_family + reuses persist_traces_r).

closes #130
closes #135
2026-06-24 18:10:54 +02:00
Brummel 68605b7d88 refactor(stage1-r): rename the strategy-output param namespace exposure -> bias
Complete the deferred rename tail from cycle 0065's #126 (which renamed only the
typed metric, keeping the param namespace uniformly "exposure" so the single-run
rename would not smuggle in a ~30-site sweep-pinned change). Now renamed together
as one consistent unit, all driven by the Bias node's instance name:

- Bias::builder().named("exposure") -> .named("bias") (the harness builders +
  the engine fixtures in test_fixtures.rs / blueprint.rs);
- the derived knob path exposure.scale -> bias.scale (Composite::param_space
  derives the knob from the node name) -- the sweep .axis/.range bindings and
  .with() points (blueprint.rs, main.rs), the walkforward ParamSpecs
  (walkforward.rs), the member_key dir names, and the JSON byte-pins in
  cli_run.rs / report.rs / main.rs tests;
- the exposure_scale manifest param label -> bias_scale (the single-run manifest
  builders + the streaming_seam / real_bars fixtures).

The rename is surgical: the three targets (named("exposure"), exposure.scale,
exposure_scale) are syntactically distinct from the deliberately-kept "exposure"
forms, which are untouched -- SimBroker's pre-reframe exposure input slot +
prev_exposure + the exposure-integral; the LongOnly / gate / latch ports named
"exposure"; and the on-disk "exposure" tap label (the recorded bias series that
feeds `chart --tap exposure`, decoupled from the node name via
ColumnarTrace::from_rows("exposure", ...)).

No on-disk back-compat break: the knob path is a runtime param address, and
recorded params in old runs.jsonl / families.jsonl are inert data strings (a
pre-existing recorded "exposure.scale" still loads, it is just data).

INDEX.md cycle-0065 realization note amended: the param namespace is recorded as
the now-completed tail; the SimBroker slot + the on-disk tap label remain the
deliberate permanent keeps (#117), and exposure_sign_flips stays a serde alias.

Verified: cargo build --workspace --all-targets clean; clippy --all-targets
-D warnings clean; full suite 514 passed / 0 failed (source + byte-pins renamed
together); live `aura run` emits "bias_scale", `aura sweep` emits "bias.scale".

closes #134
refs #126 #117
2026-06-24 14:40:51 +02:00
Brummel c6c1d3bb10 feat(stage1-r): rename the exposure_sign_flips metric to bias_sign_flips (#126)
Complete the typed-field half of the exposure->bias rename - the one the cycle-0065
close audit flagged as aging worst (a serde-stable on-disk key).

- RunMetrics.exposure_sign_flips -> bias_sign_flips with
  #[serde(alias = "exposure_sign_flips")], so legacy runs.jsonl still deserialises
  (pinned by the legacy-read tests, which keep the old key); new output serialises
  bias_sign_flips (the byte-pin tests updated to the new key).
- The registry rank metric: Metric::ExposureSignFlips -> BiasSignFlips, the rank
  string accepts BOTH "bias_sign_flips" and "exposure_sign_flips" (CLI back-compat),
  the error/listing updated; the rank tests exercise both names.
- The MC aggregate field renamed too (McAggregate is hand-rendered to JSON, not
  serde-derived - no alias needed).

Scope held to the typed metric. The strategy-output PARAM NAMESPACE - the
.named("exposure") Bias instance, its exposure.scale knob path (Composite::param_space
derives the knob from the node name), and the exposure_scale manifest label - is a
coherent ~30-site, sweep-pinned surface (the implement loop correctly flagged renaming
the knob as an unscoped expansion touching walkforward + the cli_run byte-pins). Kept
uniformly as "exposure" and deferred to a follow-on so the cluster renames as one
consistent unit. SimBroker's pre-reframe exposure concept and the on-disk "exposure"
tap label stay by the #117 fork decision.

cargo test --workspace green; clippy --workspace --all-targets -D warnings clean; a
live `aura run --harness stage1-r` emits "bias_sign_flips".

closes #126
refs #117
2026-06-24 13:11:46 +02:00
Brummel 4e6b1d9b53 audit(stage1-r): cycle-0065 close — ledger records the Stage-1 R signal-quality layer
Architect drift review of cycle 0065 (Stage-1 R: the exposure->bias rename, the
stop-rule + vol_stop, PositionManagement's dense R-record, the Sizer, summarize_r,
the public RiskExecutor, and the CLI surface): code is drift-clean - C10 fidelity
holds (bias->stop->Sizer->PositionManagement, flat-1R feed-forward, R stop-defined
and size-invariant, guarded by the cross-crate layout lockstep test), C9/C23
acyclic, the --harness selector is a compile-time match (no runtime registry/DSL,
C17). The milestone fieldtest is green after the tiny-pip tolerance fix (2ee0d74).

Lifted to the ledger (docs/design/INDEX.md):
- A C10 realization note for cycle 0065 recording the built Stage-1 R layer (the
  chain, the public RiskExecutor + StopRule axis, the CLI surface, the scale-robust
  guard, the aura-engine->aura-std normal dep) and that Stage 2
  (currency/compounding/realistic brokers) stays the deferred downstream layer.
- Amended the C10 contract's "pending Stage-1 implementation" line (it landed).

Fixed stale prose introduced by iter-3's dependency-tier change: report.rs and
position_management.rs comments cited "aura-std is only a dev-dependency" as the
rationale for the by-convention dense-record layout contract; iter-3 made it a
normal dependency, so that rationale was false. Reworded to the real,
dep-tier-independent reason - the record crosses the crate boundary as a
type-erased Scalar vector (C4 SoA), read positionally, guarded by stage1_r_e2e.rs.

Carry-on filed as follow-ons (not actioned): the exposure->bias rename tail (the
serde-stable exposure_sign_flips key + slot/param names - commented on #126, the
recommended next iteration); CLI UX friction (#131); stage1-r harness cosmetic
smells (#132); R-sweep families + rank-by-sqn (#133).

This is a drift-clean cycle-close audit, NOT a milestone-container close: the
ephemeral spec/plans stay (spec 0065's change (a) still scopes the deferred rename
tail) and the milestone close stays a deliberate act for the user.

cargo build/test --workspace green; clippy --workspace --all-targets -D warnings clean.

refs #117 #126
2026-06-24 12:35:15 +02:00
Brummel 92591e5dc0 refactor(stage1-r): name the conviction column conviction_at_entry
Rename the dense PositionManagement record's column 9 from `bias_at_entry_abs`
to `conviction_at_entry` (FIELD_NAMES, the `r_col`/test index constants, the
cross-crate layout guard, and the doc comments).

Why: the column holds |bias| at entry, which C10 defines AS conviction (bias =
direction + conviction magnitude; ledger INDEX.md ~458). The record already names
the other half of that decomposition by its domain concept (`direction` = sign,
not `bias_sign`), so naming the magnitude half mechanically (`bias_at_entry_abs`)
was an inconsistency; `conviction_at_entry` speaks the ledger's ubiquitous
language and parallels `direction`, while `_at_entry` preserves the
frozen-snapshot precision.

Considered and rejected: collapsing `(direction, bias_at_entry_abs)` into one
signed `bias_at_entry` and dropping `direction` as redundant — it is not
redundant. On a reversal row col 4 (`direction`) tracks the reopened leg while
col 9 tracks the closed trade's entry conviction, so the two describe different
trades and cannot be losslessly merged; they are also distinct semantic axes
(current-position direction vs per-trade entry conviction).

Scope: domain name at the data boundary, mechanical name internally — the
internal `bias.abs()` variables (`Open.bias_abs`, the fold's `Trade.bias_abs`)
keep their computation-faithful names, exactly the raw-internal / domain-external
split the record uses elsewhere.

Behaviour-preserving: cargo build --workspace clean; cargo test --workspace 500
passed, 0 failed (unchanged); clippy --all-targets -D warnings clean. The layout
guard (`r_col_indices_match_producer_field_layout`) confirms the renamed string.

refs #117
2026-06-24 10:25:18 +02:00
Brummel b4e84335c4 feat(stage1-r): Sizer + RiskExecutor + R-metric enrichment (iter 2)
Iteration 2 of cycle 0065 (Stage-1 R signal quality) — the node + metric layer.
Builds on iter-1's PositionManagement dense R-record + core summarize_r.

What ships:

- PositionManagement gains a 4th `size` input (slot 3 -> col 10), fed by the
  Sizer. R is computed size-INVARIANTLY (pure stop-distance ratio), so size
  never touches realized_r — pinned by a RED test (scaling size leaves every
  realized_r unchanged) at the node and again end-to-end through the executor.

- Sizer (aura-std): `size = risk_budget / stop_distance` — flat-1R (risk_budget
  1.0 => one risk unit per trade, size inversely proportional to the stop, not a
  constant). Reads bias (firing/presence) + stop_distance; the Stage-2
  fixed-fractional sizer slots in unchanged (swap risk_budget for
  risk_fraction*equity, same node shape).

- summarize_r enrichment: SQN (sqrt(n)*mean_R/sample-stdev_R; n<2 or zero-variance
  -> 0), conviction_terciles_r (E[R] by |bias_at_entry| tercile), net_expectancy_r
  (gross minus one round-trip cost per trade, charged in R via the latched_dist
  recovered from the entry_price/stop_price columns). summarize_r gains a
  `round_trip_cost` param (price units). The net-of-cost recovery is tested
  through the real producer->consumer seam, not just hand-built rows.

- RunMetrics.r: Option<RMetrics> with #[serde(default, skip_serializing_if =
  "Option::is_none")] — legacy runs.jsonl (no `r` key) deserialise to None and a
  pip-only run's on-disk shape stays byte-unchanged (C14/C18 back-compat). Every
  RunMetrics literal threaded (report.rs, aura-registry, aura-engine/mc).

- RiskExecutor (aura-engine integration-test fixture, sibling of
  vol_stop_composite): FixedStop -> Sizer -> PositionManagement behind open
  bias+price input roles, price fanned to both the stop and PM, the Sizer's size
  into PM. Bias is produced in-graph from the single price source (a second bias
  *source* would k-way-merge into separate cycles and mark stale prices). The
  Veto is a DOCUMENTED SEAM, not a runtime node (a pass-through identity is what
  C19/C23 DCE deletes). Tests: the composite bootstraps + runs + folds to the
  documented hand value; R invariant under risk_budget while the size column
  scales; a live-folded RMetrics survives the RunMetrics serde round-trip.

The dense-record size column (10) is brought under the cross-crate layout guard
(stage1_r_e2e r_col_indices_match_producer_field_layout) so the executor
fixture's size-invariance read is drift-protected like the others.

Scope: the CLI/recording surface (#129) is sub-split into a separate iteration 3
and is NOT in this commit.

Verified: cargo build --workspace clean; cargo test --workspace 500 passed,
0 failed; cargo clippy --workspace --all-targets -D warnings clean.

refs #117 #127 #128 #129
2026-06-24 02:15:51 +02:00
Brummel 2c43296c2c feat(stage1-r): bias rename + stop-rule + position-management + summarize_r (iter 1)
Iteration 1 of the Stage-1 "R-based signal quality" cycle (spec 0065): a
strategy's signal quality, measured in R on its unsized bias stream, feed-forward.
New discrete-trade machinery, NOT a SimBroker extension.

What lands:
- exposure -> bias (refs #126): the Exposure node/type/file/output-field renamed to
  Bias (computation unchanged: clamp(signal/scale,-1,+1); the SEMANTICS change — the
  output is the unsized strategy bias, sizing leaves the strategy). Scoped to the
  semantic core; behaviour-preserving cosmetics are deferred (see below).
- stop-rule nodes (refs #119): VolStop = k*EMA(|price - prev_price|) (one fused node;
  the volatility that defines 1R, close-to-close — true-range ATR deferred, needs
  OHLC) + FixedStop (constant, the test fixture / fixed-vs-vol structural-axis sibling).
- PositionManagement (refs #127): the stateful heart. Latches the entry-cycle stop
  distance as the FROZEN R-denominator (never re-read), marks/exits no-look-ahead like
  SimBroker (a position earns from the next cycle; an exit realises against the cycle's
  close), and emits ONE dense per-cycle R-record (C8). Stop-outs are NOT capped at -1R
  (a gap through the stop realises R < -1 — the honest loss tail); R is computed
  size-invariantly (size = (exit-entry)*dir / latched_dist cancels). The trade ledger
  is the rows where closed_this_cycle; the R-equity is cum_realized + unrealized; a
  position open at window end is the last row (open=true) — explicit, never silent MtM.
- summarize_r + RMetrics (refs #129): the post-run fold (NOT an in-graph node —
  recorders drain post-run). E[R], win-rate, profit-factor, avg win/loss R, by-trade
  max R-drawdown, n_open_at_end (window-end force-close). SQN / conviction terciles /
  net-of-cost / RunMetrics.r are iteration 2.

Design adversarially hardened before implementation (12-juror refute panel; decisions
on #117). Ratified implementer deviations from the plan snippet, verified by hand:
- col-4 `direction` tracks the OPEN position at cycle end (window-end synthesis; names
  the reopened leg on a reversal), falling back to the closed trade's dir only when
  flat. summarize_r does not read col 4; the R-metrics are unaffected.
- .named("exposure") kept on the 4 previously-unnamed Bias nodes so the auto-derived
  param-path stays exposure.scale (no manifest/dir-name drift) — the unnamed nodes
  would otherwise flip to bias.scale.
- intra-doc links [`Exposure`] -> [`Bias`] in sim_broker/latch + the sample-model test
  pin (cosmetic, behaviour-neutral; broken intra-doc links fail cargo doc).
- the E2E drives a full bootstrapped Harness (stronger than the plan's direct-node
  drive — exercises the real cross-crate producer->consumer seam).

Deferred (behaviour-preserving, separate cosmetic pass — NOT this iteration):
RunMetrics.exposure_sign_flips / Metric::ExposureSignFlips, SimBroker/LongOnly
"exposure" input ports, the "exposure" tap/trace names, and the .named("exposure")
instance labels. Iteration 2: the Sizer seam, the RiskExecutor composite (+ the Veto
documented-seam), summarize_r enrichment, RunMetrics.r, and the CLI/recording surface.

Verification (orchestrator-run, not agent-claimed):
- cargo build --workspace: clean.
- cargo test --workspace: all green, 0 failed (incl. the new bias/stop_rule/
  position_management unit tests, the summarize_r arithmetic tests, and the
  stage1_r_e2e capstone + layout guard).
- cargo clippy --workspace --all-targets -- -D warnings: clean (exit 0).
- Keystone RED tests pass: no_lookahead_bias_exit_realises_the_held_move,
  stop_out_is_not_capped_at_minus_one_r, no_gap_stop_is_exactly_minus_one_r,
  reversal_closes_one_leg_and_reopens, open_at_window_end_is_carried_on_the_last_row.

refs #117 #119 #126 #127 #129
2026-06-23 19:48:58 +02:00
Brummel b91c89f964 feat(broker-foundation): InstrumentSpec deploy metadata + PositionEvent schema
The realistic-broker milestone's (C10 A-side) two foundation value types — no
broker, no derivation yet, fully tested.

#113 — InstrumentSpec gains six deploy-grade fields (instrument_id,
contract_size, pip_value_per_lot, min_lot, lot_step, quote_currency) and the
vetted table is populated for GER40/FRA40/EURUSD/GBPUSD/USDCAD. The struct stays
Copy (quote_currency is &'static str); the _ => None refuse-don't-guess arm is the
permanent floor. This is the milestone's permanent authored floor (recorded-metadata
tier is forward work, #124).

#114 — PositionAction { Buy, Sell, Close } (closed enum, serde-encoded as a bare
i64 0/1/2 via From/TryFrom) + PositionEvent in aura-engine beside RunMetrics,
faithful to the C10 column spec: event_ts, action, position_id, instrument_id,
unsigned volume; no open_ts; direction is the action (no signed-volume trick).
Re-exported from the crate root.

Derived fork decisions (recorded on #113): quote_currency &'static str; action as
ordinal i64; stable instrument_id + overridable vetted values.

Verified by the orchestrator: cargo test --workspace = 458 passed / 0 failed;
clippy --all-targets -D warnings clean. An adversarial 4-lens review (ledger
faithfulness, downstream soundness, serde/wire robustness, test honesty) returned
3 SOUND + 1 CONCERN: quote_currency &'static str cannot hold a runtime-sourced
currency at the #124 resolver — not a blocker (#115/#116 read only the authored
floor), logged on #124 as a deliberate deferred design choice. The implementer's
deviation from the plan snippet (vec![..] -> [..]) avoids clippy::useless_vec;
semantics identical.

Two tester-authored consumer-boundary E2E tests added beyond the inline units:
instrument_spec_deploy_metadata.rs (public resolution seam) and
position_event_table.rs (persisted bare-int wire shape + reversal table).

closes #113 #114
2026-06-22 19:51:17 +02:00
Brummel 8bb5256041 feat(trace-charts): persist recorder taps to disk (iter 1)
Iteration 1 (persist half) of the visual face — spec 0056 / plan 0055:
tap data from a graph and save it to disk so it can later be charted
(the serve/chart half is iteration 2).

- ColumnarTrace (aura-engine): a drained tap <-> columnar SoA form
  ({tap, kinds, ts, columns}); values coerced to f64 for plotting with
  the base type preserved in `kinds` (C7); to_rows rebuilds uniformly-f64
  rows for the serve-side join. Pure (C1), beside join_on_ts/summarize.
- TraceStore (aura-registry): a per-run directory under
  runs/traces/<name>/ — one <tap>.json per tap + an index.json (manifest
  + tap order), beside the families.jsonl sibling store. Missing run =
  NotFound (no panic); corrupt file = Parse{file}.
- CLI: a shared persist_traces helper threaded into run_sample /
  run_macd / run_sample_real via an orthogonal `trace: Option<&str>`;
  new arms `aura run [--macd] --trace <name>` and `--trace <name>` on
  `run --real`. The default (no --trace) run is byte-for-byte unchanged.

Verified by the orchestrator: `cargo test --workspace` green (incl. the
4 columnar_trace, 3 trace_store, 4 cli_run e2e, and the 4-tuple
parse_real_args tests); `cargo clippy --workspace --all-targets -D
warnings` exit 0. Implementer deviations folded in, all
compiler-prescribed: #[derive(Debug)] on RunTraces (the RED tests
panic-format a Result over it), #[allow(clippy::type_complexity)] on
the parse_real_args 4-tuple (mirrors the crate's sample_harness idiom).
Default-run byte-identity stays pinned by run_prints_json_and_exits_zero.

refs #101
2026-06-19 00:14:18 +02:00
Brummel 74324d178f feat(aura-engine): join_on_ts — timestamp join for multi-tap sink traces
Ship a generic, spine-anchored join_on_ts helper + JoinedRow in the report
layer (sibling of summarize/f64_field), re-exported from lib.rs. It fuses N
recorded (Timestamp, Vec<Scalar>) tap streams on the recorded timestamp:
exactly one JoinedRow per spine entry, each side looked up by ts as
Some(row)/None where it did not fire. Option-per-side semantics keep the
engine honest — it reports presence; the consumer interprets absence (the
0.0/-1/false defaults are consumer truths, not engine ones). C8/C18 (post-run
reduction over recorded sink output), C3 (no in-graph join), C1 (one row per
ts, documented precondition).

Two unit tests pin the contract: cardinality-misalignment alignment (the #93
shape — a spine bar a side tap skipped resolves to None, not a zip-misalign)
and the spine-anchored drop of a side row at a non-spine ts. RED accepted on
the grounding-check record (both symbols were absent pre-cycle, so the tests
are RED-by-construction) and verified green here.

drain_trace's migration onto this helper + the Recorder doc note follow.

refs #93
2026-06-18 14:55:30 +02:00
Brummel 86746e3d5d refactor(aura-core): Scalar as a native tagged enum, disjoint from Cell; typed RunManifest.params
Scalar was `struct { kind: ScalarKind, cell: Cell }` — "a Cell wearing a kind hat." The recorded reason for that shape was migration ease, which is not a design rationale (CLAUDE.md: rationale != effort), so the struct had no substantive defense. Redefine it as the native tagged union it conceptually is:

    enum Scalar { I64(i64), F64(f64), Bool(bool), Timestamp(Timestamp) }

This is substantively better on four axes: kind/bits skew becomes unrepresentable (illegal states gone); accessors panic loudly on the wrong variant instead of a release-mode silent bit reinterpret; PartialEq derives the documented IEEE-754 / cross-kind value semantics (the hand-roll, needed only because the struct inherited Cell's bitwise compare, is gone); and serde is a plain derive emitting the externally-tagged wire form ({"I64":10}/{"F64":2.5}) — the private ScalarRepr shadow enum that motivated this was never needed. It is also C7-honest: erased-on-the-hot-path (Cell) and self-describing-at-the-edge (Scalar) are two disjoint types bridged by explicit conversion.

The whole public API is preserved (Scalar's fields were private), so call sites do not churn: the enum change is contained to scalar.rs, where cell() now encodes and from_cell() decodes per kind. Cell and ScalarKind are untouched.

With Scalar serializable, lift RunManifest.params from Vec<(String, f64)> to Vec<(String, Scalar)>: the param's kind (an i64 length vs an f64 scale) now survives into the C18 record (runs.jsonl) and the CLI JSON instead of collapsing to f64. scalar_as_param_f64 is deleted; sim_optimal_manifest passes typed params through. This is a deliberate wire-shape change — params now render as tagged scalars; the JSON-asserting tests are updated to the new shape on purpose.

Hand-authored manifest params across the CLI's single-run/mc/macd sites use honest kinds (lengths -> i64, scales -> f64) so they match the sweep path (which already derives correct kinds via zip_params); their in-binary JSON assertions are re-tagged accordingly.

Walk-forward fork resolves with no code change: WindowRun.chosen_params stays Vec<Scalar> (the serializable record carrier), reduced to f64 only at the param_stability statistic boundary (scalar_as_f64 retained). Glossary 'cell' entry updated to describe Scalar as the disjoint tagged union, not 'a cell plus its kind tag'.

Gates: build --all-targets, test --workspace (incl. new scalar_serde_round_trips), clippy -D warnings, doc --no-deps — all clean.
2026-06-16 17:11:25 +02:00
Brummel cd3d1ca9ed refactor(aura-core): split Scalar into a tag-free Cell + ScalarKind
Motivation
----------
`Scalar` was a tagged enum (I64/F64/Bool/Ts), so every scalar value
physically carried its own kind tag. But the kind is already known from
the schema/port/column the value flows through (C7: the type is a
property of the column, not of the value — the hot path is already
columnar `Column<T>`, and `AnyColumn::get` *reconstructs* the tag from
the column on the way out). The per-value tag was therefore redundant
with the kind the surrounding context already holds.

That redundancy had three costs:

  * It baked an implicit `match` (a branch) into every function that read
    a Scalar payload — even where the caller statically knew the type.
    The tag could never be exploited away.
  * Size: a tagged enum is tag + payload = 16 bytes (f64/i64 alignment),
    twice the 8 bytes the value needs. A `Column<Scalar>` would be double
    the memory and half the cache utilisation.
  * It is the shared root of several downstream papercuts we keep hitting
    — the lossy f64 manifest field, the `unreachable!` panic on a
    non-numeric param, the serde-tag question — all symptoms of "the type
    is baked into the value".

Change
------
Introduce `Cell`: a type-erased 64-bit word (`struct Cell(u64)`) that is
not readable without external type context. It is constructed per base
type (`from_i64/from_f64/from_bool/from_ts`) and read only by naming the
type at the call site (`i64()/f64()/bool()/ts()`) — each a branch-free
bit-cast. The hot path resolves the kind once at the boundary (from the
schema) and then reads natively, with no per-value branch. `Cell` knows
nothing of `Scalar` or `ScalarKind`; the dependency is strictly one-way,
and it lives in its own `cell.rs` (more is planned on top of it).

`Scalar` becomes `struct { kind: ScalarKind, cell: Cell }` — the
self-describing form for the dynamic boundaries (builder binding,
serialization, rendering), built on top of `Cell`. Its `as_*` accessors
now `debug_assert` the kind and return the native value (free in
release); calling the wrong accessor is a caller bug, not a checked
`Option`. The variant constructors `Scalar::I64(..)` become associated
fns `Scalar::i64(..)`.

`PartialEq` is hand-written (not derived) to preserve the former enum's
value semantics: kinds must match, then native payloads compare, so f64
keeps IEEE-754 behaviour (`NaN != NaN`, `+0.0 == -0.0`) and a kind
mismatch is never equal even when the raw words coincide. A fixture
(`scalar_eq_is_value_not_bitwise`) pins exactly the cases where bit- and
value-equality diverge, so it can't silently regress. `Cell`'s own
`Eq`/`Hash` stay bitwise — correct for a raw word.

The change is behaviour-preserving: Scalar's observable behaviour is
identical to the pre-Cell enum (the value-equality fixture proves it);
only the internal representation changed. The ~440 call sites across the
workspace are a mechanical constructor rename plus ~12 destructuring
sites (match-arms / `let`-patterns) rewritten to `kind()` + `as_*`.

Verified: cargo build --workspace --all-targets, cargo clippy --workspace
--all-targets -- -D warnings, cargo test --workspace — all green.
2026-06-16 12:12:52 +02:00
Brummel eec129ee51 feat(aura-engine): Source ingestion seam — run() takes Vec<Box<dyn Source>>
The engine's ingestion input was the only type encoding an eager-dataflow
assumption: `Harness::run(Vec<Vec<(Timestamp, Scalar)>>)` — one fully
materialized stream per source. At realistic research scale (20y, 3 tick
streams, ~7.9e9 ticks) that layout is ~190 GB resident, non-residable. Yet
the merge loop never needs the whole stream: it only ever peeks the head
timestamp of each live source and pops one record. So the eager Vec is
gratuitous.

This lands the first half of the Source seam (plan Tasks 1-2):

  - A `Source` trait (`peek(&self) -> Option<Timestamp>`,
    `next(&mut) -> Option<(Timestamp, Scalar)>`), object-safe so the merge
    holds `Vec<Box<dyn Source>>`. The peek/next split is the producer contract
    the k-way merge drives.
  - `VecSource`, an adapter over the old `Vec<(Timestamp, Scalar)>` — the
    cycle-0011 eager shape, named. It IS the old behaviour.
  - `Harness::run` re-typed to `Vec<Box<dyn Source>>`; the merge loop's index
    arithmetic becomes peek/next. C4 tie-by-source-index is preserved (the
    strictly-`<` replace + source-order scan is byte-for-byte the old
    semantics). The forward+eval body is unchanged.
  - All 53 call sites threaded VecSource-wrapped in the same change, so the
    workspace compiles atomically and run output is byte-identical (C1).

Behaviour-preserving is the load-bearing guarantee: the full suite is green
and unchanged (the two-run C1 determinism pairs — real_bars r1/r2, blueprint
sweep-equality, harness h/h2 — stay byte-identical); aura-engine unit tests
129 -> 132 (+3 VecSource unit tests). No residency change yet: VecSource holds
the same Vec as before. The lazy data-server-backed streaming source and its
end-to-end residency proof are the second half (plan Tasks 3-4).

refs #71
2026-06-15 10:23:09 +02:00
Brummel f1d0bf00ec fix(aura-engine): unify RunReport stdout JSON with the on-disk serde shape
RunReport had two divergent JSON encoders: the registry serialized to disk via
serde_json (params as an array-of-pairs, floats as 2.0), while stdout rendered
through a hand-rolled to_json (params as an object, whole floats as 2). The same
record was a structurally different document on disk than on the wire, and no
test pinned their relationship — most visibly, `aura runs list` read a record
from disk via serde and reprinted it in a different shape.

Retire the hand-rolled writer: RunReport::to_json now delegates to
serde_json::to_string(self), so stdout is byte-identical to the runs.jsonl line
for the same record. The hand-rolled json_str helper is deleted (the
graph_model.rs json_str is a separate symbol, untouched — the graph-model JSON
is out of scope, #58/#28). A new pin test, to_json_equals_serde_disk_shape,
asserts to_json() == serde_json::to_string(&report) so the two encoders can
never silently diverge again.

Observable change: stdout `params` is now an array-of-pairs and finite f64
fields carry a fractional part (2 -> 2.0); the nested envelope, field order,
window [from,to], and integer-valued seed/exposure_sign_flips are unchanged. The
on-disk shape does NOT change — only stdout moves to match disk.

serde_json is promoted from a dev-dependency to a normal dependency of
aura-engine (to_json is non-test code). Under the amended C16 per-case policy
this passes: serde_json is a vetted standard crate already in the workspace
(registry, ingest), deterministic (C1-safe, already relied on for the disk
path), and is exactly "the vetted standard crate doing what the code would
otherwise hand-roll" — it removes the last hand-rolled RunReport JSON writer.

Goldens flipped to the serde shape: the engine canonical-form golden, the
aura-cli sweep golden (cli_run.rs), and the aura-cli odometer-order sweep golden
(main.rs) — the last was not in the plan's inventory; the cargo test --workspace
gate surfaced it and it took the identical transformation. Structural CLI asserts
and the r1.to_json()==r2.to_json() determinism asserts are shape-agnostic and
stay green.

Spec docs/specs/0033, plan docs/plans/0033, boss-signed (unanimous 5-lens panel).
Two stale prose cross-refs (graph_model.rs:30, docs/design/INDEX.md:729) deferred
to cycle-close audit. Gates: cargo build/test --workspace green, clippy
--all-targets -D warnings clean.

closes #54
2026-06-11 19:43:36 +02:00
Brummel eec876975c feat(engine): serde foundation + amend C16 dependency policy (cycle D / iter 1)
Iteration 1 of the run-registry cycle (#33): lay the dependency + serde
foundation the registry's typed read-path needs, and amend the contract that
forbade it.

Contract change (load-bearing — C18/C16). C16's blanket "zero-external-
dependency by commitment" + "aura-ingest is the sole external-dependency
firewall" framing is struck and replaced by a considered, per-case dependency
policy: dependencies are admitted by deliberate review (what a crate pulls in
vs. what it buys), with particular scrutiny for anything entering the frozen
deploy artifact (C13); standard vetted crates (serde, rayon, ...) pass that
review and are used wherever they do the job, including in the bot; hand-rolling
what a vetted crate does is the anti-pattern. C16's engine/project split +
three-tier node reuse are unchanged. The five source comments that asserted the
struck framing (aura-engine/aura-ingest Cargo.toml + aura-ingest/report.rs docs)
are rewritten to match; no live source or ledger text still asserts it.

Per-case justification for serde (the policy now requires one): it gives the
run-report types a typed (de)serialization path — exactly what ranking/compare
over stored runs needs, and what the writer-only hand-rolled JSON (C14) could
never provide; closes the typed-read-path gap (#17). Its closure is tiny,
ubiquitous, heavily audited, and its output deterministic (C1-safe). serde_json
is test-only this iteration (dev-dependency); no production serde_json yet.

Changes: [workspace.dependencies] serde (derive) + serde_json; serde derives on
Timestamp (aura-core) and RunMetrics/RunManifest/RunReport (aura-engine), with
serde_json round-trip tests (window renders as a [from,to] integer array via the
transparent Timestamp newtype). The hand-rolled to_json writers are untouched
(still drive stdout). No aura-registry crate, no SweepPoint change — those are
iterations 2 and 3.

Verified: cargo test --workspace green (incl. the two new round-trip tests);
clippy --workspace --all-targets -D warnings clean; no surviving assertion of
the struck zero-dep framing in live source or the ledger.

refs #33
2026-06-10 20:11:39 +02:00
Brummel e304dbaae1 feat(aura-core): name input ports
PortSpec gains a non-load-bearing `name: String`, so an input port is named just
as FieldSpec.name (output) and ParamSpec.name (param) already are — input ports
were the lone unnamed member of the node signature. Identity stays positional by
slot (C23); the name is render/debug only, never read by bootstrap or the run
loop. PortSpec drops Copy (String is not Copy), exactly as ParamSpec already does.

- Every aura-std node names its input slots: SMA/EMA "series", Sub/Add "lhs"/"rhs",
  Exposure "signal", SimBroker "exposure"/"price" (the slots become
  self-documenting), LinComb "term[i]" and Recorder "col[i]" generated in their
  build loops (mirroring LinComb's existing weights[i] param loop).
- derive_signature carries a composite's Role.name into the derived input port
  (it was dropped before — the output side already carried FieldSpec.name), so the
  graph model is homogeneously named at both levels.
- model_to_json (port_json + the composite-header inputs in scope_json) emits the
  name as a third tuple element: ["f64","any","exposure"]. The byte golden was
  re-captured (machine bytes) and its substring twins updated; the model is now
  fully named across inputs/outputs/params.
- All 16 PortSpec construction sites threaded in one compile-gate change; test
  fixtures carry fixture names. C8 realization note added to the design ledger.

Why name-only, no validation: the name is a pure debug symbol. Wire-by-name was
rejected (it would be a C23 contract change). Bootstrap slot-wiring validation
(which would close #21's same-kind swap footgun) is deferred to its own cycle —
a name alone does not catch the swap; it makes the slots self-documenting and
gives a future validation something to check against.

Verified: cargo test --workspace 168 green; clippy --all-targets -D warnings
clean; cargo build --workspace clean. Read-only render path (C9), no serde (C14),
scalar kinds unchanged (C4).

closes #50
refs #21
refs #51
2026-06-10 16:19:08 +02:00
Brummel 1b3909316e feat(aura): node signature lives in the blueprint; collapse Blueprint into Composite
Consolidate the node data structure so every node's signature (NodeSchema:
inputs/output/params) is declared once and exists in the blueprint pre-build,
and dissolve the special "root graph" type. Behaviour-preserving (C1).

Signature vs sizing
- NodeSchema is now the static signature only: InputSpec -> PortSpec{kind,firing},
  with lookback removed. The signature is fully static per blueprint (input
  kinds/firing, output fields, params); LinComb's variable arity is a builder arg,
  not an injected param.
- The one param-dependent quantity, an input's buffer lookback (e.g. Sma's window =
  its injected length), moves out of the signature to Node::lookbacks() -> Vec<usize>,
  read only by bootstrap for sizing. Node::schema() is removed.
- LeafFactory -> PrimitiveBuilder, which carries the full NodeSchema. The built node
  no longer re-declares it: closes the params-declared-twice drift (#36, the 8
  per-node factory_params_match_built_node_schema lockstep tests are deleted — their
  subject is now structurally impossible) and a value-empty recipe exposes its full
  I/O interface pre-build (#43).

Root is just a bound composite
- struct Blueprint is deleted; its compile/bootstrap/param_space methods move onto
  Composite. Role gains source: Option<ScalarKind> (None = open interior port,
  Some = bound ingestion feed). A composite is runnable iff every root role is bound;
  the "main graph" is no longer a category, only the fully-source-bound composite.
  New error CompileError::UnboundRootRole for an open root role.
- BlueprintNode::signature() answers uniformly for both arms: Primitive returns the
  builder's declared schema, Composite derives it from the interior (role kinds in,
  OutField kinds out, aggregated params), pre-build, no build.

compile -> FlatGraph -> bootstrap
- compile validates structure pre-build via signature() (validate_wiring: range +
  kind, returning the same variants as before, so an edge kind fault is now caught
  before any build closure fires) and emits FlatGraph{nodes,signatures,sources,edges}.
- bootstrap consumes the FlatGraph: kinds/firing/output from the carried signatures,
  buffer depth from node.lookbacks(). SourceSpec survives as the flat descriptor.

Renames: BlueprintNode::Leaf -> Primitive, LeafFactory -> PrimitiveBuilder.

Render (aura-cli/src/graph.rs) is migrated compile-only: it takes &Composite, maps
bound roles to the same source-entry shape, so both render goldens reproduce
byte-identical output (no re-capture needed). Render-fidelity tuning is the next cycle.

Verification (orchestrator-run, not agent-reported): cargo build --workspace green;
cargo test --workspace 150 passed / 0 failed; cargo clippy --workspace --all-targets
-D warnings clean. All pinned determinism/run-output tests pass with values unchanged;
no behavioural assertion was altered to go green. 5 new tests assert the signature is
pre-build and uniform, that compile rejects a kind mismatch without building (via a
panicking builder), UnboundRootRole, and lookbacks()/signature arity agreement.

Deferred to cycle-close audit (per plan): docs/design/INDEX.md and some aura-std
module docs still name the old Node::schema()/LeafFactory/BlueprintNode::Leaf/
Blueprint::param_space contracts; prose reconciliation is the architect's at audit.

closes #43 #36
2026-06-09 12:49:22 +02:00
Brummel 561482c422 tidy(aura-engine): re-export scalar vocab (#29) + drop duplicate Recorder fixtures (#14)
Two behaviour-preserving tidies from the post-0013 fieldtest/backlog.

#29: aura-engine re-exports Firing/Scalar/ScalarKind/Timestamp at the crate
root. A Blueprint builder needs ScalarKind for SourceSpec.kind and Scalar/
Firing/Timestamp for sources & Recorder columns, but aura-engine previously
re-exported only the wiring types — forcing a second aura-core import for the
vocabulary its own public structs (SourceSpec.kind, ...) demand. The fieldtest
flagged this paper cut (mc_4). One import surface now. Guarded by a new
reexport_tests module.

#14: the two near-identical #[cfg(test)] Recorder fixtures in report.rs and
harness.rs are deleted in favour of the shipped aura-std::Recorder (already a
dev-dependency). Constructor signature, schema, and try_iter() drain are
identical, so no call site changed — only the struct/impl deletion plus adding
Recorder to each file's existing aura_std import. report.rs additionally drops
four imports (Ctx/InputSpec/Node/NodeSchema) that only the deleted fixture
used. The separate TapForward fixture in harness.rs is untouched.

Verified: cargo build/test/clippy --workspace all green (49 aura-engine lib
tests incl. the new one); bit-identical, golden-snapshot, SMA-disambiguation,
and mixed-kind recording tests all stay green — behaviour preserved.

closes #29
closes #14
2026-06-05 23:25:48 +02:00
Brummel 7a8d2097e7 docs(aura-engine): document to_json JSON schema + integer-token rendering
Resolves the two doc-level findings from the cycle-0009 fieldtest
(docs/specs/fieldtest-0009-run-metrics.md), both the same doc pass on
RunReport::to_json's rustdoc:

  - spec_gap: the JSON key names and {manifest,metrics} nesting were not on the
    public surface — a consumer parsing the JSON (the C18 registry, the deferred
    aura run printer) could not author against it from rustdoc alone. Now stated:
    keys mirror the struct field names in fixed order, window is a 2-element
    [from,to] array, params is an insertion-order object, with a worked sample
    object. Ratifies field-name-mirroring as the contract for the C14 face.
  - friction: a whole-valued f64 renders without a fractional part (12.0 -> 12),
    so one f64 field may appear as an integer or decimal token across runs. Now
    documented, with the consumer guidance to parse as a number, never key off
    token shape.

Doc-only change; no behaviour change. The sample is a `text` fence (not a
doctest). Verified: RUSTDOCFLAGS="-D warnings" cargo doc -p aura-engine clean;
clippy -p aura-engine --all-targets -D warnings clean; doctests 0.

refs #6
2026-06-04 19:06:59 +02:00
Brummel 80d7bfbbf0 feat(aura-engine): run metrics + manifest report surface
Cycle 0009 (Walking skeleton milestone). A run's two C18 "from day one"
artefacts — a reproducible manifest and summary metrics — land as a new,
pure-additive `report` module in aura-engine:

- summarize(equity, exposure) -> RunMetrics: a post-run pure reduction over a
  run's recorded pip-equity + exposure streams. total_pips (last cumulative
  value), max_drawdown (worst running-peak-to-trough), exposure_sign_flips (a
  turnover proxy: adjacent normalized-sign changes, flat distinct from
  long/short via a three-way sign0). Total and pure: empty -> zeros, identical
  inputs -> identical metrics (C1/C12).
- RunManifest: the reproducible descriptor (commit, params as name->value
  pairs, data-window, seed, broker label). Caller-supplied — the engine cannot
  introspect a git commit or seed. params is the honest precursor to the
  deferred typed param-space (node.rs; 0008 audit).
- RunReport { manifest, metrics } + to_json(): canonical, hand-rolled JSON for
  the structured C14 face.
- f64_field(rows, field): bridges a recording sink's Vec<Scalar> rows to
  summarize; panics on a kind/width mismatch (a wiring bug), like the engine's
  other "checked at wiring" violations.

Why post-run, not a node: a node emits one record per eval with no terminal
eval (C8), so an end-of-run reduction has no home as a node; the World drains
its cycle-0006 recording sinks after Harness::run and folds them here. Matches
C18's "store manifests + metrics, re-derive results on demand".

Why hand-rolled JSON, not serde: the workspace is deliberately zero-dependency
and the schema is tiny/closed/flat; serde is reconsidered when the run-registry
milestone grows the schema. (User-approved at spec review.)

Scope: the surface + a determinism end-to-end test reusing the cycle-0007
two-sink harness; the `aura run` subcommand that prints the report is #8. No
engine/Harness/node-contract change; workspace stays zero-dependency.

Verified: cargo test --workspace green (aura-engine 40, incl. 10 new report
tests); clippy --all-targets -D warnings clean; RUSTDOCFLAGS=-D warnings cargo
doc clean.

closes #6
2026-06-04 18:57:14 +02:00