Commit Graph

54 Commits

Author SHA1 Message Date
Brummel e2c550f2f9 feat(aura-engine): monte_carlo family — McFamily over a seed set (C12 axis 4)
`monte_carlo(base_point, seeds, run_one) -> McFamily`, the Monte-Carlo
orchestration family: a fixed base point run over a seed set, each seed a
disjoint C1 realization. C12 axis 4 — Monte-Carlo IS a sweep over seeds, so it
reuses the disjoint-parallel executor rather than forking a run loop.

The reuse is realized by extracting the index-parallel core out of
`sweep_with_threads` into a `pub(crate) run_indexed<T>` (generic over the
per-job result, over a 0..n job range); `sweep_with_threads` becomes a thin
adapter over it. Behaviour-preserving (C1/C11/C23): the three pre-existing sweep
tests stay green — they ARE the proof the refactor preserved behaviour. A small
incidental win: the adapter consumes `points` via zip, dropping the old
per-point clone.

mc.rs adds McDraw{seed,report} / McFamily{draws,aggregate} (draws in seed-INPUT
order, independent of thread completion) / McAggregate (mean + p5/p25/p50/p75/p95
of all three run metrics — the V1 decision: covers every metric, not a "chosen"
one) / MetricStats, a private type-7 linear-interpolation `quantile`, and
`McAggregate::from_draws` (a pure post-run reduction recomputable from the
retained raw draws). Eager-agnostic firewall (#71): the API takes seeds + a
per-draw closure `Fn(u64,&[Scalar])->RunReport`, never a materialized stream Vec;
seed->Source construction stays a closure-body concern. An empty seed set is a
debug-asserted precondition, preserving the no-Result `-> McFamily` signature.

7 RED-first mc::tests pin: one draw per seed in input order; determinism across
1 vs 8 threads (C1); draw == an independent seeded run; distinct seeds perturb
the family; aggregate == from_draws(draws); type-7 quantile/mean math on a known
fixture; quantile endpoints + singleton. Workspace green, clippy -D warnings
clean, cargo doc clean.

refs #68
2026-06-15 13:45:28 +02:00
Brummel b9203624d3 audit: cycle 0042 — drift-clean (seed-as-input)
Architect drift review over 682e459..HEAD (the seed-as-input cycle plus the
prior unaudited `aura run --real` work). Cycle is sound: Fork B respected (seed
forks at the data-generation edge into the source and the manifest; the engine
loop sees only a Source — C12/C1/C3 intact), the contract names Source never Vec
(precondition for #68/#52 delivered as specced), and the four seed-free callers
thread 0 in lockstep.

One medium doc-lag item found and fixed here: aura-engine/src/lib.rs module-doc
listed the seed axis under "still to come"; seed-as-input shipped this cycle and
RunManifest.seed is now live, so the headline doc now records SyntheticSpec as a
delivered seeded Source producer (params + orchestration families remain future).

One low carry-on note (not drift): run_sample_seeded exists only under
#[cfg(test)] — intended this cycle (no --seed CLI flag per spec non-goals); the
only non-test seed value remains 0. A scaling marker for #68, not debt.

Regression gate (test suite — no dedicated regression scripts in project facts):
cargo test --workspace green; clippy --workspace --all-targets -D warnings clean;
cargo doc -p aura-engine clean (the new [`SyntheticSpec`] intra-doc link resolves).
2026-06-15 12:18:33 +02:00
Brummel c9f0e438e1 feat(aura-engine): seeded source + live RunManifest.seed (C12 seed-as-input)
A seeded source whose stream is fully determined by a u64 seed, making
RunManifest.seed a live captured input instead of the dead 0 it was. This is
C12's reconciliation of stochastic runs with C1: a run is bit-identical for a
fixed seed (the seed is a captured input, not hidden nondeterminism), and a
different seed gives a different-but-reproducible run. Precondition for the
Monte-Carlo family (#68) and random param-sweep (#52).

Engine (aura-engine):
- SplitMix64: a ~10-line private, dependency-free PRNG. Chosen over a rand-family
  crate for guaranteed bit-stability across toolchains/crate versions — the whole
  value of seed-as-input is reproducibility, and a crate whose algorithm is not
  version-stable would silently break a recorded seed on a dep bump. No new direct
  dep.
- SyntheticSpec + source(seed) -> impl Source: the Fn(u64) -> impl Source contract
  (Fork B — seed at the data-generation edge, outside the engine graph). Returns a
  Source, never a Vec, so the MC family can re-seed N times without materializing
  N streams. First cut collects to a VecSource internally; the signature does not
  name Vec, so a lazy generator is a drop-in replacement. Re-exported from lib.rs
  beside VecSource.

CLI (aura-cli):
- sim_optimal_manifest gains a seed: u64 param recorded into the manifest (the
  single Fork B capture site). All four existing seed-free callers (run_sample,
  run_sample_real, the sweep grid-report, run_macd) pass 0 unchanged — their
  determinism tests stay green.
- A test-only run_sample_seeded + SeededTrace exercise a live seed and expose the
  drained sink trace, so the three e2e tests assert bit-identity at the trace
  level (strictly stronger than the folded 3-field metrics).

Tests (RED-first): 2 engine producer tests (same-seed identical / different-seed
differs) + 3 cli e2e tests (bit-identical trace, different-seed metrics differ,
seed recorded in manifest). Full workspace green; clippy -D warnings clean.

Errata vs plan 0042 (two compile-forced deviations, neither alters the spec
contract):
- source returns `impl Source + use<>`: under edition 2024 a bare `impl Source`
  captures the &self lifetime, which blocks boxing as Box<dyn Source + 'static>
  for run() (E0597). The source owns its data, so use<> (captures nothing) is
  correct.
- SyntheticSpec is imported inside `#[cfg(test)] mod tests` in main.rs: it is
  used only by the seeded test vehicle, so a top-level import is unused in the
  non-test build under clippy -D warnings.

closes #66
2026-06-15 12:16:03 +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 6021ce6aeb docs(aura-engine): drop two stale aura-cli cross-references in graph_model
kind_str and collect_distinct_composites no longer exist in aura-cli (it
consumes model_to_json directly), so the "mirrors aura-cli's ..." notes
pointed at nothing. Keep the substantive rationale (Debug is PascalCase).
2026-06-14 22:30:17 +02:00
Brummel 941503ed3d refactor(aura-engine): fold resolve/resolve_axes into one generic resolve_into
resolve (single Scalar per slot) and resolve_axes (a Vec<Scalar> axis per
slot) were the same two-phase named-binding resolution against param_space(),
duplicated almost verbatim — the live instance of the "two raise-sites for
one invariant" hazard (cf. #45/#53). A change to binding precedence or error
ordering had to touch both in lockstep.

Collapse the shared skeleton into resolve_into<T>, parameterized by two
closures: claim_ok (phase-1 claim-time gate, axis-only EmptyAxis, before the
duplicate check) and kind_ok (phase-2 per-slot kind gate). The error total
order now lives in exactly one place. Behaviour-preserving: same error for
same input, all 231 tests green (the resolve/bind precedence tests pin it).
2026-06-14 22:30:17 +02:00
Brummel a8bd52eaff refactor(aura-core): own FieldSpec.name to drop the derive_signature heap leak
FieldSpec.name was &'static str, forcing derive_signature to Box::leak
every re-exported composite OutField name. Because compile_with_params
reruns derive_signature per sweep point, an N-point sweep over an
M-output-field composite leaked N×M boundary names unbounded — a scaling
wall the moment the World/sweep layer compiles families per point.

Pull FieldSpec.name to String, mirroring the already-owned PortSpec.name
and ParamSpec.name: a composite re-exports interior fields under runtime
boundary names, so ownership is the correct model, not a workaround.
FieldSpec loses Copy; the only fallout is one borrow at the render site.
leak_name/Box::leak deleted.

Behaviour-preserving: signature contents unchanged, all 231 tests green.
2026-06-14 22:21:15 +02:00
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00
Brummel 227d004c9d feat(aura-engine): reject unwired or double-wired input ports
A graph that leaves an interior input slot unconnected, or wires one slot from more
than one producer, is now a compile-time error instead of a silently-wrong run.
Until now an unwired interior slot was accepted and bootstrapped to an empty column
(harness.rs:137-148), so a forgotten connection ran a deterministic but wrong
backtest; two producers into one slot — ill-formed, since a slot holds one column —
was likewise uncompiled-against.

A single name-free check, `check_ports_connected`, is added to `validate_wiring`
after the existing index/kind checks and before the nested-composite recursion. It
counts coverage of each (interior-node, slot) uniformly across interior edges and
role targets, and rejects zero coverage (new `CompileError::UnconnectedPort`) or >1
(new `CompileError::DoubleWiredPort`). Because `validate_wiring` is called once from
`compile_with_params` and recurses, both the raw `Composite::new` path and the
`GraphBuilder::build()` path inherit it at every nesting level with no builder-side
code (mirrors `check_param_namespace_injective`, the param-side sibling). A
composite's own open input roles (source: None) are coverage providers — the
wired-by-enclosing boundary, the root already guarded by UnboundRootRole — not
consumers. Index-based and name-free: nothing reaches the compilat, so C23 holds.

Supporting fix: the check computes `signature()` on every interior node before the
recursion validates that node's interior, so `derive_signature` and
`interior_slot_kind` are made bounds-total (a placeholder kind for an out-of-range
OutField/target, never a panic; the real fault is still reported by validate_wiring's
guarded OutputPortOutOfRange/BadInteriorIndex). This latent panic was exposed by the
new check, not introduced by it.

Test maintenance (blast radius enumerated empirically, not hand-traced): four
existing tests relied incidentally on under-wiring being accepted — three negative
tests get a covering root role so their intended deep fault still surfaces via the
recursion, and one param-order nesting test is fully wired (param order unchanged).
Six new tests: five raw-surface (unwired, double-wire via edge+role and edge+edge,
nested, open-role boundary) and one builder-surface forgotten-leg (proving the
GraphBuilder inherits the check).

Narrowed scope of #65: the original "promote names to load-bearing wiring keys /
close the exposure-price swap structurally" goal was dropped — #64 already moved
authoring to handles + visible port names, so the residual same-kind swap is a
valid-but-wrong name choice no structural check catches, and a structural fix would
push domain semantics into the domain-free engine (C10/C7). This ships the name-free
half that stands on its own: wiring completeness, which catches forgotten and doubled
connections.

Verified: cargo test --workspace 231 passed / 0 failed; cargo clippy --workspace
--all-targets -- -D warnings clean.

closes #65
2026-06-14 16:39:40 +02:00
Brummel b6174cff80 refactor(aura-engine): rename NodeHandle::in_/out to input/output
The trailing underscore on in_() was a keyword-escape scar (`in` is reserved).
Rename the NodeHandle port/field accessors to input()/output(), which also
aligns the authoring surface with the schema's own nomenclature
(NodeSchema.inputs/output, "input port"/"output field"): node.input("lhs")
now reads in the same vocabulary the schema uses, instead of a second one.

Mechanical, behaviour-preserving: the two method definitions plus every call
site in builder.rs and the aura-cli sample blueprints. Full workspace green;
clippy --all-targets -D warnings clean.
2026-06-14 10:35:52 +02:00
Brummel a6a314bb98 feat(aura-engine): name-based blueprint wiring via GraphBuilder
Add an additive, fluent GraphBuilder authoring surface that wires a
blueprint's topology by typed node handles and port/field names instead of
raw positional indices. Node references are Copy NodeHandle values (a node
reference cannot be mistyped); only port/field names are strings, resolved
against each node's cached NodeSchema by exactly-one-match at a single
fallible build() -> Result<Composite, BuildError> — the Binder posture, one
level up. build() lowers to the unchanged Composite::new, so the compilat
stays index-wired (C23): names resolve at the authoring boundary and never
reach FlatGraph. A new From<Composite> for BlueprintNode lets add() accept
nested composites.

Verified: builder-authored sma_cross is structurally equal to the hand-wired
fixture, and the full builder-authored harness lowers to a byte-identical
FlatGraph (equal edges + sources). The five BuildError variants
(Unknown/Ambiguous In/Out, BadHandle) are covered, and the SimBroker
exposure/price legs are now addressable by name (the #21 legibility win).

The structural close of the exposure/price swap (#21) is deliberately out of
scope — this builder makes the swap legible, not impossible — tracked as #65.

Two plan-test corrections applied during implementation (both in-scope, no
behaviour change): the harness parity test uses compile_with_params with a
topology-invariant param vector on both sides (the harness declares three
params, so a no-param compile() would trip ParamArity), and the error asserts
use .err()/Some(...) rather than unwrap_err() (Composite is not Debug).

aura-engine 123 tests green (+9); full workspace green; clippy --all-targets
-D warnings clean. Existing index-form Composite::new sites and tests
untouched (coexistence).

closes #64
2026-06-14 02:27:03 +02:00
Brummel 1f01badac7 refactor(aura-engine): share the SMA-cross harness test fixtures
Collapse the three verbatim-duplicated #[cfg(test)] fixtures —
synthetic_prices, sma_cross, composite_sma_cross_harness — that lived
in both blueprint.rs's and sweep.rs's test modules into one shared
crate-root `#[cfg(test)] mod test_fixtures` (pub(crate) free fns). The
two consumers now import from `crate::test_fixtures`; sweep.rs's
test-mod `use` block sheds the imports that were only needed by the
moved fns.

Why: the topology had to move in lockstep across the two copies but no
test pinned them equal, so an edit to one silently diverged the other
(#53). One definition removes the hazard structurally — better than a
pin-them-equal test, which loses its premise once there is a single
definition. Test-only; no production code, public API, or runtime path
changes. C16 untouched (a #[cfg(test)] module ships in no artifact).

The CLI sample is no longer a third peer (cycle 0036 enriched it into a
trend+momentum blend), so this is an engine-internal two-copy dedup, not
the three-way the issue originally named. graph_model.rs keeps its own
intentionally-minimal golden copy, out of scope.

Plan glitch fixed in-flight: the plan's literal import list named
sma_cross, but neither consumer calls it directly (only transitively via
composite_sma_cross_harness), so importing it tripped the plan's own
Step-7 clippy -D warnings gate. Trimmed to {composite_sma_cross_harness,
synthetic_prices} — within the task's "drop now-unused imports" scope.

Verified: cargo test --workspace green (aura-engine 114 passed, count
unchanged — behaviour-preserving); the two load-bearing fixture
consumers composite_sma_cross_runs_bit_identical_to_hand_wired (C1) and
sweep_equals_n_independent_runs (C11/C12) both green; cargo clippy
--workspace --all-targets -- -D warnings clean.

closes #53
2026-06-14 00:18:04 +02:00
Brummel 47e0e605e1 feat(aura): surface bind-bound params in the graph model + viewer signature
A node built with `.bind(slot, value)` fixes a declared param to a structural
constant: the slot correctly leaves the tunable surface (param_space / sweep axes),
but it also vanished from the RENDERED signature, because prim_record emitted only
schema.params. The cycle-0036 `blend` (a LinComb(3) with weights[2] bound to 0.5)
rendered `LinComb[weights[0], weights[1]]` — a 2-arity signature over a 3-port box,
with the fixed 0.5 invisible. The signature misrepresented the node.

bind now ANNOTATES instead of erasing. All slots are shown; the bound one renders
`name=value`, dimmed. The blend renders:
    blend: LinComb[weights[0], weights[1], weights[2]=0.5]

Structurally a twin of cycle 0035's instance-name thread (commit 0ae8320): a
conditional field threaded engine -> model -> viewer.
- aura-core: a `BoundParam { pos, name, kind, value }` recorded by bind alongside
  the unchanged closure capture, plus a `bound_params()` accessor (twin of
  instance_name()). `pos` is the slot's ORIGINAL pre-bind position (the compressed
  index is mapped back over earlier-bound positions), so the render is faithful for
  any bind pattern, not just a trailing one.
- aura-engine: a conditional `"bound"` field in prim_record (present only when the
  node has binds, mirroring the conditional "name"), each entry [pos,"name","kind",
  "value"] via a new scalar_str helper (f64 via {:?} — keeps a decimal point so a
  bound f64 never reads as an i64).
- graph-viewer.js: adaptNodes/genDot carry `bound`; cellLabel merges free + bound
  params back into slot order by position and renders the bound slot dimmed
  (reusing the 0035 separator grey #9399b2).

Render notation settled with the user (issue #63, reconciliation comment): keep the
[...] convention and name=value (not parens, not positional value-only).

Render/debug surface only: dropped at lowering (C23 — bind still resolves the name
to a fixed position and the compilat is unchanged), model stays deterministic (C14
— the inline no-bind golden is byte-unchanged; only the sample fixture's blend node
gains "bound"). The tunable surface is untouched: the eight-param sweep pins stay
byte-identical (C12/C19). New unit tests pin the recorded original position and the
conditional model field; a new headless render guard pins the dimmed name=value
render and slot-order faithfulness for both a trailing and a non-trailing bind.

Verified: cargo build/test (0 failed) + clippy -D warnings (0) all green.

closes #63
2026-06-13 23:23:29 +02:00
Brummel d069484559 audit: cycle 0036 — refresh stale sample cross-refs; drift-clean otherwise
Close-audit for cycle 0036 (commit range 560c2d0..94af4c7). Architect drift review
against the design ledger + CLAUDE.md: no contract drift. No regression scripts are
configured (the architect is the gate); build + test + clippy verified green.

cycle 0036 tidy (clean):
- C8/C9/C10 preserved: the enriched sample is multiply-nested composites over shipped
  primitives — multi-output is the macd composite re-exporting columns (not a >1-record
  primitive); brokers stay downstream nodes.
- C12/C19 preserved: the eight sweep axes are a bijection with the injective,
  path-qualified param_space under nesting; the bound blend.weights[2] is correctly
  absent from both the axes and the surface. The re-captured render golden, the two
  in-crate pins, and the cli_run E2E pin all carry the identical eight-tuple in lockstep.
- C14 preserved: the re-captured sample-model.json is the deterministic model of the
  enriched sample; the engine's own minimal model_golden (sample_root) is independent
  and byte-unchanged.
- C23 preserved: bind resolves the param name to a fixed position; the fixed blend
  weight is a structural constant (deform-not-tune), dropped from the tuning surface,
  and the compilat is unchanged.

Two stale cross-references found and refreshed (doc debt, no behaviour change):
- crates/aura-engine/src/graph_model.rs: sample_root's "Mirrors build_sample()
  (...:161-190)" was doubly stale — build_sample moved and the CLI sample is now the
  richer signals graph; re-grounded to state sample_root is a deliberately minimal,
  independent serializer fixture.
- crates/aura-cli/src/main.rs: sample_blueprint_with_sinks' "single source of the
  sample topology" + "SMA lengths + exposure scale" overclaimed post-enrichment;
  re-grounded to the root harness whose signal is the nested signals composite, with
  the eight free params.
2026-06-13 20:27:25 +02:00
Brummel 0ae8320d14 feat(aura): surface explicit node instance name in the graph viewer
A leaf primitive built with `.named("fast")` now renders its `aura graph` viewer
box head as `fast: SMA[length]` — the instance name as a `:` declaration prefix.
An unnamed leaf keeps the bare `SMA[length]`.

Engine half: a new raw `PrimitiveBuilder::instance_name() -> Option<&str>` (the
explicit name, default not resolved) feeds a conditional leading `"name"` field
in prim_record, present only for an explicitly-named node; both golden twins
(inline model_golden + sample-model.json) re-captured. Viewer half: adaptNodes
carries `name`, the genDot leaf-emit forwards it, and cellLabel prepends the
`name: ` prefix when present (composites stay bare). `named()`'s non-empty
debug_assert is unchanged, its doc re-grounded to the now load-bearing Some/None
invariant (knob-address segment + prefix switch).

The name is a render/model-only debug symbol — dropped at lowering (C23), and the
model stays deterministic (C14). Parameter ganging (one knob, several nodes) was
considered and spun off as an explicit composite-shared-param idea (#61), not via
name collision (param_space is injective, C12/C19).

closes #58
2026-06-13 19:02:16 +02:00
Brummel 7d587d0c4e feat(aura-core): PrimitiveBuilder::bind — fix a node param as a structural constant
Add `PrimitiveBuilder::bind(slot, value)`: bind a declared param to a structural
constant, removed from param_space entirely rather than pinned in the injected
vector. This closes the gap #55 names — param-bearing nodes (Sma/Exposure/LinComb)
had no constant path, only the no-sweep-mode SimBroker precedent. The motivating
case is "SMA2-entry": the 2 is bound to the two-candle construction, so sweeping
it would enumerate deformed strategies as valid family members.

Approach (Option B, builder-level — the originally-planned direction): bind shrinks
the builder's declared param surface (schema.params) and wraps its build closure to
re-splice the captured constant at its original positional slot. The construction
layer (collect_params / lower_items / param_space / compile_with_params) is
byte-unchanged: both dock sites already key off builder.params(), so the shrink
propagates for free. Addressing is by param name (the by-name authoring address
space, C23/0032); the compilat stays wired by raw index — names never reach it.

bind enforces "exactly one open param matches" itself (collect-all-then-reject,
panic on zero / on duplicate), mirroring the resolve binder's posture, because a
node's own schema.params is not covered by check_param_namespace_injective. Chained
binds reconstruct the correct positional vector because each layer computes its
slot index relative to the param list it sees (no global position table).

Considered and rejected: a per-node builder_const(N) constructor (explodes
combinatorially over which subset is constant, not dynamic); a ParamBinding overlay
struct on Composite (would need new bookkeeping the builder-level form avoids).

Verified: cargo build/clippy/test --workspace all green; new tests cover slot
removal, positional reconstruction (chained + partial, via eval), the three panic
paths, and the composite param_space projection; blueprint.rs construction layer
confirmed byte-unchanged.

Deferred (out of scope, follow-up issue): exporting a named frozen strategy as a
blueprint-as-values collection — the issue's third fork, needs its own brainstorm.

closes #55
2026-06-12 16:26:28 +02:00
Brummel 52e0214adb audit: cycle 0033 (#54) — refresh stale JSON-writer cross-refs after to_json→serde
Architect drift review over 065cf1d..HEAD. Code + contracts hold; the only drift
was documentation cross-references made stale by retiring RunReport's hand-rolled
to_json.

Contracts (all hold):
- C14: to_json still emits canonical, flat, deterministic JSON — now serde-produced.
  model_to_json (the graph-model JSON) stays hand-rolled and was correctly untouched.
- C16: serde_json dev-dep → normal-dep in aura-engine is squarely sanctioned by the
  amended per-case policy (INDEX.md:576-583) — a vetted standard crate used where it
  does the job (incl. the frozen bot), removing a hand-rolled writer. No gap.
- C1: report types have no HashMap; serde is declaration-order/deterministic; the
  r1.to_json()==r2.to_json() asserts and the new to_json_equals_serde_disk_shape pin
  stay green.

Regression: none configured (profile regression: []) — no-op; architect is the gate.

Resolution — fix (3 prose cross-refs, fixed inline this commit):
- graph_model.rs:5 (module doc) — claimed the model JSON is in the "RunReport::to_json
  house style (no serde)"; to_json is serde now. Reframed: the model JSON is hand-rolled
  (no serde), and is the engine's last hand-rolled JSON writer since 0033.
- graph_model.rs:30 (json_str doc) — cross-referenced RunReport::json_str, deleted this
  cycle. Reworded to describe the escape set directly (graph_model's own json_str stays).
- docs/design/INDEX.md:729 — "golden-tested like RunReport::to_json" framed the hand-rolled
  model JSON by a now-serde to_json. Reframed to name model_to_json as the surviving
  hand-rolled writer.

Cycle 0033 drift-clean after this tidy. Not a milestone close (the param-sweep milestone
fieldtest is a separate deliberate act). Gates: clippy --workspace --all-targets -D
warnings clean (doc-only edits, compile-inert).

refs #54
2026-06-11 19:46:34 +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 1b7e4ad169 feat(aura-engine): param_space() injectivity as a compile invariant
A non-injective param_space() — two slots under one path-qualified name — is
the by-name knob address space (C12/C19) being broken: no binding can select
one slot without the other. This cycle makes it a structural compile error.

One check, check_param_namespace_injective over the param_space() names,
replaces the whole fan-in machinery (signature_of, leaf_has_param,
check_fan_in_distinguishability, check_composite_fan_in) and runs from
compile_with_params AND from both binders before resolve/resolve_axes — so the
canonical by-name author sees the structural cure (CompileError::DuplicateParamPath,
carrying the path and pointing at .named(...)) instead of the AmbiguousKnob symptom
(#59). With an injective space guaranteed before resolve, no bound name can
multi-match, so BindError::AmbiguousKnob is dead and removed (both resolver arms ->
unreachable!). param_space() stays infallible; the invariant lives in the compile
step (C23).

The check is strictly the by-name-addressability property. The old fan-in predicate
(equal signature AND >=1 param) also rejected two collisions that are NOT
param_space duplicates — an asymmetric param/paramless collision and a role-vs-leg
collision — which guarded render identity, dead since the renderer was retired in
0026. Both are intentionally dropped; a future node-/wiring-name check, if wanted,
is decoupled from injectivity.

The new invariant correctly rejected a pre-existing fixture (multi_output_composite's
two unnamed Sma legs); cured in place with the .named(fast/slow) the invariant
mandates. A new E2E test drives the cured cross through the by-name binder
end-to-end (resolve + bootstrap + run to a populated exposure trace).

C9/C12/C19/C23 ledger notes amended. Verified: cargo build/test --workspace green
(0 failed), clippy --workspace --all-targets -D warnings clean.

closes #59
2026-06-11 18:30:26 +02:00
Brummel ffed8cc612 feat(aura-core,aura-engine,aura-cli): node-instance naming retires ParamAlias
Every blueprint node now carries a name. A primitive builder gains an optional
instance name (Sma::builder().named("fast")); when omitted it defaults to the
node's type label, ASCII-lowercased verbatim ("SMA"->"sma", "SimBroker"->
"simbroker"). param_space() is now uniformly <node>.<param> at every level
including the root (sma_cross.fast.length, exposure.scale). Fan-in
distinguishability (C9) and signature_of re-key onto the node name: two same-type
siblings both defaulting to "sma" collide and IndistinguishableFanIn fires, so
one act -- naming the legs "fast"/"slow" -- fixes both the naming collision and
the fan-in check. The index-addressed ParamAlias overlay, Composite.params, the
Composite::new 5th argument, aliases_on, and check_alias_indices are removed
(Role.name and OutField.name untouched). Ledger C9 and C23 amended.

This is why the change is correct, not merely convenient: blueprints are
value-empty (C11), so the thing that would otherwise distinguish two SMAs --
their length -- is not present at construction; identity must come from a name,
not a deferred value. Closes the #56 friction surfaced by the param-space &
sweep milestone fieldtest (a freshly-authored 2-SMA cross was unbindable and
would not bootstrap without two hand-counted ParamAlias overlays).

Two plan defects were corrected during implementation and verified:
- the three new fan-in/path tests authored sma_cross at root level, which would
  qualify to "fast.length" (root prefix is empty) and is not the nested case the
  fan-in check inspects; nesting sma_cross under a root (a shared
  sma_cross_under_root helper) restores the asserted "sma_cross.fast.length" and
  IndistinguishableFanIn{node:2};
- three cycle-0030 named-binder tests bound the real harness by the old names
  (sma_cross.fast / scale) and were migrated to the new path strings, surfaced by
  the workspace test gate.

Verified by the orchestrator: cargo build/test --workspace green (198 tests,
0 red), clippy --all-targets -D warnings clean. model_to_json emits the type
label + factory param names (node-name-independent), so sample-model.json and the
graph_model golden are byte-identical (untouched). NodeSchema gained a Default
derive (the builder default-name test constructs an empty schema). fieldtests/
are frozen non-workspace records, not migrated.

closes #56
2026-06-11 11:51:30 +02:00
Brummel acaa6b0df8 audit: cycle 0030 (#35) — drift-clean; named param binding
cycle 0030 tidy (clean). The architect drift review against docs/design/INDEX.md
and CLAUDE.md found the cycle drift-clean: every load-bearing contract the named
binding layer touches holds.

- C1 / C12 / C19 / C23: a pure authoring layer — sweep.rs, the lowering path,
  collect_params, and param_space() are unchanged; with/axis/Binder/SweepBinder/
  resolve/resolve_axes all terminate into the existing bootstrap_with_params /
  GridSpace::new / sweep. No new state, no concurrency, no path into the run loop.
  Equivalence pinned by named_binder_runs_bit_identical_to_positional and
  named_axes_grid_parity_with_positional. The match key is the exact param_space()
  name (p.name == name), read-only.
- C7: pure-equality kind check (v.kind() != p.kind), no coercion.
- C16 / C10: std-only (the e97906a pin added a test, not a dependency); builder
  idiom (.with()/.axis()), no DSL/macro.
- The full BindError enum (incl. EmptyAxis) is constructed (EmptyAxis in
  resolve_axes) — no dead variant. No .bind() ships — the #55 structural-constant
  reservation is intact. The resolve/resolve_axes two-phase duplication is the
  documented accepted debt (spec §Components), not silent drift.

Resolution: one Nit fixed inline — a redundant `use crate::GridSpace;` in the
blueprint.rs test module (GridSpace is already in scope via `use super::*`).
Removed; the parity test still compiles, full `cargo test --workspace` green and
`cargo clippy --workspace --all-targets -- -D warnings` clean.

Regression gate: none configured (profile regression: []); architect is the gate.

This closes the named-binding feature (single run + sweep). Note: this is a
cycle drift-clean close, not a milestone close — the milestone fieldtest for
"The World — parameter-space & sweep" remains a deliberate manual act.

closes #35
2026-06-11 00:33:08 +02:00
Brummel 937257e368 feat(aura-engine,aura-cli): named param binding — sweep axes .axis()/.sweep() (0030 iter 2)
Bind a sweep's axes by name instead of by a positional GridSpace:

    bp.axis("sma_cross.fast", [2, 3]).axis("sma_cross.slow", [4, 5]).axis("scale", [0.5]).sweep(run)

The sweep half of spec 0030, completing the named-binding feature (#35). A pure
authoring layer over the existing GridSpace::new / sweep primitives; engine core
untouched (C1/C12/C19/C23).

This iteration:
- resolve_axes(): shares resolve's name->slot mapping (iter 1), adds the EmptyAxis
  check (the variant defined in iter 1 is now first constructed here) and a
  per-element axis kind-check (every element of each axis, first offending element
  in axis order). Its validation is a strict superset of GridSpace::new's
  (arity / non-empty / per-element kind), so SweepBinder::sweep's downstream
  GridSpace::new(...).expect(...) is infallible by construction — it can never
  panic on author input.
- Composite::axis -> SweepBinder -> SweepBinder::sweep (Result<SweepFamily, BindError>).
- The CLI sample sweep converted to the named form (behaviour-preserving).

Plan correction (verified by the orchestrator): the iteration plan said only the
GridSpace import was orphaned by the CLI conversion, but replacing the free
sweep(&grid, ..) call with the .sweep(..) method also orphans the free `sweep`
import — both were dropped from aura-cli to satisfy clippy -D warnings. The Scalar
import (from aura_core) stays used and was untouched.

Verified (run by the orchestrator): 6 new aura-engine tests green —
resolve_axes round-trip, EmptyAxis, MissingKnob, the per-element KindMismatch on a
mixed axis (the superset/no-panic guarantee), a builder no-panic wrong-kind test,
and a GridSpace .points() parity test; the two sweep JSON goldens
(sweep_report_renders_four_points_in_odometer_order + the cli_run integration
golden) green unchanged (name/value/order-preserving conversion); full
`cargo test --workspace` green and `cargo clippy --workspace --all-targets
-- -D warnings` clean.

refs #35
2026-06-11 00:30:14 +02:00
Brummel 64b19ab3d5 feat(aura-engine,aura-cli): named param binding — single-run .with()/.bootstrap() (0030 iter 1)
Bind a blueprint's open knobs by name for a single run instead of by a positional,
Scalar-wrapped Vec in param_space() order:

    bp.with("sma_cross.fast", 2).with("sma_cross.slow", 4).with("scale", 0.5).bootstrap()

A pure authoring layer over the existing Composite::param_space / bootstrap_with_params
primitives; the engine run loop and the construction core are untouched (C1/C12/C19/C23
preserved). Raw literals lower via Into<Scalar> (the literal fixes the variant: 2->I64,
0.5->F64, pinned by e97906a); the match key is the exact param_space() name — path-qualified
for a composite-interior knob (sma_cross.fast), bare for a root-level knob (scale).

This iteration (single-run side):
- BindError vocabulary (name-qualified); the full enum incl. the sweep-only EmptyAxis is
  defined and re-exported now, so the unconstructed variant is reachable public API, not
  dead_code — EmptyAxis is constructed in iteration 2 (the sweep side).
- resolve(): the shared name-resolution core, a total error order — Phase 1 per binding in
  input order (a UnknownKnob / b AmbiguousKnob / d DuplicateBinding), Phase 2 slot walk in
  param_space() order (e MissingKnob / f KindMismatch); first failing check wins.
- Composite::with -> Binder -> Binder::bootstrap; .with() not .bind() (bind is reserved for
  #55's structural-constant overlay).
- The CLI sample single-run test converted to the named form (behaviour-preserving).

Verified (run by the orchestrator, not the agent's claim): 9 new aura-engine tests green —
resolve round-trip + one per single-run BindError variant + two precedence tests + the C1
named-equals-positional equivalence over the sample composite harness; the converted CLI
sample test green; full `cargo test --workspace` green and `cargo clippy --workspace
--all-targets -- -D warnings` clean.

Iteration 2 (the sweep side: axis / SweepBinder / resolve_axes / EmptyAxis + the sweep CLI
conversion) is next. refs #35
2026-06-11 00:15:13 +02:00
Brummel fe11cfea8a feat(engine,registry): SweepPoint carries RunReport; add aura-registry (cycle D / iter 2)
Iteration 2 of the run-registry cycle (#33): make the sweep family
self-describing and add the registry store.

Engine. SweepPoint.metrics (RunMetrics) becomes SweepPoint.report (RunReport),
and the sweep closure bound is now Fn(&[Scalar]) -> RunReport. This closes the
manifest-per-point gap cycle C explicitly deferred to #33 — each swept point now
carries its full (manifest, metrics), the unit the registry indexes (C18). All
engine-internal callers, the engine test fixture run_point, and the three sweep
tests were rethreaded in the same iteration so the crate stays green; the CLI
caller followed in the same change so `cargo test --workspace` is green again.

CLI. The sweep_report closure builds a per-point RunReport (manifest params =
param_space names zipped onto the point via scalar_as_param_f64; commit/window/
seed/broker as run_sample builds them) and prints via RunReport::to_json. The
hand-rolled sweep_point_to_json/json_string/scalar_token are retired (the report
renders itself), and the orphaned ParamSpec/SweepPoint imports dropped. Net:
aura run, aura sweep, and (next iteration) aura runs all print the same
RunReport shape. The two aura sweep goldens now pin the per-point manifest
params, NOT the commit (it is the real git HEAD, volatile).

Registry. New crate aura-registry: Registry::{open, append, load} over an
append-only runs/runs.jsonl (one serde_json line per RunReport), free rank_by
(best-first per metric — total_pips desc, max_drawdown/exposure_sign_flips asc),
and RegistryError (Io / Parse{line} / UnknownMetric). Five unit tests cover
append->load round-trip, missing-file = empty, corrupt-line = Parse{line},
per-metric ranking, and unknown-metric error. Built and unit-tested; NOT yet
CLI-wired (aura sweep persistence + aura runs list/rank are iteration 3).

Verified: cargo test --workspace green (aura-registry 5, aura-engine 93, aura-cli
7+8, others unchanged); clippy --workspace --all-targets -D warnings clean; aura
run stdout shape unchanged; aura sweep now emits full RunReports.

refs #33
2026-06-10 20:28:31 +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 a665a966a7 feat(aura-engine): param-sweep grid primitive (the World, cycle C / iter 1)
Turn one value-empty blueprint into a family of disjoint instances over a
declared grid, run them in parallel, and collect per-point metrics. This is
C12.1's grid axis — the first "N instead of 1": #30/#31 made a blueprint
param-generic and bindable; this reaps it.

New module crates/aura-engine/src/sweep.rs, three parts:
- GridSpace: a validated cartesian grid over a blueprint's param_space() —
  one discrete value-list per slot, kind/arity/non-empty checked at
  construction (SweepError::{Arity, KindMismatch, EmptyAxis}, the typed gate
  before any run). points() enumerates in odometer order (last axis fastest),
  deterministic.
- sweep(): closure-driven. The engine owns enumeration + disjoint execution
  + collection; the author owns run_one: Fn(&[Scalar]) -> RunMetrics (build
  fresh -> bootstrap_with_params -> run -> drain -> summarize). Metrics
  reduction is harness-specific sink glue the engine cannot generically own
  (C8/C18), and a fresh per-point build resolves #31's two structural facts:
  bootstrap_with_params consumes the blueprint, and a Recorder bakes its
  channel in — so each point gets its own drainable instance.
- SweepFamily / SweepPoint: ordered, self-describing (params carry the point
  coordinate), in enumeration order independent of thread completion.

Execution is std::thread::scope over available_parallelism() workers pulling
point indices from a shared atomic cursor (work-stealing balances uneven
per-point cost); results are tagged by index and sorted after join, so the
family is bit-identical across thread counts and repeated runs (C1: disjoint,
lock-free; order = enumeration, not completion). No external dependency —
std only, never rayon (C16: the engine workspace stays zero-dep for deploy
purity). A private sweep_with_threads(nthreads) lets the tests pin
determinism at 1 vs N workers while the public sweep() derives the count.

Out of scope this iteration: the `aura sweep` CLI demonstrator (cycle 0028
iter 2); random enumeration, per-point manifest assembly (#33), value-domain
validation (C20) are deferred per the spec.

One deviation from the plan's verbatim code: GridSpace derives Debug
(load-bearing — the three .unwrap_err() fault tests need the Ok type to be
Debug to compile).

Verified: 8 new sweep:: tests green (enumeration/odometer order, the three
typed faults, sweep == N independent runs, determinism across thread counts,
distinct-points-distinct-metrics); cargo test --workspace green (92 engine
lib tests, no regression); cargo clippy --workspace --all-targets
-- -D warnings clean.

refs #32
2026-06-10 18:36:35 +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 288f58953e feat(aura-engine): graph model serializer (0026 it1)
Iteration 1 of the graph render redesign: a read-only model_to_json(&Composite)
that serializes the harness root + every distinct composite type into the
canonical, deterministic JSON model the browser viewer will consume. New
crates/aura-engine/src/graph_model.rs; lib.rs re-exports model_to_json.

- House style: hand-rolled JSON like RunReport::to_json, NO serde (C14).
- Model shape (faithful to the types, resolving the spec's prototype-flavoured
  example): node keys are indices (C23, the blueprint has no unique names);
  input ports carry kind+firing only, no name (PortSpec has none — acceptance
  criterion 3); a vec![] output is the C8 sink; bound root source-roles are
  minted as synthetic src_<role> source nodes; composite boundaries use @role
  (input) and #N (output) endpoints; distinct composites collected once.
- Improvement over the plan: a composite's input kind is read from the actually
  fed interior slot (matching blueprint::derive_signature) rather than a hardcoded
  f64 — invents nothing.
- Read-only (C9): &Composite -> String, no eval/compile/bootstrap on the path.
- 10 new tests green: byte golden on the sample harness, determinism, structural
  mis-wire differs (param swaps are invisible to the pre-compile model — the
  property is carried by a structural re-target), read-only witness, sink /
  two-input / distinct-once structural assertions. Workspace green, clippy clean.

aura graph still renders via ascii-dag (untouched); iteration 2 ports the viewer
JS, vendors the WASM-Graphviz asset, emits the self-contained HTML, and retires
ascii-dag.
2026-06-10 11:18:28 +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 69d20949c0 tidy(aura-engine): single-own alias-validity + shared alias-count anchor
Collapse the two correctness-neutral debt items the 0021 fan-in pre-pass
left behind (architect drift review at cycle close).

Alias-validity: the `BadInteriorIndex` check was raised in two places —
the structural pre-pass `check_composite_fan_in` and `inline_composite`.
Since `compile_with_params` runs the pre-pass over every composite before
any lowering, `inline_composite`'s copy was dead for that error path. The
check now lives in one helper `check_alias_indices`, called solely by the
pre-pass; `inline_composite` drops its copy (and the now-unused
`param_aliases` binding — the overlay is a pure naming layer, unused in
lowering, which is driven by the injected scalar vector).

Alias-count: the per-node `a.node == node` predicate was re-derived in
three spots (signature base, the unaliased-param test, the CLI render
base-length). All three now route through one exported anchor
`aliases_on`, so an alias-semantics change touches one site.

Behaviour-preserving: the full workspace suite is the guard (15+6 cli,
25 core, 69 engine, 6+1+1 ingest/misc, 29 std — 0 failed),
`out_of_range_param_alias_rejected` still green (now raised by the
pre-pass), clippy -D warnings clean. No ledger change (the C9 refinement
landed in 0021).

closes #45
2026-06-08 16:38:09 +02:00
Brummel 3f4d756ed2 feat(aura): fan-in input distinguishability — construction constraint + source-derived render
A fan-in node (>1 input) whose colliding sources hide an unnamed configuration
axis is now illegal at construction, and the definition view renders each fan-in
input as a source-derived recursive-signature identifier instead of the
positional, meaningless #A/#B.

The root defect was sma_cross: two Sma into a Sub with unaliased length params,
rendering sma_cross() -> (cross) with two indistinguishable [SMA] and
[Sub(#A,#B)] — the author meant fast vs slow but the identity hid it. Now it
renders sma_cross(fast:i64, slow:i64) with [SMA(fast)]/[SMA(slow)] and
[Sub(#Sf,#Ss)].

Shape (single source of truth for "signature" shared by engine + CLI):
- aura-engine signature_of(): a node's recursive authoring identity — type
  initial + alias initials + each wired input's signature (recursing into
  interior leaves, stopping at named roles/composites at their initial).
- aura-cli leaf_label/fan_in_identifiers: shortest sibling-unique prefix of a
  source's signature, never below its base (type + alias initials); a role-fed
  slot renders its name verbatim (#price); equal-signature interchangeable
  inputs keep the positional letter.
- aura-engine IndistinguishableFanIn: a signature collision is a fault only when
  a colliding source carries an unaliased param (the unnamed axis); param-less
  interchangeable sources (fan_composite's Pass/Pass) stay legal.

Param-aware criterion (refined during design): keeps the blast radius to the
param-bearing alias-less fixtures (sma_cross CLI + engine, the nested fast_slow);
fan_composite and the hand-wired flat fixtures stay green.

Placement decision (deviates from the plan): the constraint runs as a structural
pre-pass (check_fan_in_distinguishability) at the head of compile_with_params,
BEFORE the param-arity gate — not inside inline_composite as the plan drafted.
Reason: the no-param compile() of a param-bearing composite would hit ParamArity
first and preempt the fault; the spec mandates a structural check needing no
param values, so the pre-pass is the spec-aligned home. Alias-validity ordering
(BadInteriorIndex before the fan-in fault) is preserved at the pre-pass head.

C23 untouched: the check is construction-phase; the compilat stays name-free
(compiled_view_golden byte-stable). Ledger C9 carries the refinement.

Known debt (non-gating): the alias-index validity check now exists in both
inline_composite and the pre-pass (the pre-pass reaches every composite first,
making inline_composite's copy effectively dead). Left for a separate tidy — a
5-line, correctness-neutral removal with its own test surface.

closes #44
2026-06-08 15:50:19 +02:00
Brummel 41cbb5506f feat(aura-engine): name the composite boundary — input roles + param aliases
Brings the other two composite-boundary edge-kinds to the named-projection
shape #40 gave outputs. input_roles changes from a bare Vec<Vec<Target>>
to Vec<Role { name, targets }> (rendered [in:<name>]); a composite gains
params: Vec<ParamAlias { name, node, slot }> that relabels an interior
leaf param slot's surface name in param_space() (rendered [param:<name>]).

Param aliasing is a PURE NAMING OVERLAY, not curation (the load-bearing
decision, per spec 0019): every interior param slot stays in param_space()
and sweepable; the alias only relabels in place, never reorders or hides.
Proven empirically — the MACD run is byte-identical (total_pips
0.1637945563898923, 3 sign flips), only the param labels improved:
param_space() now surfaces [macd.fast, macd.slow, macd.signal] instead of
three indistinguishable macd.length, and the manifest reads ema_fast/
ema_slow/ema_signal.

C23 honoured: role/param/output names are non-load-bearing debug symbols
dropped at lowering; identity is positional (role index, param slot,
output field). compiled_view_golden is byte-identical (verified: the
golden region is untouched in the diff). An out-of-range alias (missing/
non-leaf node or slot past the leaf's param count) is rejected at
compile_with_params as BadInteriorIndex, mirroring the output range-check
(no new variant). Orthogonal to #36 — purely additive at the composite
level.

Aliasing is demonstrated on the CLI MACD site only (the spec's worked
example + a new E2E test macd_param_space_surfaces_the_three_named_aliases);
sma_cross, the engine test fixtures, and the construction-layer fieldtests
get the forced role-name + empty params, so the param_space C23 anchor
goldens (param_space_mirrors_compiled_flat_node_param_order + siblings)
stay byte-identical.

Verification (orchestrator-run, not trusted from the agent report):
cargo build/test/clippy --workspace -D warnings all green (engine 66,
cli 12); the separate-workspace construction-layer fieldtest crate builds
(guards the #42 latent-drift recurrence); compiled_view_golden + MACD
determinism unchanged.

Two faithful repairs to the plan's literal test/code bodies, no semantic
change: the out-of-range test uses .err()/Some(BadInteriorIndex) (the Ok
arm Vec<Box<dyn Node>> is not Debug, so .unwrap_err() would not compile),
and the inline_composite destructure binds params as `param_aliases` to
avoid shadowing the injected `params: &[Scalar]` arg.

closes #41
2026-06-08 02:21:30 +02:00
Brummel 8bc429f367 audit: cycle 0018 (#40) — drift-clean; C9 multi-output realization note
Architect drift review over a4cfe5c..HEAD (covers the previously-unaudited
EMA node and MACD PoC plus the #40 composite multi-output cycle): status
clean. C8/C7/C4 preserved (one port, one row per eval, K co-fresh base
columns — structurally identical to OHLCV); C23 honoured (ItemLowering::
Composite.output is Vec<(usize,usize)>, no name in the compilat; the
compiled-view render stayed bit-identical). EMA and MACD PoC carry no
ledger drift.

Resolution of the three minors:
- Ledger lagged the new behaviour: added a "Realization (cycle 0018)" note
  under C9 recording that a composite's output is now a named K-field record
  (Vec<OutField>), the same arity C8 already grants a leaf, names dropped at
  lowering (C23). Amended here (ledger amendment is the audit's job).
- blueprint.rs module-doc / Composite struct-doc / new-doc still said "one
  output port" singular — updated to "output record".
- Non-workspace fieldtest From<Sma> debt is pre-existing (cycle-0016) and
  already tracked as #42; not re-filed.

No regression scripts configured (profile regression: []), so the architect
review is the sole gate. Carry-on: the next natural iteration is the #41
legibility sibling (input-role + param naming at the composite boundary).
2026-06-08 01:05:41 +02:00
Brummel 784e6c917a feat(aura-engine): composite output is a named multi-field record
Replace a composite's single output port (OutPort { node, field }) with
an ordered, named record (Vec<OutField { node, field, name }>). A
composite can now re-export K interior fields as one output; a consumer
selects one via the existing Edge::from_field — the same field-wise
selector that already works for multi-output leaf producers (OHLCV).

Why: multi-line indicators (MACD, Bollinger, Stochastic, Ichimoku) could
not be authored as a composition and re-exported as a unit — only one
line escaped the boundary. The MACD PoC was forced to expose only the
histogram. This lifts the three `field == 0` / `from_field == 0` caps at
the composite boundary (inline_composite nested arm, rewrite_edge
composite arm), range-checking the index instead.

Boundary completion, not an invariant change:
- C8/C7/C4 untouched — one port, one row per eval, K base columns (a
  3-line MACD is identical in kind to OHLCV).
- C23 honoured — re-export names live at the blueprint boundary only and
  are dropped in the compilat (raw index wiring). compiled_view_golden
  stayed byte-identical (the regression guard): no name leaked into the
  flat graph.

The MACD PoC now re-exports macd / signal / histogram as a record; the
strategy still trades the histogram, selected via from_field: 2. The
render shows K [out:<name>] markers per multi-output composite (input
roles stay [in:<index>] — naming them is the dependent #41).

Output naming ships with the capability (OutField is born name-ready) so
#41 — composite param aliasing + input-role naming — does not reopen the
type.

Verification: cargo build/test/clippy --workspace -- -D warnings all
green; aura-engine 62 tests (+3 capability, +1 through-run E2E), aura-cli
unchanged-count green; compiled_view_golden byte-identical.

Known debt (out of scope, pre-existing): the non-workspace construction-
layer fieldtests (fieldtests/milestone-construction-layer/mc_1..mc_4)
carry their OutField sweep but still do not compile standalone — they use
the Sma::new / BlueprintNode::from(Sma) API retired in the cycle-0016
value-empty migration, never propagated to these fixtures. Tracked as a
follow-up.

closes #40
2026-06-08 01:00:04 +02:00
Brummel 4b64409036 feat(aura-core,aura-std,aura-engine,aura-cli): inject a param-set vector at bootstrap (#31)
Cycle B of milestone "The World — parameter-space & sweep". A blueprint is now
value-empty: a leaf holds a param-generic recipe, not a built node, and a positional
Scalar vector is bound slot-by-slot at bootstrap — so one blueprint bootstraps into
many distinct instances under different vectors, with no cdylib rebuild (C12/C19).
This is the binding primitive a sweep (#32) drives.

Ratified design (brainstorm): value-empty reconstruct-through-new() over
mutate-in-place and over a default-bearing variant. The value lives only in the
injected vector (no baked default), keeping the blueprint a pure param-generic
recipe (C19); every injected value flows through the node's own constructor (the
single sizing/validation gate).

- aura-core: LeafFactory { name, params, build } — the recipe (params -> sized node
  through `new`); Scalar::as_i64/as_f64 value accessors. Node trait unchanged.
- aura-std: each of the 7 nodes exposes factory() (SMA length:I64, Exposure
  scale:F64, LinComb arity x weights[i]:F64; Sub/Add/SimBroker/Recorder paramless,
  capturing their non-param construction args — pip_size, the Recorder channel).
- aura-engine: BlueprintNode::Leaf(LeafFactory), From<LeafFactory>; param_space()
  reads factory.params() pre-build; compile_with_params/bootstrap_with_params build
  each leaf from its kind-checked slice while lowering (build-then-wire), arity
  checked up front via param_space().len(); CompileError::{ParamKindMismatch,
  ParamArity}. compile/inline/edge-rewrite are structurally unchanged, so the
  compilat stays bit-identical for a given point (the bit-identity and both
  param_space mirror tests stay green). The vestigial pre-build Composite::schema /
  BlueprintNode::schema (no live caller — interface resolution is structural on the
  built flat nodes) are removed.
- aura-cli: the blueprint view labels leaves by bare type via LeafFactory::label()
  (`[SMA]`) — the ascii-dag renderer cannot render wide cluster-sibling labels (see
  spec); the compiled view still labels valued (`SMA(2)`). The sample supplies its
  point as a vector; the mis-wiring swap moved to the compiled view.

The #34 dual-traversal drift hazard is subsumed: compile_with_params consumes the
vector in the same recipe walk param_space() reports, so the two share one
traversal.

Verified: cargo build/test --workspace green (127 tests, incl. bit-identity, both
mirror tests, and 4 new injection tests — different-vector-different-run, kind
mismatch, arity, determinism); clippy --workspace --all-targets -D warnings clean;
`aura graph` blueprint view renders cleanly.

Scope: one vector -> one instance. Deferred: sweep enumeration (#32), domain
validation (#32/C20), single-run authoring convenience (#35).

closes #31
2026-06-07 21:04:52 +02:00
Brummel a31d91453c test(aura-engine): mirror param_space against compile() under nesting (#34)
The cycle-0015 E2E guard param_space_mirrors_compiled_flat_node_param_order
pins the load-bearing C23/#31 invariant — Blueprint::param_space() slot order
mirrors compile()'s flat-node param order kind-by-slot — but only on the
single-level composite_sma_cross_harness. collect_params (blueprint.rs:217)
duplicates lower_items' depth-first traversal rather than sharing it (kept a
parallel read-only projection so compile/inline_composite stay untouched and
the compilat bit-identical, C9/C23), so the two orders must stay in lockstep
forever. No mirror test ever compiled a composite-inside-a-composite: a future
inliner reorder that desynced the projections only under nesting could pass the
single-level mirror and the isolated nested param_space order test, yet break
the slot-by-slot premise #31's binding rests on.

Adds param_space_mirrors_compiled_flat_node_param_order_under_nesting: compiles
a strategy -> { fast_slow -> [Sma, Sma, Sub], LinComb } nest and asserts
param_space() equals the compiled flat-node param order kind-by-slot, pinning
the concrete [I64, I64, F64, F64] shape. Green immediately — coverage-hardening,
no desync in the current tree. Test-only; collect_params/lower_items/compile
untouched.

Verified: cargo test -p aura-engine green (55 tests), clippy --all-targets -D
warnings clean.

closes #34
2026-06-07 16:16:18 +02:00
Brummel 931109df58 feat(aura-core,aura-std,aura-engine): declare node tunable params (#30)
Cycle A of milestone 'The World — parameter-space & sweep'. A node now declares
its tunable parameters in its C8 schema, and a blueprint aggregates them into one
flat, inspectable param-space — the root that unlocks the C12 orchestration axes
(#31 bind, #32 sweep), filling the gap C8/C23 name 'deliberately not in the schema
yet'.

- aura-core: ParamSpec { name: String, kind: ScalarKind } as a third schema-
  declaration type; NodeSchema gains a third field 'params'. name is String (not
  &'static like FieldSpec) because a vector knob carries a runtime index and
  aggregation prefixes the composite path.
- aura-std: Sma declares [length:I64], Exposure [scale:F64], LinComb expands to N
  flat [weights[i]:F64] (N = its input arity, topology-fixed per C19); Sub/Add/
  SimBroker/Recorder declare none (pip_size is metadata, C10/C15; Recorder is
  wiring).
- aura-engine: Blueprint::param_space() walks the graph-as-data depth-first in
  lower_items order, path-qualifying via the already-public Composite::name() — a
  read-only projection (C9). compile/inline_composite/lower_items are untouched, so
  the compilat stays bit-identical (composite_sma_cross_runs_bit_identical_to_hand_wired
  and the golden render tests stay green).

Design: param identity is positional (slot after the deterministic inline order,
C23 'by index not name'); the path-qualified name is a non-load-bearing debug
symbol — same-type siblings in one composite share a name, uniqueness is at the
slot. Flat over a structured arity-bearing ParamSpec because flattening is
unavoidable (a sweep enumerates a flat point-space) and structure-in-the-runtime
is the nested-composite reading C23 rejects (see spec 0015 for the full rationale).

Scope is declaration + aggregation + inspection only; binding (#31), sweep
enumeration (#32), search-range, validity-constraint, and default-range/slider are
deferred. An E2E test pins the load-bearing C23/#31 invariant: param_space() slot
order mirrors compile()'s flat-node param order, kind-by-slot, on the SMA-cross
harness. fieldtests/ (excluded crates) left as frozen snapshots.

Verified: cargo build/test --workspace green (64 tests), clippy --all-targets -D
warnings clean; compile/inline diff empty.

closes #30
2026-06-07 15:29:58 +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 0a855c3943 feat(aura-cli): aura graph renders a wired graph as an ASCII DAG (#13)
Make a wired graph introspectable so a mis-wiring becomes visible. Two
selectable views, both authored from one built-in sample blueprint:

  aura graph             clustered blueprint view — composites drawn as named
                         ascii-dag cluster boxes (pre-inline, C9)
  aura graph --compiled  flat compilat view — composite boundaries dissolved
                         (C23); the graph the run loop actually runs

The payoff is intrinsic, param-carrying node labels: two SMAs read SMA(2) /
SMA(4), so a swapped fast/slow input reads back differently. This is realized
by a `label()` default method on the core Node trait — a non-load-bearing
render symbol the run loop never reads (wiring is by index), the symbol C23
already reserves citing #13. Recorded as a C8 refinement in the ledger.
Rejected the alternatives in brainstorm: a separate Describe trait (forces a
combined trait-object dance for a label the ledger calls non-load-bearing) and
extrinsic parallel labels (decoupled from the node, so an author can label the
slow SMA "fast" and mask the very bug the render exists to surface).

Mechanism:
- aura-core: Node::label() default method (additive, object-safe, single-line).
- aura-std: per-node overrides — SMA(n)/Exposure(s)/SimBroker(p) carry params;
  Sub/Add/LinComb/Recorder are bare (their identity is not a mis-wiring axis).
- aura-engine: Composite gains an authored `name` (cluster title; dissolves at
  inline) + read-only graph-as-data accessors on Blueprint/Composite. No
  external dependency — the engine stays dependency-pure (C16); ascii-dag lives
  only in aura-cli. The label-free index-wired compilat (C23) is unchanged.
- aura-cli: ascii-dag v0.9.1 + a graph.rs adapter (Vertical mode; labels
  materialized into an owned Vec<String> that outlives the borrow-based Graph).
  `aura run` is untouched (the sample duplication is dedup idea #14).

Tests: a concern-defining test (swapped sample renders != correct), structure
pins (cluster present in blueprint view, absent in compiled view), per-node
label disambiguation, and two frozen byte-goldens of the deterministic render.
The cycle-0012 bit-identical and run-sample non-regression tests stay green.

Verified by the orchestrator (not the agent report): cargo build --workspace
clean; cargo test --workspace all green; cargo clippy --workspace --all-targets
-D warnings clean; both views run live and render as expected.

closes #13
2026-06-05 20:56:52 +02:00
Brummel a4fb5d7182 feat(aura-engine): blueprint construction layer with composite inlining
Add the construction layer (C9/C19/C23): a named, param-generic graph-as-data
(`Blueprint`) that *compiles* to the flat, type-erased instance the run loop
already runs. The unit of reuse is the `Composite` — a nestable sub-graph
fragment exposing one output port (C8) and named input roles — which
`Blueprint::compile()` **inlines** by raw-index lowering into the flat
`(nodes, sources, edges)` the unchanged `Harness::bootstrap` consumes.

Design (settled in spec 0012, ledger C9/C19/C23): a composite is an
authoring-level node, NOT a runtime `Box<dyn Node>` sub-engine. The chosen
inlining approach keeps the sacrosanct deterministic run loop untouched and
leaves the composite interior fully inspectable for the deferred cross-graph
optimiser; the rejected "runtime sub-engine" reading would have put throwaway
complexity into the run loop and kept the interior opaque. The compilat is
wired by raw index, not by name; names survive only as non-load-bearing debug
symbols (as `FieldSpec.name` already is).

Lowering is recursive index rewriting: interior items append at an offset,
interior edges rewrite by that offset, an edge into a composite fans through
its input roles (one blueprint edge -> several flat edges), an edge out
resolves to the interior output port, and nesting recurses inside-out.
Construction-phase faults are caught as a typed `CompileError`
(BadInteriorIndex / RoleKindMismatch / OutputPortOutOfRange); the lowered flat
compilat is validated by bootstrap's existing kind- and Kahn-cycle-check
(wrapped as `CompileError::Bootstrap`), with no re-implementation.

Non-goals (deferred per C23/C16): no optimisation pass (CSE/DCE,
sweep-invariant hoisting), no external optimisation crate, no named-handle
ergonomic wiring, no `aura graph` render (#13).

Verification: 48 engine tests green (8 blueprint: derived-schema, single +
nested composite inlining, the three CompileError paths, bootstrap-error wrap,
and the headline `composite_sma_cross_runs_bit_identical_to_hand_wired` C1
demonstrator — the SMA-cross composite lowers to a flat graph byte-identical to
the hand-wired sample harness, producing bit-for-bit identical equity +
exposure traces); `cargo clippy --workspace --all-targets -D warnings` clean.
`Node`, `Harness::bootstrap`, the run loop, and `Edge`/`Target`/`SourceSpec`
are unchanged.

closes #12
2026-06-05 14:03:27 +02:00
Brummel 63ea7eb3b1 audit: cycle 0011 tidy — record the external-dependency firewall invariant
Architect drift review (f68258b..HEAD) found no code drift — the firewall is
real at the dependency-graph level (aura-core/std/engine/cli carry no external
dep; the chrono/regex/zip tree is confined to aura-ingest), and C3/C7/C1 are
faithfully realized. Four ledger-level items, all resolved here:

- [ledger-drift] The zero-external-dependency commitment — the load-bearing
  rationale for making aura-ingest a crate rather than a feature-gate — lived
  only in spec 0011. Recorded in the ledger: C16 now states the engine workspace
  is zero-external-dependency by commitment, names aura-ingest as the ingestion
  edge / external-dependency firewall, and forbids an external dep in any engine
  crate other than aura-ingest.
- [ledger-drift] C16's reuse taxonomy did not place aura-ingest. C16 now lists
  the non-node engine crates (aura-engine, aura-cli, aura-ingest).
- [ledger-debt] C12's eager-materialization-now / Arc<[T]>-sharing-later choice
  was only in the spec. C12 now carries a "Status (cycle 0011)" note recording
  the deliberate gap.
- [debt] aura-engine/Cargo.toml carried a stale comment ("data-server enters
  when the ingestion task starts") that contradicted the firewall it should
  protect. Replaced with the dependency-pure / firewall-in-aura-ingest note.

The External components data-server bullet is updated to reality (pulled in by
aura-ingest; workspace now resolves from a populated cargo cache, not fully
offline).

Regression gate: no-op (commands.regression empty); architect is the sole gate.
Cycle 0011 is drift-clean. (Drift-clean, not a milestone close — the
Walking-skeleton milestone close additionally needs its end-to-end milestone
fieldtest, a separate deliberate act.)
2026-06-04 21:56:29 +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
Brummel 3b49074156 feat(aura-std): signal-quality loop — Exposure node + sim-optimal broker
Realizes the C10 reframe (cycle 0007): the strategy DAG's output is an
intent/exposure stream, and a sim-optimal broker integrates it into a synthetic
pip-equity curve that measures SIGNAL QUALITY — the project's first trading
result, and its primary research loop.

Two new aura-std nodes (plain structs; the engine stays domain-free):

  - Exposure { scale }: the decision/sizing node — clamp(signal/scale, -1, +1),
    one f64 exposure per fired cycle, None until warmed up. Sizing/risk live in
    `scale`.
  - SimBroker { pip_size }: class (a) of C10 — consumes exposure (slot 0) + price
    (slot 1), integrates the return earned by the exposure HELD INTO each cycle
    (prev_exposure, decided at t-1) so it is causal / look-ahead-free (C2), and
    emits cumulative pips. pip_size is held reference metadata (C7/C15), never
    streamed.

End-to-end proof in the aura-engine harness test module (it dev-depends on
aura-std): a SMA(2)-SMA(4) cross -> Exposure -> SimBroker -> Recorder sink wires
a full signal-quality backtest; the recorded equity matches a hand-computed pip
curve ([0,0,0,0, 0.035]) and is bit-identical across two runs (C1). The engine
itself is unchanged — pure node authoring on the frozen substrate.

Verified: cargo test --workspace green (20 core + 30 engine + 9 std; 8 new);
clippy --all-targets -D warnings clean; engine/core surface-purity grep (no
dyn Any/Rc/RefCell) clean.

Note: the plan inlined `Window::first()`, which aura-core's Window does not
expose; the shipped code uses the semantically identical `Window::get(0)`
(newest value, or 0.0 when the exposure leg is cold).

closes #4
closes #5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:15:32 +02:00
Brummel e5d80959e0 test: M×N×K stress matrix — node fan-out, deep chains, wide layers
The milestone's green end-to-end gate (#3): prove the closed
bootstrap + run substrate carries arbitrary node-level
fan-out / fan-in / depth / width DAGs deterministically (C1) and computes
every recorded stream correctly. Closes the two coverage holes the engine
tests left open after cycle 0006 — node fan-out (one PRODUCING node read
by several consumers; prior coverage was source fan-out only) and
deep-chain / wide-layer topologies.

Six tests appended to harness.rs's test module, composing the existing
Sma / Sub / Recorder / BarrierSum fixtures (no new fixtures, no aura-std
surface — C9):
- node_fan_out_identical_taps_record_identical_streams
- node_fan_out_divergent_consumers_each_compute_their_own
- node_fan_out_under_mixed_firing_each_consumer_records_per_policy
- deep_transform_chain_propagates_end_to_end
- wide_parallel_layer_multi_sink_records_each_stream
- milestone_end_to_end_mixed_dag_records_every_stream_deterministically

Each asserts exact (Timestamp, Vec<Scalar>) recorded values (hand-computed
from the SMA/Sub + firing/warm-up semantics) AND determinism via a second
fresh-harness drain proving bit-identical streams. 28 engine tests green
(22 + 6), clippy -D warnings clean. No engine misbehaviour surfaced.

closes #3
2026-06-04 14:59:13 +02:00
Brummel 1100a60c76 feat: recording is a node role, not a type (multi-sink substrate)
Replace the engine's single observe: usize recording affordance with
recording-by-node, so one run records many streams. A recording node
reads its typed input windows + ctx.now() in eval and pushes the record
to a destination it holds as a field (a channel, a chart handle) — an
out-of-graph side effect. There is no Sink type, trait, or engine flag:
a pure-consumer node returns None, and a node may record AND return a
forwarded output in the same eval (the C8 "both" case). In-graph routing
stays engine-owned data (the edge table); the escape out of the graph is
the node's own side effect, and that boundary is the determinism /
graph-as-data boundary.

Engine surface shrinks: Ctx gains now: Timestamp + now() (C2-causal, the
present cycle's timestamp); Harness loses the observe field, its
observe >= n bootstrap check, and the per-cycle observed-row collection;
bootstrap drops its 4th param; run returns (). Recorded streams are now
sparse and timestamped (a record per fired cycle) instead of the dense
Vec<Option<row>>.

The Ctx::new signature change touched 9 call sites across three crates
(not the 3 the spec estimated) — aura-std's node tests and the engine
run loop were threaded too. The engine test suite migrated to a
test-local Recorder fixture whose read-back is an mpsc channel, never
Rc/RefCell, keeping aura-engine/src purity-clean (C7). Eight new proof
tests cover the multi-sink headline, producer-and-sink, mixed-kind
recording, all-fields tap, both recorder firing modes, determinism, and
recorder-edge kind rejection. C8/C22 gain cycle-0006 realization notes.

Gates: workspace test 45 green (core 20, std 3, engine 22), clippy
-D warnings clean, purity grep clean (only a comment names Rc/RefCell).

closes #2
2026-06-04 14:38:26 +02:00
Brummel 5228542bfd feat: node output is a record (composite stream), not a single scalar
Cycle 0005 (BLOCKER #1): a node's output generalizes from one scalar to a record
of K >= 1 named base-scalar columns, with a scalar being the degenerate K = 1
case. A node keeps exactly one output port; its payload is now an ordered bundle
of base columns (a composite stream, C7). This unblocks every multi-column
producer the engine needs next -- OHLCV bars and, later, the C10 position-event
table -- none of which could exist while a node emitted at most one column.
Revises C8, sharpens C7 in the design ledger.

Contract (aura-core):
- `NodeSchema.output: ScalarKind` -> `Vec<FieldSpec>` (FieldSpec = { name:
  &'static str, kind }; the field position is what an Edge binds, the name is
  metadata for later sinks/playground, C18).
- `Node::eval -> Option<Scalar>` -> `-> Option<&[Scalar]>`: a node fills a buffer
  it owns (sized once at construction) and returns a borrowed K-field row; `None`
  still means filter/not-warmed. This is the C7-faithful representation chosen
  with the user: zero per-cycle heap allocation on the forward path (rejected:
  Option<Vec<Scalar>> allocates per fire; an inline fixed [Scalar; N] bakes a
  width cap; engine-owned output columns invert the eval model). Scalar output is
  the degenerate 1-field record -- no separate scalar path.

Field-wise binding (aura-engine):
- `Edge` gains `from_field: usize` -- which producer column this edge forwards.
  Consuming a whole record is N edges; there is no "bind whole record" mechanism.
- bootstrap kind-checks per field (`from.output.get(from_field)` -> BadIndex if
  out of range; `field.kind != slot.kind` -> KindMismatch).
- the run loop copies a producer's returned row into one reused scratch buffer
  (resolving the borrow; no per-cycle alloc once warm) and scatters
  scratch[from_field] into each out-edge slot. `NodeBox.out_len` (set at
  bootstrap) backs a debug_assert on the returned row width. `run` returns
  `Vec<Option<Vec<Scalar>>>` (the observed node's full row per cycle -- a
  materialization-surface alloc, not the inter-node hot path).
- the K fields of one record are co-fresh by construction (one eval, one
  timestamp), so C6 is untouched.

aura-std: Sma/Sub migrate to the 1-field degenerate case (hold a [Scalar; 1]
buffer; behaviour unchanged). Sub drops #[derive(Default)] (Scalar has no Default)
for a manual Default.

Proof (aura-engine tests, +5): an Ohlcv 5-field bundler fed by five
timestamp-aligned barrier sources emits one complete bar per timestamp
(ohlcv_bundles_five_field_record); a downstream Sub binds high(1)-low(2)
field-wise, observed end-to-end + a determinism re-run
(edge_binds_single_field_high_minus_low); a second consumer binds close(3)-open(0)
on the same record (distinct_edges_read_distinct_fields); must-fail:
bootstrap_rejects_from_field_out_of_range (BadIndex),
bootstrap_rejects_per_field_kind_mismatch (a TwoField [f64,i64] producer, the i64
field into an f64 slot). All prior 0003/0004 tests adapted to from_field + the Vec
return, expected vectors unchanged (scalar = 1-field record).

Gates re-run by the orchestrator (not just the agent): cargo build/test
--workspace --all-targets (36: aura-core 19 + aura-std 3 + aura-engine 14, 0
failed) / clippy --workspace --all-targets -D warnings clean; surface-purity grep
(no dyn-Any / Rc / RefCell) clean. Ledger C8/C7 edits verified
(Option<&[Scalar]> present, "one series per node" gone, cycle-0005 realization
note added). The glossary composite/node record-reality pass is the audit-time
follow-up.

closes #1
refs walking-skeleton

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:55:20 +02:00
Brummel 9ff90b5d80 audit: cycle 0004 tidy — diamond-barrier test + ledger/glossary record-reality
Architect drift review over 02fe891..f37b2a3 (firing-policies-and-merge):
drift_found, all medium/low, no contract violation. Invariants hold: C1 (the
merge is a deterministic linear scan, strict < on (timestamp, source-index) gives
C4 tie-break by declaration order; only per-call allocs), C3 (one merge at
ingestion), C5/C6 (both rails proven with worked vectors; sample-and-hold falls
out of the push model), C7 (type-erased edges, no dyn-Any/Rc/RefCell — purity grep
clean). Resolved the 0003 forward-note: cycle_id is now a real read field (the
freshness epoch, fresh_at == cycle_id), not write-only.

Resolved [med] (test gap): the barrier's timestamp token was proven only for the
multi-source same-timestamp case; C6's within-source diamond rejoin (one source,
two unequal-latency paths rejoining at a barrier) was claimed by the spec but
untested. Added `within_source_diamond_rejoin_barrier_fires`
(source -> {SMA(2), SMA(4)} -> BarrierSum): once SMA(4) warms at cycle 4, both
paths emit each cycle at the same timestamp and the barrier fires
(15+13, 17+15, 19+17). The code was already correct — every push in a cycle
stamps last_ts = that cycle's timestamp (source pushes and produced-edge forwards
alike) — so this closes the coverage gap, not a bug.

Resolved [med] (glossary record-reality): the run-count entry claimed it "drives
freshness-gated recompute", now false — the engine's firing gate reads the
per-wiring-slot cycle epoch (fresh_at), not Column::run_count. Sharpened the entry
to record reality: run-count is the node-visible per-column push counter;
fresh_at is the engine's per-slot freshness epoch. Not redundant (different
levels); both are maintained consistently on every push.

Resolved [low] (ledger silent on the barrier token): added a C6 "Realization
(cycle 0004)" note recording the load-bearing decision — barrier token is the
timestamp not cycle_id (forced by C4's same-timestamp-tie rule), firing tagged
per input with Firing::{Any, Barrier(u8)}, a mode-A input being its own trivial
group (so "per input group" and the per-input tag coincide).

Carry-on forward notes (next-cycle owners, not fixes now):
- [low] cycle_id is materialized internally (the freshness epoch) but not yet
  surfaced in run output (run returns Vec<Option<Scalar>>). Its first observable
  consumer is a sink / clock node (C18) or a timestamped trace — materialize the
  surfacing then.
- [low] all join fixtures declare lookback 1; a test guarding C8's "a node never
  reads beyond its declared lookback" for a depth>1 join is still owed (the
  substrate already enforces it via Window bounds, so this is confirmation, not a
  hole).

Regression gate: profile regression list empty — architect is the sole gate, now
clean (31 tests: aura-core 19 + aura-std 3 + aura-engine 9; clippy -D warnings
clean; purity grep clean). Cycle 0004 is drift-clean. NOT a milestone close: the
walking-skeleton milestone still needs its end-to-end fieldtest (ingest -> signal
-> backtest -> position table -> broker -> pip-equity), of which this cycle
delivers the firing/merge layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:27:00 +02:00
Brummel f37b2a35d3 feat(engine): firing policies A/B + the k-way ingestion merge
Cycle 0004: the reactive-semantics layer. The engine now merges multiple
timestamped sources into one chronological cycle stream (C3) and gates each
node's re-evaluation by a per-input firing policy (C5/C6) — both rails shipping
together, as the user required ("must sit right from the start").

- `Firing` (aura-core) — a per-input enum on `InputSpec`, where C8 places firing:
  `Any` (mode A, fire-on-any-fresh + hold / as-of join) and `Barrier(u8)` (mode B,
  all-fresh barrier / synchronizing join). Default-free; Sma/Sub declare
  `Firing::Any`, so under a single source ticking every cycle their behavior is
  unchanged (backward compatible — the 0003 fan-out/join DAG still emits
  [None,None,None,2,2,2]).
- `SourceSpec` + the k-way merge (aura-engine) — `run` takes one
  `(Timestamp, Scalar)` stream per source and merges them by (timestamp, source
  index) (C4 ties by declaration order). One record = one cycle. This is the
  permanent ingestion core; the Source trait + data-server IO (C3/C11) are a
  later orthogonal cycle (the user scoped this cycle to the reactive semantics).
- Two read clocks, both load-bearing (resolves the 0003 audit's "0004 owns
  cycle_id" with no write-only field): each push stamps the input slot with
  `fresh_at` (the cycle_id — freshness epoch, C5: fresh iff fresh_at == cycle_id)
  and `last_ts` (the data timestamp — barrier token, C6: a barrier group is
  complete iff all members carry last_ts == T). The `fires()` predicate ORs the
  groups; the ">=1 member fresh this cycle" clause is the once-per-timestamp
  guard. Sample-and-hold falls out of the push model: a non-firing node pushes
  nothing, so consumers keep seeing the held value via window[0].

Key design call, studied against RustAst (src/ast/rtl/streams/): the barrier
token is the data *timestamp*, not cycle_id. RustAst's
last_cycle_per_input.all(== cycle_id) synchronizes one source's fan-out but
cannot synchronize independent sources — under C4 four same-timestamp sources
are four different cycle_ids, so the cycle_id barrier never fires for C6's own
OHLC example. Substituting timestamp is the minimal faithful generalization
(fires both the diamond rejoin and the multi-source bar). Mode A and the k-way
merge are both new (RustAst had neither); RustAst's total_count push counter is
aura's already-built Column::run_count.

Both rails proven on identical sources, firing-only difference:
- mode A (AsOfSum): [None,None,120,130,140,240] — holds the slow input;
- mode B (BarrierSum): [None,None,120,None,None,240] — waits for timestamp
  coincidence;
- mixed A+B (MixedSum): the OR-combine fires both when the barrier pair completes
  (holding the as-of input) and when the as-of input ticks (holding the pair).
Plus determinism (C1, bit-identical re-run of each rail) and the three bootstrap
rejections re-expressed on SourceSpec.

Gates green (re-run by the orchestrator, not just the agent): cargo build/test
(30: aura-core 19 + aura-std 3 + aura-engine 8) / clippy --workspace
--all-targets -D warnings clean; surface-purity grep (no dyn-Any / Rc / RefCell)
clean — the harness doc phrases RustAst's model without the literal tokens to
avoid self-match.

refs walking-skeleton

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:21:42 +02:00
Brummel 02fe89176f audit: cycle 0003 tidy — rename Sim -> Harness (glossary alignment)
Architect drift review over 71c8f6e..dd5c3fa (deterministic-sim-loop).
Invariants hold: C1 (single-threaded, bit-identical re-run, alloc-free per-cycle
ring path), C7 (AnyColumn type-erased edges, Box<dyn Node> is the node object not
a dyn-Any payload, kinds checked once at bootstrap), C8/C9 (DAG wired, Kahn
rejects cycles, None forwards nothing = sample-and-hold seed). Column::run_count
already exists, so the C5 freshness primitive lives at the substrate — the
deferral is honest, not a hole.

Resolved [high]: the runtime type was named `Sim`, colliding with the glossary's
load-bearing distinction — `sim` is the disjoint *executable unit / parallelism*
framing, whereas the thing built here (a closed root graph of source + nodes +
observe under a clock) is by C20 / the glossary a **harness**. Renamed
`Sim` -> `Harness`, `sim.rs` -> `harness.rs`, and the module/exports/doc to match,
before any other code takes a dependency on the wrong term. Gates re-run green
(26 tests, clippy -D warnings, purity grep).

Resolved [med] (glossary gap): added an `edge` entry anchoring the new wiring
vocabulary (Edge / source target) to the glossary — record-reality per the boss
glossary-write authority.

Carry-on with forward notes (next-cycle owners, not fixes now):
- [med] C4 monotonic cycle_id has no realization in the runtime type yet (the
  cycle clock is the record iteration; the explicit counter's first reader is
  freshness). Cycle 0004 owns materializing it together with C5.
- [low] Sub declares lookback 1; once depth>1 join nodes exist, add a test
  guarding C8's "a node never reads beyond its declared lookback".

Regression gate: profile regression list empty — architect is the sole gate,
now clean. Next direction: freshness-gated recompute + a second source (C5/C3).
2026-06-03 13:07:43 +02:00