The acceptance proof: hl_channel — a causal Donchian channel (Delay(1) excludes the current bar, C2) consuming high/low/close roles, built from rostered vocabulary, shipped as the r_channel/r_channel_open example pair (builder + regenerator + serialize pins, the r_* pattern). Proven at three layers: a non-gated loaded-vs-carve equivalence over inline sources (non-zero-bias hardened per review), the unconditional synthetic multi-column refusal, and archive-gated CLI e2e — aura run on GER40 end-to-end plus a sweep whose members run and reproduce 2/2 bit-identically. Ledger: new C26 entry (the binding vocabulary — closed column set + price alias, name-driven default + campaign data.bindings override, canonical order = the C4 tie-break, Blockly-litmus argument, the #71 extension point for recorded non-price sources) and the C20/C24 scaffolding-clause annotations: the single-price data weld is retired, wrap_r's remaining R-scaffolding retirement stays #159. Verified: all proof tests green, full workspace suite green, clippy -D warnings clean; independent quality review approved. closes #231
187 KiB
aura design ledger — INDEX
The ledger records the load-bearing design contracts and their rationale. Each contract states what it guarantees, what it forbids, and why. A change that breaks a contract is a design decision (amend the contract here with its new rationale), never a silent refactor.
Provenance: contracts C1–C18 were settled in the initial rough-sketch design
interview (2026-06-03), walking the design tree root-to-leaf (C16–C18 and the
C10 refinement to a broker-independent position table came in follow-up turns;
C10 was reframed in cycle 0007 to an exposure stream, then again 2026-06-23
(#117) to a bias stream with signal quality in R, the position table a
derived layer — see C10). C19–C22 were added as the
construction / World / playground layer; C23 and the C9/C19 compilation
refinements were settled 2026-06-05 for the Construction-layer milestone (the
blueprint→flat-graph reading of composites and graph optimisation). C24 (the
blueprint as a serializable, World-owned data value) was settled 2026-06-29,
resolving the long-deferred #109 fork in favour of topology-as-data (the
game-engine principle, C16): the engine owns topology as content it serializes /
loads / generates, not as Rust source baked into the binary.
The CLAUDE.md Domain invariants section is the compressed, always-loaded
summary of the
subset that agents must never violate; this file is the fuller form with
rationale.
Vocabulary: a contract is one ledger entry. A cycle is one pipeline round; a milestone is a tracker container spanning many cycles (the first milestone is the walking skeleton: ingest → one signal → exposure → sim-optimal broker → synthetic pip-equity signal-quality metric — the pre-reframe substrate, now reframed to bias → R-evaluator → R-expectancy, see C10).
Foundation — what aura is
aura is a framework and a playground for traders. A human and (primarily) LLMs author trading nodes directly in Rust; the engine backtests them deterministically and massively in parallel, composes them fractally, validates them (sweep / Monte-Carlo / walk-forward), and freezes a validated strategy into a standalone bot with a broker connection.
The predecessor RustAst (myc) tried this as a custom DSL and failed: too slow,
too buggy, and LLMs author far better in Rust than in an unfamiliar DSL. aura
inverts it — engine in Rust, strategies in Rust — but keeps RustAst's concepts
(synchronous reactive streams, bounded-lookback series, run-counting, SoA).
RustAst is a conceptual reference, not a dependency. The one reused component is
data-server (the first data source).
What sets aura apart is not the single backtest — every quant system does that — but the World: the meta-level where harnesses are dynamically constructed and families of them orchestrated and explored (walk-forward, sweeps, Monte-Carlo, comparison). The deterministic single-harness engine is the substrate; the World is the product (C20–C22).
External components
Two sibling projects live outside this repo and are named throughout the contracts. Their concrete location is recorded here so the repo is the single source of truth — not session memory.
-
data-server—~/dev/libs/data-server, GiteaBrummel/data-server(http://192.168.178.103:3000/Brummel/data-server.git). aura's first data source and the one reused component (Foundation; C3, C11, C12). A standalone leaf crate (deps:chrono,regex,zip) that loads Pepperstone M1/tick binary files and shares them lock-free asArc<[T]>chunks (CHUNK_SIZE = 1024) viaSymbolChunkIter::next_chunk(an inherent method driven bywhile let, not theIteratortrait);stream_*_windowed(from_ms, to_ms)provides C12's data-window with inclusive Unix-ms bounds. Records are AoS (M1Parsed/TickParsed,time_ms: i64Unix-ms). The ingestion boundary (C3) therefore transposes these AoS records into aura's SoA columns (C7) and normalizestime_ms→ canonical epoch-ns at that one boundary. Pulled in as a cargo git dependency by theaura-ingestcrate (cycle 0011) — the data-source ingestion edge, where thedata-serverexternal tree (and its transitivechrono/regex/zip) enters the workspace. Under the amended C16 per-case dependency policy (cycle 0029),aura-ingestis no longer a zero-dep firewall for the rest: the engine crates link standard vetted crates where they earn their place (e.g.serde/serde_jsonfor the run registry, cycle 0029), with particular scrutiny for the frozen deploy artifact (C13). Consequence:cargo build/test --workspaceresolves from a populated cargo cache (a Gitea fetch fordata-server+ crates.io for the standard crates) rather than fully offline. -
RustAst (
myc) —~/dev/RustAst, GiteaBrummel/RustAst(http://192.168.178.103:3000/Brummel/RustAst.git). The predecessor DSL attempt (Foundation): a conceptual reference, never a dependency — its DSL authoring surface andValue/HM-inference machinery are exactly what aura rejects (C17). But itssrc/ast/rtl/layer is a working reference implementation of the very streaming substrate aura rebuilds, and is worth reading before authoringaura-core— these are not just concepts, they exist as code:rtl/series/data.rs—RingBuffer<T>with financial-style indexing (index 0 = newest),total_count(the run-count of C5) andlookback_limit(C8's pre-sized window); theScalarValuemarker trait ("flat scalars only, no String/Record" = C7's closed scalar set);ScalarSeries<f64|i64|bool>and the SoARecordSeriesfor composites (C7's "OHLCV = a bundle of base columns").rtl/series/mod.rs—create_typed_series, dispatching element type → storage backend (the type-specialized factory of C19).rtl/streams/mod.rs—Signal { cycle_id, value }(C4's cycle clock) and theStream/Observer/ObservableStreampush traits (the reactive model of C4/C5).rtl/streams/register.rs— the RTL "register" / delay node (the one explicit feedback path of C5/C9) plus a seeded, reproducible OHLC generator (C12's seed-as-input).
aura reimplements these natively and sharpens them: types are monomorphized and edges type-erased to the four scalar kinds with direct dispatch (C7) instead of carrying boxed
Values, and input history is shared zero-copy asArc<[T]>(C12) instead of aVecDeque<Value>. RustAst shows the shape; aura makes it fast and deterministic.
Contracts
C1 — Determinism and disjoint parallelism
Guarantee. A backtest is a deterministic, synchronous, non-concurrent event
loop that reaches a unique state after each input tick. Same input (incl. seed)
→ bit-identical run. Two backtests are fully disjoint and run concurrently
without locking.
Scope (ratified, fieldtest 0078). The bit-identity is per run — one backtest
of given (inputs, seed) reproduces byte-for-byte. A derived metric recomputed for
the same params by two different command paths (e.g. a swept member's sqn vs the
same cell re-run under aura generalize) may differ by floating-point reassociation
(≤1 ULP), since the two paths accumulate the same logical reduction in a different
operation order (IEEE-754 non-associativity). This is not a C1 violation: C1 governs
the determinism of a single run, not the cross-command bit-identity of a re-derived
statistic.
Forbids. Concurrency within a single sim; any nondeterministic input that
is not captured as an explicit input (see C11, C12).
Why. Real money rides on backtest results; reproducibility and an audit
trail are non-negotiable. Speed comes from parallelism across sims, which
disjointness makes lock-free.
C2 — Causality / no look-ahead
Guarantee. A node sees only the past. Input history is a read-only window
that ends at the current cursor; a resampler emits a bar only once it is
complete.
Forbids. Any node access to data with timestamp > now; emitting a partial
/ still-forming bar.
Why. Look-ahead is the cardinal backtester bug — a fast backtester that
leaks the future is worse than none. Making the future physically absent from
what a node receives beats merely discouraging it.
C3 — One merge, at ingestion only
Guarantee. Heterogeneous timestamped sources are k-way-merged by timestamp
into one chronological cycle stream at the ingestion boundary; source-native
time units (e.g. data-server's Unix-time_ms) are normalized there to the
canonical epoch-ns timestamp of C7.
Forbids. Any merge / as-of join inside the graph.
Why. A single ordered timeline is the mechanism that makes heterogeneous-rate
sources (news daily-bias + M5 + ticks) causally combinable without leaking the
future. Keeping the merge at one boundary keeps the graph semantics simple.
C4 — Cycle granularity
Guarantee. The clock is data-driven: one input record = one cycle, advanced
in global timestamp order, with a monotonic cycle_id. Ties (same timestamp,
multiple sources) break by source declaration order.
Forbids. A fixed time-grid clock; nondeterministic tie ordering.
Why. The market is an irregular event sequence; a grid is arbitrary and
either wastes empty cycles or clumps ticks. Backtest and live differ only in the
origin of records, not the cycle semantics. Tie determinism preserves C1.
C5 — Freshness-gated recompute and sample-and-hold
Guarantee. The cycle_id advances everywhere (a cheap counter), but a node
re-evaluates only when ≥1 of its own inputs is fresh this cycle (detected by
run-count); otherwise it holds its last output. Stale inputs contribute their
last (held) value.
Forbids. Recomputing every node every cycle ("push all" is true for the
clock, not for *recompute"); treating a held value as missing.
Why. Total recompute does not scale to many sparse high-frequency sources;
freshness-gating is the performance discipline that keeps the synchronous model
fast.
C6 — Firing policy A and B, per input group
Guarantee. A node declares, per input group, one of two firing policies:
A fire-on-any-fresh + hold (latest / as-of join — e.g. tick × held
daily-bias); B all-fresh barrier (synchronizing join — e.g. O/H/L/C from
four separate 15m sources: the candle is complete only when all four are fresh).
A single node may mix an A input and a B group.
Forbids. A single global firing mode; forcing per-node-only granularity.
Why. Both are genuinely needed; RustAst implemented only B. Per-input-group
granularity is required by the OHLC-plus-bias case where one node needs both.
Realization (cycle 0004). Firing is tagged per input — Firing::{Any, Barrier(u8)} on InputSpec — and inputs sharing a Barrier id form a group; a
mode-A input is its own trivial group, so "per input group" and the per-input tag
coincide. The barrier's synchronization token is the cycle timestamp, not the
cycle_id: under C4 four same-timestamp sources are four distinct cycles, so
RustAst's cycle_id-equality barrier could never fire across them. A group fires
when every member's last push carries the current cycle's timestamp, guarded by
"≥1 member fresh this cycle" (so a group completed earlier does not re-fire). This
fires both the multi-source bar and the within-source diamond rejoin (every push
in a cycle carries that cycle's timestamp).
C7 — Four scalar base types, streamed as SoA
Guarantee. Only i64, f64, bool, timestamp (newtype over i64,
epoch-ns UTC) are streamed, as columnar Structure-of-Arrays. Composite streams
(OHLCV) are bundles of base columns — this is the node-output model too: a
node emits a record of 1..K base columns (C8), each forwarded field-wise to a
consumer slot; the bundle is structural, never a fifth scalar type. Edges are
type-erased to these four kinds;
the type check is paid once at wiring/sim-start, then the topology is frozen per
sim → direct dispatch, no per-event allocation.
Forbids. Streaming non-scalars (String, Records, tables, calendars) — those
live as metadata beside the hot path; dyn Any payloads; per-event heap
allocation; topology mutation mid-sim.
Why. Maximal streaming performance (SIMD/cache) needs a tiny closed scalar
set and SoA. The open set is composites (schemas of columns), not scalar types.
Type-erasure at the edge is also forced by the cdylib boundary (C13).
Realization (Cell carrier split, 2026-06). The streamed value is now split
into a tag-free 64-bit word and its kind. Cell (crates/aura-core/src/cell.rs)
is a type-erased u64: constructed per base type (from_i64/f64/bool/ts) and
read only by naming the type at the call site (i64()/f64()/bool()/ts() —
branch-free bit-casts). The kind is therefore resolved once at the boundary and
the value itself carries no tag — C7's "the type lives at the column/edge, not in
the value" made explicit on the single-value carrier (and a single 8-byte word
vs. the 16-byte tagged enum). Scalar becomes { kind: ScalarKind, cell: Cell }
— the self-describing form for the dynamic boundaries (builder binding, serde,
rendering) — with debug_assert-guarded native accessors (caller asserts the
kind; free in release) and a hand-written value PartialEq that preserves
the former enum's IEEE-754 semantics (NaN != NaN, +0.0 == -0.0), pinned by
the scalar_eq_is_value_not_bitwise fixture. Behaviour-preserving.
Realization (Cell becomes the hot-path carrier, 2026-06, #74). The carrier
swap deferred above has landed: Node::eval now returns Option<&[Cell]> and
every node out-buffer is [Cell; N], so the inter-node forward carries tag-free
8-byte words. The kind lives only at the schema/column: the harness forwards each
field via the new branch-free AnyColumn::push_cell (infallible — the edge
kind match is verified once at bootstrap, the surviving guard
bootstrap_rejects_*_kind_mismatch). Scalar remains on the self-describing
dynamic boundaries: the param plane (build/bind/compile_with_params/sweep
points/RunManifest), AnyColumn::get (the type-erased read for sinks/serde),
and source ingestion (Source::next, the heterogeneous C3 merge). The removed
per-value runtime kind check on node output is the same authoring-bug class C8
already leaves to a debug_assert (output width); node-output-kind correctness
is the node's declared FieldSpec contract, caught by each node's own test.
Behaviour-preserving (C1).
C8 — The node contract
Guarantee. A node has a signature — its NodeSchema: each input's scalar
type and firing group, its output record, and the node's own tunable parameters —
typed, with ranges, which aggregate into the blueprint's param-space the optimizer
sweeps (C12/C19/C20). The signature is declared pre-build on the value-empty
recipe (C19), so the whole interface is legible without building (cycle 0024).
The built node implements lookbacks() — the per-input buffer depth, the one
quantity that may depend on an injected param (e.g. an Sma's window = its
length), read once at bootstrap to size the windows — and eval(ctx) -> Option<&[Cell]>. The
engine provides read-only, zero-copy windows into each input's SoA ring buffer
(ctx.f64_in(x)[k], sized at wiring); a node may additionally keep its own
mutable series for derived/intermediate state. None/Void return = filter /
not-yet-warmed-up. A node is a producer, a consumer, or both: a
producer/transformer exposes one output port, whose payload is a record of
1..K base-scalar columns (a scalar is the degenerate K=1 record; an eval
returns a borrowed row, one value per column); a pure consumer (sink) — chart,
equity, logger — has no output. Sources are pure producers; sinks are pure
consumers.
Forbids. A node sizing/growing its input lookback at runtime; more than one
output port per node; a fifth scalar type or a heterogeneous output payload
(a record is a bundle of base columns, C7); copy-on-read of input history.
Why. Engine-provided windows mean LLM-authored code cannot mis-manage
lookback bookkeeping, and history passes through zero-copy. Fixed, pre-sized
buffers suit deterministic, pre-dimensioned sims (no realloc in the hot loop).
Realization (cycle 0005). NodeSchema.output is a Vec<FieldSpec> (named base
columns; length 1 = scalar). Binding is field-wise only: Edge::from_field
selects one producer column per edge; consuming a whole record is N edges (no
"bind whole record" mechanism). The K fields of one record are co-fresh by
construction (one eval, one timestamp), so C6 is untouched. eval returns
Option<&[Cell]> — a borrowed row into a node-owned buffer — so the forward
path allocates nothing per cycle (C7) (the carrier is now a tag-free Cell — see
the C7 carrier note).
Realization (cycle 0006). The pure-consumer (sink) half of this contract is
now realized at the substrate: recording is a node role, not a type. 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
node that only records returns None (pure consumer), and a node may record
and return an output the engine forwards in the same eval (the "both"
case). Encoding & return contract. A pure consumer declares output: vec![]
— the empty record is the sink declaration; there is no separate type, trait,
or marker. Its eval returns None or a zero-width Some(&[]), and the run
loop debug-asserts the returned row's width equals the declared output width
(row.len() == schema.output.len()). Field-wise wiring resolves
Edge::from_field against the producer's output at bootstrap, so no edge can
bind a field of a zero-output node — it fails with BadIndex — making a sink
structurally unwireable as an in-graph producer; its only output is the
out-of-graph side effect. 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 (C1/C7).
Refinement (cycle 0070 — end-of-stream finalize lifecycle, 2026-06-25). The
node contract gains a second lifecycle phase beside eval: finalize(&mut self),
a default-no-op trait method the engine calls once per node, in topological
order, after the source loop drains (the Harness::run epilogue). It lets a sink
fold online — accumulate per eval into O(trades)/O(1) owned state and flush
one compact summary at stream end — instead of pushing a record every fired cycle
into an unbounded channel that buffers O(cycles) rows until the run ends (the
retention BLOCKER #138 hit: a full-history Stage-1 sweep held ~2 GiB/member over
~5.5M one-minute bars). C1/C7/C8 are preserved: finalize runs once after the
deterministic event loop, adding no within-sim concurrency (C1); a folding sink
holds only owned accumulator state, no interior mutability (C7); it still declares
output: vec![] and its flush is the same out-of-graph side effect (C8) — one
summary row, not a per-cycle stream. Realized by GatedRecorder (emits only gated
rows + the genuine final row) and SeriesReducer (folds one column, emits one
summary row), aura-std siblings of the per-cycle Recorder, which survives for the
live / --trace / test-tap path. The recording sink's own per-cycle allocation
(the Recorder→Probe rename + its accumulate-vs-stream choice, #77) stays open;
finalize is the "non-channel exit from the graph" that issue's
accumulate-then-read option named as missing.
Refinement (Construction-layer milestone — render labels, 2026-06-05). A node
additionally exposes label() -> String, a single-line, non-load-bearing
render symbol: a default trait method the run loop never calls (wiring is by
index, C23). Overrides carry the node's identifying params (SMA(2) vs SMA(4))
so a graph render (C9 graph-as-data, #13) disambiguates identical node types and
surfaces a mis-wiring. Like FieldSpec.name, it is an informative debug symbol,
not part of the C8 dataflow contract — adding it changes no run behaviour.
Realization (cycle 0015 — param declaration). The tunable-parameter half of
this contract is now realized: a node declares its knobs in schema() as
params: Vec<ParamSpec> (ParamSpec { name: String, kind: ScalarKind }), and
Composite::param_space() (cycle 0024; was Blueprint::param_space()) aggregates
them into one flat, path-qualified list — a
read-only projection of the graph-as-data (C9), mirroring the inline order
(C19/C23) so a param's slot matches the later flat-node order. Two refinements to
the guarantee's "typed, with ranges": (1) the declaration carries name + kind
only — the search-range is the run's, not the node's (which subset / grid a
sweep covers is an experiment axis, #32/C20; the node declares the knob's existence
and type, never its search interval). (2) Identity is positional (the slot,
C23 "by index, not name"); the path-qualified name is a non-load-bearing debug
symbol (like FieldSpec.name) — uniqueness is at the slot. Refinement (cycle
0032): identity in the flat graph stays positional (C23, unchanged), but the
param_space() name projection — the authoring / by-name address space (the
surface a sweep axis or single-run binding addresses) — must be injective for a
blueprint to compile (a duplicated path is unaddressable; see C9). Two layers:
positional wiring below (the flat graph), an injective name address space above (the
authoring boundary). So same-type siblings in one composite no longer silently
share a name — they must be .named(...) apart, which is also what disambiguates a
same-type fan-in (C9). A vector knob (LinComb.weights) expands to N flat
weights[i] entries, N topology-fixed (C19). Permitted kinds are i64/f64/
bool (a timestamp knob is a structural axis, C20, never a numeric sweep param).
Binding a value to a slot landed in cycle 0016 (#31, see C19/C23); enumerating a
sweep (#32) is the deferred next layer. The 0016 binding also moved the param
declaration's authoring home into the value-empty recipe, whose params() is read
pre-build. In 0016–0023 the built node's schema().params still reported the same
slots, kept in lockstep by a per-node test (a duplication filed as debt, #36); cycle
0024 dissolved this — the signature is declared once on the recipe and the built
node no longer carries schema() (#36 closed). See the C8 cycle-0024 realization.
Realization (cycle 0024 — the signature lives in the blueprint, #43/#36). The
node's whole declared interface — its NodeSchema (input scalar types + firing,
output record, params) — now lives once, on the value-empty recipe
PrimitiveBuilder (ex-LeafFactory), read pre-build by param_space(), the
compile-time structural validation, and the render. This closes two debts: #43
(a value-empty recipe used to declare no input/output interface pre-build — only
params) and #36 (params declared twice, recipe vs built node, kept in lockstep
by a per-node test — those 8 tests are deleted, the duplication structurally gone).
The split that makes this work: a node's signature is fully static per blueprint
(input kinds/firing, output fields, params — verified across the roster; a variable-
arity node like LinComb takes its arity as a recipe argument, not an injected
param), so it can be declared without building; the one param-dependent quantity,
an input's buffer lookback depth, is no longer in the signature but answered by
Node::lookbacks() -> Vec<usize>, read only by bootstrap to size the windows.
Node::schema() is therefore removed — the signature is pre-build data, not a
built-node method. BlueprintNode::signature() answers uniformly for both arms (a
primitive returns its recipe's schema; a composite derives it from the interior:
role kinds in, re-exported field kinds out, aggregated params), so "every node has a
signature in the blueprint" holds for composites too.
Realization (cycle 0027 — name input ports, refs #21/#51). PortSpec now
carries a name: String (it drops Copy, like ParamSpec), so an input port is
named just as FieldSpec.name (output) and ParamSpec.name (param) already are.
The name is non-load-bearing (C23): wiring stays positional by slot, bootstrap
and the run loop never read it; it exists for tracing / graph rendering (#13). Leaf
primitives declare their slot names (SimBroker's exposure/price, etc.);
derive_signature carries a composite's Role.name into the derived input port,
so the graph model (model_to_json) is homogeneously named across inputs, outputs,
and params and across both graph levels. This does not close #21 (a swapped
same-kind wiring is still only kind-checked) — it makes those slots
self-documenting; a name-consuming validation is its own future cycle.
Realization (cycle 0040 — wiring totality: every input slot connected exactly
once, #65). The node contract's "the engine provides a window into each input"
presupposes each input is actually fed; until now an unwired interior input slot was
accepted and bootstrapped to a silent empty column (harness.rs), and two producers
into one slot — ill-formed, since a slot holds one column — was likewise
uncompiled-against. Cycle 0040 makes a valid graph total and single-valued over
its interior input slots: check_ports_connected (in validate_wiring, run at every
nesting level) requires every interior node's every declared input slot to be covered
by exactly one wiring act — one interior Edge { to, slot } or one role
Target { node, slot }, edges and role targets counted uniformly. Zero coverage is
CompileError::UnconnectedPort, more than one is CompileError::DoubleWiredPort. A
composite's own input roles (source: None) are coverage providers — the
wired-by-enclosing boundary, the root case already guarded by UnboundRootRole —
never consumers, so only interior input slots are subject to the rule. There is no
optional-input concept: every declared input port is required (no shipped node runs
meaningfully without one; an unwarmed mode-A input is wired-but-not-yet-valued, not
unwired). The check is index-based and name-free — it touches no name machinery
and emits nothing into the flat graph, so C23 is untouched (it proves the existing
raw-index wiring is total and single-valued). Inherited identically by the raw
Composite::new path and the GraphBuilder::build() path (both compile via
compile_with_params).
C9 — Fractal, acyclic composition
Guarantee. A composite is itself a Node that wires a sub-graph and exposes
one output; signal, combined signal, and (with execution) strategy are all the
same abstraction, nestable arbitrarily. The dataflow graph is a DAG; the only
feedback path is an explicit delay/state node (the RTL "register"). Wiring is
written in Rust (builder API); the built graph is introspectable runtime data.
Forbids. Implicit dataflow cycles (combinational loops); special-casing
"signal-of-signals" as separate mechanics.
Why. Self-application of one contract gives unlimited composition with no
adapter zoo. Acyclicity keeps the synchronous reactive model well-defined;
forcing feedback through a visible delay node keeps the per-cycle determinism
intact and the one legitimate feedback path explicit. Graph-as-data enables
visualization, freezing, and re-parameterization for sweeps.
Refinement (Construction-layer milestone, 2026-06-05). "A composite is itself
a Node" is an authoring-level identity: a composite declares the same
interface (typed inputs + ≤1 output, C8) and is wireable wherever a node is, but it
is not a runtime object. The bootstrap compiles it away by inlining its
sub-graph into the one flat instance (C19/C23): the composite boundary dissolves
into the raw index wiring the run loop already consumes, and names stay
non-load-bearing (informative debug symbols only, as FieldSpec.name already
is). So "nestable arbitrarily" and "graph-as-data" hold at the blueprint
(source) level; the running graph is the flat, index-wired FlatGraph. The earlier reading — a composite survives as a Box<dyn Node>
driving a nested sub-engine — is explicitly rejected: it would keep the
interior opaque to the cross-graph optimiser (C23) and add a runtime sub-loop the
flat model does not need. Inlining is what makes the composite boundary free.
Realization (cycle 0018 — composite multi-output record, #40). "Exposes one
output" is one output port carrying a record of 1..K re-exported fields, not
one field: Composite.output is a Vec<OutField { node, field, name }> (was a
single OutPort). Each entry is a named projection of one interior
(node, output-field); a consumer selects which re-exported field it reads via the
same Edge::from_field that already binds leaf record columns (C8 realization,
cycle 0005). This is the same arity C8 already grants a leaf (OHLCV = one port,
5 columns): a multi-line indicator (MACD = macd/signal/histogram) is one record
of K fields, one port, one row per eval — C8/C7/C4 untouched, a boundary
completion, not a contract change. A strategy composite is simply the K=1 case
(one bias field, C10). The re-export names are non-load-bearing (C23):
they live at the blueprint boundary and in render (cycle 0022/#46 folds each onto
its producing node as a name := … binding; originally [out:<name>] markers, #13)
but are dropped at lowering — ItemLowering::Composite.output is Vec<(usize, usize)>, raw index pairs only, so the flat graph is name-free (verified: the
compiled-view render stayed bit-identical across this change).
Realization (cycle 0019 — name the composite boundary, #41; param-overlay retired,
cycle 0031). The named-projection shape covers the surviving boundary edge-kinds:
input_roles is a Vec<Role { name, targets }> (was a bare Vec<Vec<Target>>),
alongside output: Vec<OutField>. Each is an ordered, positionally-indexed
named projection of interior handles. Param projection is no longer a
composite overlay (the index-addressed ParamAlias was retired in cycle 0031):
a node's surface param name flows from its own instance name — every node
carries a name (default = its lowercased type label, override via .named()), and
param_space() is uniformly <node>.<param> at every level including the root. A
same-type fan-in is distinguished by naming the colliding legs, the same single act
that qualifies their param paths. Like the output and role names, node names are
non-load-bearing (C23): they live at the blueprint boundary and in render but are
dropped at lowering — the flat graph is wired by raw index. The full composite
boundary signature (named inputs, multi-outputs) and the per-node param path are
legible without changing the flat graph.
Refinement (param-namespace injectivity, cycle 0032; supersedes the fan-in
distinguishability check). A blueprint compiles only if its param_space() name
projection is injective — every path-qualified knob name is unique. A duplicated
path is a knob no binding can select alone (it has no distinct by-name address,
C12/C19), so it is a CompileError::DuplicateParamPath carrying the offending path;
the cure is to give the colliding same-type sibling nodes distinct names with
.named(...), the same single act that qualifies their param paths. The
param-bearing indistinguishable fan-in is one instance of a duplicated path (two
default-named same-type legs share a leaf path). signature_of, leaf_has_param,
and the fan-in-specific check (check_fan_in_distinguishability /
check_composite_fan_in) are retired (cycle 0032), replaced by one structural
check_param_namespace_injective over the param_space() names, run before name
resolution in compile_with_params and both binders — so the canonical by-name
author sees the structural cure rather than a downstream AmbiguousKnob symptom
(that BindError arm is retired too: an injective space can never multi-match).
Paramless interchangeable same-name sources stay legal (no path, no duplicate).
The old signature-collision predicate had extra breadth — it also rejected an
asymmetric param/paramless collision and a role-vs-leg collision, neither
a path duplicate (each colliding configuration keeps a unique param path, or none).
That breadth guarded render identity, dead since the renderer was retired in
0026; both extra rejections are intentionally dropped. A future node-/wiring-name
distinguishability check, if ever wanted (e.g. for the WASM graph view's #21
thread), is decoupled from param-space injectivity. Construction-phase only; the
flat graph stays name-free (C23).
Refinement (2026-06-29 — graph-as-data round-trips both ways, C24). "The built
graph is introspectable runtime data" is now contract-level bidirectional: a
blueprint is not only emitted as data (model_to_json, the render half) but is
itself a serializable data value with a load path (data → blueprint →
FlatGraph), so topology is a value the World generates, stores, and reproduces —
see C24. The Rust builder API stays the primary human / LLM authoring surface
(C17); the data form is the machine surface the World owns. (Load path realised
cycle 0087 / #155, d5602ec: aura-engine::blueprint_from_json.)
C10 — Strategy output is a bias stream; signal quality is measured in R; cost is a composable downstream graph (gross R → net R); money is decoupled to the live deploy edge
Guarantee. A strategy's primary, backtestable output is not an equity
curve, nor a position-event table, nor a position size, but a bias
stream: the DAG expresses exactly one state at time t (C8 — a node emits at
most one record per eval), so a strategy emits one signed, bounded bias
f64 ∈ [-1, +1] per cycle (per instrument) — the sign is direction, the
magnitude is conviction, and conviction is optional (a bare ±1 / 0 is the modal
case). Bias is unsized: position sizing and the protective stop do not
live in the strategy. The chain is signals (scores) → decision node → bias stream.
Risk-based execution is a decoupled downstream layer; in research it is Stop +
position-management in R, no Sizer. Turning a bias into a tracked trade is the
job of a downstream execution chain, never the strategy's. The stop-rule
sets a protective stop, which defines the risk unit R (1R = the loss taken if
stopped). In the research loop the executor is stop-rule → position-management,
operating directly in R, with the Veto an optional documented pre-trade-gate
seam (a pass-through identity DCE'd away under C19/C23 when absent): there is no
Sizer. Sizing in currency (size / volume) is a deploy concept. C8's
wiring totality (cycle-0040 check_ports_connected: no optional-input concept —
every declared port is covered by exactly one wiring act) forbids a dangling
size port, so the resolution is concrete: research PositionManagement either
drops its size input port from its node signature, or has that port driven
by a constant unit node (the flat-1R degenerate) — never an unwired "vestigial"
port; the record's size field is held at unit and carries no research
information, and the position-event table's volume column likewise. Consequently
the position-event table is demoted to a deploy / reconciliation artifact (real
volume lives there), no longer a research artifact and no longer "fed to a broker".
The three-way decoupling of direction (bias) / sizing / fill stands as a
structure; sizing and fill are simply pushed entirely to the deploy edge, out of
the research loop.
Signal quality is measured in R — gross R and net R. A downstream
R-evaluator consumes the executor run and integrates the per-trade R-outcomes
into an R-expectancy / R-curve: the account- and instrument-agnostic yardstick
for "how much R out per 1R risked?". R, not pips, is the unit (pips are not
risk-normalized). Two readings of the same unit: gross R (signal only) vs net
R (after the cost model), with net R = gross R − cost-in-R. The headline
artifact is the net-R equity curve — the cost-drag drawn onto the R curve,
recorded through a named net_r_equity tap/sink (sibling of the existing
r_equity tap; a sink is the only thing the registry can display, C8/C18/C22). No
new unit is invented: it is R, gross and net, continuous with the existing
net_expectancy_r. A bare gross-R run with no cost model attached is valid:
the cost layer is optional and additively composed-on (the zero-cost baseline is
the "default simple" floor).
Cost is a COST MODEL — a composable C9 graph of cost nodes, in R, that
approximates (never claims) realism. The realistic broker is retired (see
Forbids / the 2026-06-28 reframe): real friction — slippage (live
liquidity / order-size / volatility at fill), swaps (broker-set, time-varying),
even recorded feed spread (often a fake constant) — is not historically
knowable, so an authored-friction historical broker is "horseshoe-throwing". Its
replacement is a cost model: an ordinary downstream C9 graph of cost nodes
that approximates the cost side a broker would produce, explicitly as an
approximation. The cost nodes live in aura-std (the cost-graph composite-builder
in aura-composites), never in the domain-free aura-engine (C14/C16). They are
not "additive on the R stream" in isolation: a cost node reads the state it
depends on — the price stream, a realized-volatility tap, a C11-recorded
interest-rate source, and the executor's per-cycle R-record / trade events — and
emits a cost-in-R stream that is subtracted from gross R to yield net R. Cost
attaches at the structurally correct grain: per-trade factors (commission, a
flat cost-per-trade) deduct from realized_r at close; per-cycle-held factors
(carry / funding / swap) accrue over the holding duration. The model generalizes
the existing scalar round_trip_cost / net_expectancy_r into a possibly
state-dependent graph: the scalar round_trip_cost is the degenerate
constant-per-trade special case, subsumed by the cost graph — the post-run
summarize_r fold no longer recomputes cost independently but folds the
cost-model's net-R stream into net_expectancy_r (one home for cost, no
double-count). Discipline: every cost factor is either a clearly-labelled
stress-parameter (e.g. a flat cost-per-trade is a breakeven-threshold probe)
or data-grounded / falsifiable (e.g. realized-volatility → slippage;
recorded interest-rate data via C11 → funding / swap). Default simple;
complexity is earned per grounded factor. Stacking unfalsifiable guesses
(over-modelling) is the anti-pattern.
The research loop is pure feed-forward — compounding is removed. Flat-1R-style
R accounting needs no equity, and with the Sizer gone there is no equity → size
edge at all in research: the loop is feed-forward, maximally parallel and
deterministic (C1), the cost model a feed-forward subtraction on the R stream.
Compounding is removed from research: it is a post-strategy money-management
transform — a pure function of the per-trade net-R sequence and a
bet-fraction f, multiplicative and path / order-dependent, the sole source of
feedback — so compounding, Kelly-f, and drawdown-under-compounding are derived
post-hoc, analytically, at the deploy / account layer from the net-R
distribution. Consequently the z⁻¹ fill-edge register and the
flat-1R-vs-compounding structural axis are gone from the research loop (with no
in-loop feedback there is nothing for a register to cut, so the C23-reordering
hazard the old text invoked to make the register mandatory no longer exists — this
is strictly C9 / C23-cleaner).
Conviction-based risk allocation survives — as an R-aggregation axis, not a
Sizer. Scaling risk by bias strength is signal-side and R-denominated.
Because per-trade R is size-invariant, conviction cannot be expressed by
scaling position size (that is invisible to R); it is expressed by weighting the
per-trade R-contribution in the R-equity: flat (sum of realized_r, sign
only) vs conviction-weighted (sum of |bias| · realized_r, sign + magnitude).
This is a feed-forward, additive, order-independent research axis (flat vs
conviction-weighted R-aggregation), distinct from the removed money-Sizer; it is
tested via the existing conviction_at_entry field and conviction_terciles_r
metric, and may sit in-graph or as a post-hoc fold.
Money / real broker / cTrader Open API = a separate, later live / deploy-edge
concern — the only belastbare (reliable) ground truth, measured never modelled.
Reliable friction statistics require forward-trading against a real broker
(e.g. cTrader Open API), and are non-stationary even then. This fits C11
(record-then-replay: real fills are recorded live, then replayable) and the
frozen-deploy invariant (C13: deploy = frozen bot + broker connection);
reconciliation with the real account is an external I/O adapter at the
recording / deploy edge, not an in-graph node. This is the only place account
money appears. (Currency-denominated reference geometry — pip value,
stop-distance-in-currency from the C15 instrument_geometry sidecar — is still
read at the ingestion edge to normalize a currency / pip cost factor into R;
notional size cancels in cost_in_R = cost_in_currency / (size · stop_dist), so
the cost model is R-pure without ever holding equity. It is equity / account
money, not reference geometry, that lives only at the deploy edge.) The
broker-independent position-event table (event_ts, action[buy/sell/close], position_id, instrument_id, volume) is the deploy / reconciliation record at
this edge (real volume), not a research artifact.
Honesty principle. The net-R curve under the cost model is a research /
ranking tool and a hypothesis; the forward / live run against a real broker is
the ground truth. The cost model approximates; it never claims realism.
SimBroker (the pip-equity, unsized-exposure node) is the pre-reframe pip
ancestor — with the net-R cost model it is redundant as a quality measure (R
supersedes pips). It is retained as a legacy / simple optional pip yardstick
(still wired in the r-sma harness for an honest dual readout), not part of
the new model and not to be expanded.
Forbids. Putting sizing or the stop in the strategy (bias is unsized; the
stop-rule owns the stop, R is the unit); treating an equity curve (R or
currency) as the strategy's direct output; putting a Sizer / currency size /
volume into the research loop (size is a deploy concept; research is in R);
leaving a dangling size input port on the research executor (C8 wiring
totality forbids it — drop the port or drive it with a constant unit); any
equity → size / equity → anything feedback in research (the research loop is pure
feed-forward; compounding is a post-hoc money-management transform, not an in-loop
edge); modelling an authored-friction "realistic broker" over historical data
(real friction is not historically knowable — use the approximating cost model, and
treat the real broker as the live-edge ground truth only); claiming the cost
model is realism (it is an explicit approximation); stacking unfalsifiable cost
guesses (each cost factor is a labelled stress-parameter or data-grounded —
over-modelling is the anti-pattern); computing cost in two homes (the cost
graph owns cost; the post-run fold subsumes the old scalar round_trip_cost, never
double-counts it); expressing conviction by scaling position size
(size-invisible to R; conviction is an R-aggregation weight); making the
position-event table the strategy's direct DAG output (it is derived, not
emitted per eval — a decision instant may need >1 event, which C8 forbids) or a
research measure of signal quality (it is a deploy / reconciliation artifact);
measuring signal quality in currency / account money in the research loop at all
(account money lives only at the live deploy edge; reference geometry at ingestion
is not account money); baking a broker into the strategy or an in-graph broker
subsystem (the in-graph realistic broker is retired; the only broker is the
live-edge I/O adapter, C11 / C13); a signed-volume direction trick in the event
table (use action); storing open_ts (derive it from the opening event).
Why. A strategy's edge is a procedure, not a currency outcome ("focus on the
procedure, not the money"): the right primary question is "how much R out per 1R
risked?", and R — defined by the stop — is the only account- and
instrument-agnostic, risk-normalized unit (pips are not). Separating direction
(bias) from sizing from fill is the decomposition every mature system converges
on (LEAN's Alpha → Portfolio-Construction → Execution is a near isomorphism;
backtrader, QSTrader, zipline all emit an unsized directional signal sized
downstream) — and aura pushes sizing and fill off the research loop for two
distinct reasons: sizing is off because per-trade R is size-invariant
(size carries no information in R — flat-1R is perfectly knowable, it just does not
matter), and fill / friction is off because real friction is not
historically knowable and therefore not honest over history. Keeping research
pure feed-forward leaves the signal-quality layer parallel and deterministic
(C1); the only real feedback (equity → bet-fraction) is compounding, a
closed-form, path-dependent transform of the net-R sequence, and therefore belongs
after the strategy, at the deploy / account layer, not as an in-loop register.
The cost model as a C9 graph keeps cost within the one Node / graph abstraction
(C9) and generalizes the scalar net_expectancy_r continuously, while the
gross-R / net-R split states the cost-drag honestly without inventing a unit.
Refusing the historical realistic broker is an honesty stance: the only
belastbare friction is measured forward against a real broker (cTrader Open
API) — the live deploy edge, the sole place account money and ground truth appear.
The DAG holds exactly one state at t and a node emits ≤1 record per eval (C8), so
the faithful per-cycle output is the bias (one value); position events are a
derived, deploy-side consequence. This supersedes the realistic-broker /
currency / compounding / Stage-1-vs-Stage-2 framing of the #117 reframe (see the
2026-06-28 reframe note), which itself superseded the exposure framing of
cycle-0007 and the still-earlier "strategy's output is the position-event table"
framing.
Reframe (2026-06-28, #116 — realistic broker retired; cost-model graph in R; money to the live edge).
This is the live contract above. Ratified in an in-context design discussion
(reference issue #116). It preserves the durable spine — unsized bias stream
(sign = direction, magnitude = conviction), signal quality in R, the stop defining
R, and the decoupling of direction from sizing from fill — and the shipped Stage-1
realizations (the Bias node, FixedStop / vol_stop, PositionManagement,
summarize_r / RMetrics / sqn_normalized, SQN as ranking objective). The
RiskExecutor composite survives in shape (bias + price → stop-rule →
position-management) but with its Sizer interior removed and its risk_budget
argument dropped / vestigial (it sized the now-removed Sizer); the Veto remains an
optional documented seam. The contract supersedes the following, which were
design intent, largely unbuilt (#116 — the realistic broker and the whole
Stage-2 currency layer were never implemented; the Stage-1 R chain was) and are
retired:
- the "realistic broker" concept — an authored-friction historical broker —
rejected as "horseshoe-throwing" (real friction is not historically knowable),
replaced by the cost model: a composable C9 graph of cost nodes (in
aura-std/aura-composites), approximating not claiming realism, generalizing / subsuminground_trip_costintonet_expectancy_r; - the "Currency P&L is Stage 2" paragraph in full, the Stage-1-vs-Stage-2 hard sequencing gate, and currency / fixed-fractional / compounding in research — compounding is now a post-hoc money-management transform of the net-R sequence at the deploy / account layer;
- the
z⁻¹register on the fill edge and the flat-1R-vs-compounding structural axis as research mechanism — there is no equity → size edge in research, so no register and no such axis in the loop; - the Sizer in research and currency size /
volume— size is a deploy concept; the research executor is stop + position-management in R;PositionManagement'ssizeport is dropped or constant-driven (no dangling port, C8), itssizefield and the event table'svolumeare vestigial in research; - the "a broker is an ordinary in-graph node / no special external broker subsystem" forbid, for the live edge only: in-graph brokers are retired outright, and the live broker is now an explicit I/O adapter at the C11 recording / C13 deploy edge — not an in-graph node and not part of the research graph (the no-in-graph-broker-subsystem prohibition still holds inside the graph);
- the position-event table as the realistic-broker input and its
first-difference-of-the-book (
deal = target − book − in_flight) execution framing — the table (schema 0063 #114, derive 0068 #115) survives as the deploy / reconciliation artifact (real volume), not a research artifact and not "fed to a broker" in research. Money, a real broker, and cTrader Open API are a separate, later live / deploy-edge concern (C11 record-then-replay; C13 frozen-deploy invariant) — the onlybelastbareground truth, measured, never modelled. The honesty principle is explicit: the net-R curve is a research / ranking hypothesis; the forward / live run is ground truth.SimBrokeris downgraded to a legacy / optional pip yardstick, not to be expanded. (Terminology note: with Stage 2 gone, the "Stage-1" label below is no longer one half of a two-stage gate — it survives only as a historical cycle name, like theexposure→biason-disk alias, denoting the shipped feed-forward R chain; the identifier family that carried it was renamed to the r-family —r-sma/r-breakout/r-meanrev— in cycle 0100, #174.)
Realization (cycle 0081 — cost-model graph, cycle 1, #148). The first concrete
cost node + the net-R seam shipped. ConstantCost (aura-std) is an ordinary
downstream node (C9) emitting a 3-field cost-in-R record {cost_in_r, cum_cost_in_r, open_cost_in_r} isomorphic to PositionManagement's R-triple; R-pure
(cost_per_trade / |entry − stop|, notional cancels, no equity held). The scalar
round_trip_cost argument of summarize_r is retired: summarize_r folds a
co-temporal cost stream (positional 1:1 join — cost[i] is record[i]'s cycle) into
net_expectancy_r, one home for cost / no double-count, byte-identical to the old
cost = 0 baseline on an empty stream. The headline sink is net_r_equity =
LinComb(4)[cum_realized_r, unrealized_r, −cum_cost_in_r, −open_cost_in_r] → Recorder
(C8/C18), a sibling of r_equity, emitted only when a cost is authored. Wired on the
run path via --cost-per-trade (r_sma_graph); sweep / walk-forward / mc pass
None (cost on the reduce-mode sweep path and cost in OOS pooling deferred — the
positional join holds only while one cost node fires in lockstep with PM). Deferred to
later milestone cycles: the general CostNode trait + multi-node cost-graph
composite-builder, data-grounded factors (realized-vol → slippage, recorded-rate →
swap), per-cycle-held accrual (carry / funding), and the conviction-weighting
R-aggregation axis. Decision log: #148.
Realization (cycle 0082 — cost-graph composition, cycle 2, #148). The cost graph
composes: a second, state-dependent cost node plus an aggregator prove that two
cost nodes sum into one net-R curve with summarize_r and the net_r_equity tap
structurally unchanged. VolSlippageCost (aura-std) charges slip_vol_mult · vol / |entry − stop| in R, reading an independent short-horizon realized-range vol
(RollingMax − RollingMin, window distinct from the stop's own vol — scaling slippage
by the stop's vol would collapse cost-in-R to a constant, indistinguishable from
ConstantCost). CostSum (aura-std) is the cost-graph output node: it sums N
cost nodes' 3-field cost-in-R records per-field into one aggregate (n = 1 is the
identity), so the seam consumes a single cost stream regardless of node count — one
home for cost, the positional join unchanged. Co-temporality contract (load-bearing,
generalizes to all future factors): since summarize_r positionally joins cost[i] ↔ record[i], a cost node is gated only by the PM trade-geometry; any not-yet-warm
state input (the vol proxy warms later than PM) contributes 0 cost that cycle
rather than withholding — the node still emits its row, so the cost stream stays
co-temporal 1:1 with the PM record. This makes co-temporality structural and
warm-up-independent, preserves the C18 golden, and is honest (no slippage estimate yet
→ no charge). ConstantCost satisfies it trivially; only state-dependent nodes need
the missing-factor → 0 rule. Wired on the run path via --slip-vol-mult,
composable with --cost-per-trade (their costs sum); sweep / walk-forward / mc pass
None. The 3-field cost triple is now restated by-convention across the two producers
CostSum(a compiler-unlinked lockstep) — to be unified by the still-deferred generalCostNodetrait (now justified by two concrete nodes). Decision log: #148.
Realization (cycle 0083 — CostNode trait + shared cost-record contract, cycle 3,
#148). The deferred unifier ships. A new aura-std/src/cost.rs owns the cost-model
node abstraction: the 3-field cost triple is now one source of truth
(COST_FIELD_NAMES / COST_WIDTH, mirroring position_management::{FIELD_NAMES, WIDTH}), read by both producers + CostSum + the CLI wiring — the cycle-0082
by-convention lockstep is structurally gone (four restatements collapsed to one).
The CostNode factor trait carries a cost node's only per-node difference — the
price-unit cost numerator (cost_numerator(&mut self, &Ctx) -> f64), plus
extra_inputs (default none) and label; everything else is the generic
CostRunner<F: CostNode> adapter, which holds the shared cum/out state and
implements Node, writing the co-temporality skeleton (geometry-only gating,
numerator / latched R-normalization, the closed/open charge, the running cum, the
3-field emit) once. ConstantCost and VolSlippageCost are now thin factors whose
new() returns CostRunner<Self>. Honours C9 (a CostRunner<F> is a plain downstream
Node, no runtime sub-object) and C23 (name() dropped as dead surface — label()
remains the non-load-bearing symbol). Behaviour-preserving: the builders emit
unchanged schemas, so the wiring / net_r_equity seam / summarize_r are untouched and
the numerator / latched token form is byte-identical; the existing suite passes
verbatim and two new CLI characterization goldens pin the exact flat/composed
net_expectancy_r (the prior tests only asserted net < gross). Decision log: #148.
Realization (cycle 0084 — cost-graph composite-builder, cycle 4, #148). Decision E
ships. A new cost_graph(Vec<PrimitiveBuilder>) -> Composite in aura-composites (the
C16 layer that couples the engine builder + aura-std nodes) is the cost-model graph's
authoring primitive: it fans the 4 PM-geometry inputs to N cost nodes, surfaces
each node's extra inputs (discovered via schema().inputs[GEOMETRY_WIDTH..],
GEOMETRY_WIDTH now re-exported from aura-std) as cost[k].<port> composite roles,
sums them through CostSum, and exposes the 3-field aggregate. The CLI's manual
slot-indexed cost-wiring + the hardcoded MAX_RUN_COST_NODES = 2 cap are deleted — the
composite handles arbitrary arity. Behaviour-preserving (C11): the composite inlines
at bootstrap to the same flat fan-in, so the cycle-0083 net_expectancy_r goldens are
byte-identical (four aura-composites unit tests pin the exposed role-set + output
triple, incl. arbitrary-arity per-node namespacing). Honours C9 (ordinary downstream
nodes), C16 (wiring stays out of aura-engine), C23 (role/port names are
non-load-bearing). Carried debt (#152, for the deferred sweep-cost cycle): the
cost[k].<port> index-namespacing is restated across CostSum / cost_graph / the CLI
(a build-validated lockstep, not the silent positional kind 0083 collapsed), and
cost_graph .leak()s runtime port names per build — fine for one-shot run-path
construction, but to be interned (the COL_PORTS production pattern, not the test-only
.leak()) before cost reaches the per-member sweep path. Decision log: #148.
Realization (cycle 0085 — per-cycle-held accrual, cycle 5, #148). C10's
"per-trade factors deduct at close; per-cycle-held factors accrue over the hold"
clause is first realized. A cost factor now declares when it charges via
ChargeMode { AtClose, PerHeldCycle } (a defaulted CostNode::charge_mode(), default
AtClose), read by the one shared CostRunner — not a second runner type (a
commission is intrinsically at-close, a carry intrinsically per-held-cycle; the timing
belongs to the factor, preserving the cycle-0083 "skeleton written once" win). The
PerHeldCycle arm accrues per into a per-position acc every held cycle, dumps the
accrued total into cum at close (resetting acc), and marks the open position via a
growing open_cost_in_r. The first accrual node, CarryCost (aura-std,
a ConstantCost twin differing only in charge_mode()), is a labelled stress
parameter — the flat base of the accrual family; a run-path --carry-per-cycle flag
pushes it into the existing cost_graph/CostSum aggregation (no new wiring; a
per-trade and a per-held-cycle node compose, the costs summing per-field). Approach B
(honest bleed), achieved without a summarize_r fold change: the headline
net_r_equity curve bleeds continuously over the hold because the bleed lives in
open_cost_in_r, which the net_r_equity tap already subtracts — so summarize_r and
the CLI net_eq wiring are untouched, and the cycle-0083/0084 net_expectancy_r
goldens stay byte-identical (the AtClose arm is the pre-cycle-5 eval tokens verbatim).
The 3-field cost record's semantics generalize cleanly: cost_in_r = cost realized this
cycle (into cum); open_cost_in_r = the open position's cost marked-to-market
as-of-now (would-be-close for AtClose, accrued-so-far for PerHeldCycle) — singly
counted, no cum/open double-count (the two are mutually exclusive per cycle). Honours
C9 (a CostRunner<CarryCost> is an ordinary downstream Node), C11 (byte-identity), C16
(factor in aura-std, aura-engine domain-free), C23 (label() carries the rate). The
B-vs-A discriminator (a growing intra-hold open_cost_in_r, invisible to the scalar
net_expectancy_r) is pinned by unit B-proofs + a net_r_equity-bleeds-over-the-hold
integration test. Deferred (each its own #148 cycle): a notional-based carry
(price × rate, reads the price tap), the calendar-aware overnight swap proper
(rollover-boundary timing, 3× Wednesday, long/short asymmetry — deploy-edge realism), and
sweep-path cost. Decision log: #148.
Realization (cost-flag harness scoping, #153). Cost flags are defined only
against an R-evaluator harness (the gross-R → net-R chain). On a non-R harness
(--harness sma/macd, which produce no R) a cost flag is a usage error
(exit 2), not a silent no-op — refuse-don't-guess. Negative cost rates are
likewise rejected (exit 2) with a named diagnostic that identifies the offending
flag. CLI ergonomics only; the cost-model graph and the R math are untouched.
[HISTORY — the built-in --harness selector (its sma/macd non-R examples)
was retired with the demos → blueprint-data (#159, cuts 1b-4); the cost-flag
scoping rule survives on the aura <verb> <blueprint.json> r-sma run path over
examples/r_*.json.]
Reframe (2026-06-23, #117 — exposure → bias, R as the signal-quality unit).
[HISTORY — its R spine survives into the 2026-06-28 contract; its Stage-2 currency /
realistic-broker / register / flat-1R-vs-compounding portions are SUPERSEDED by that
reframe.] The contract was reframed from exposure (a signed fractional
position) to an unsized bias plus R as the signal-quality unit. The
realization notes below describe the pre-reframe code (Exposure, SimBroker,
pip-equity), retained as history and as the ancestor of the current chain:
Exposure { scale } is the ancestor of the unsized bias node, and SimBroker's
pip integral is the ancestor of the R-evaluator (which additionally requires a
stop, since R is stop-defined). The exposure → bias rename, the RiskExecutor /
Sizer / Veto nodes, and the R-evaluator landed in cycle 0065; the
position-event schema (0063, #114) survives as the audit layer. Industry grounding
for this reframe: LEAN / nautilus_trader / backtrader / QSTrader / vectorbt /
zipline (see #117 decision log).
Realization (cycle 0007). [HISTORY — pre-reframe; SimBroker is now legacy per
the 2026-06-28 reframe.] The signal-quality half was realized at the substrate as
two aura-std nodes on the unchanged engine (the engine stays domain-free — it
routes only f64 records). The exposure stream was realized as Exposure { scale }: clamp(signal / scale, -1, +1), one f64 per fired cycle. The
sim-optimal broker was realized as SimBroker { pip_size }: a two-input node
(exposure, price) accumulating prev_exposure · (price − prev_price) / pip_size
and emitting cumulative pip equity — the exposure held into a cycle (decided at
t-1) earns that cycle's return (causal, C2); pip_size is held reference metadata
(C7/C15). An end-to-end harness (SMA-cross → Exposure → SimBroker → recording
sink) produced a recorded pip-equity curve, bit-identical across runs (C1).
Realization (per-instrument pip channel, 2026-06, #22). [HISTORY — pertains to
the legacy SimBroker pip channel.] SimBroker's pip_size is sourced per
instrument from the recorded geometry sidecar (instrument_geometry, over
data-server's symbol_meta), at the ingestion / source edge (never Aura.toml,
never Ctx); the engine stays domain-free. (The original cycle-0022 form was a
Rust-authored vetted floor InstrumentSpec { pip_size } + instrument_spec(symbol);
cycle 0074 removed that floor — see the C15 note — once the sidecar geometry made it
redundant for the real path. Refuse-don't-guess on absent geometry.) The honesty
rule is refuse, don't guess: a real-data run for a symbol with no vetted spec
is a usage error (exit 2). Threaded through the CLI aura run --real path; the
manifest broker label records the looked-up pip.
Realization (position-event schema, cycle 0063, #114). [Survives as the
deploy / reconciliation schema per the 2026-06-28 reframe — no longer a
broker-input research artifact.] A closed PositionAction { Buy, Sell, Close }
enum + the PositionEvent row (event_ts, action, position_id,
instrument_id, unsigned volume; no open_ts; direction is the action) live
beside RunMetrics as a post-run value type (not a per-eval node — C8) — both in
the aura-analysis crate since cycle 0079 (#136); originally aura-engine, see the
C16 cycle-0079 note. action serde-encodes as a bare i64 (Buy=0, Sell=1,
Close=2), the C7 scalar column form, with an out-of-range code rejected on read.
The table stays broker-independent.
Realization (cycle 0065 — Stage-1 R signal quality, #119/#126/#127/#128/#129).
[The R spine here is the live model; the Stage-2 deferral clause at its end is
SUPERSEDED by the 2026-06-28 reframe — there is no Stage-2 currency / compounding
layer; cost is now the cost-model graph and money lives only at the live deploy
edge. "Stage-1" reads as a historical identifier, not a gate half.] Exposure → Bias renamed the unsized strategy output (node + output field); the persisted
exposure_sign_flips metric key (serde alias), the SimBroker exposure input
slot, and the on-disk exposure tap label retain the old name. The
strategy-output param namespace (the Bias instance, its bias.scale knob,
the bias_scale manifest param) was completed in #134. A stop-rule defines 1R:
FixedStop (a triggered-constant primitive) and a vol_stop(length, k)
composition k·√EMA(Δ²) (a composition of Mul / Sqrt primitives).
PositionManagement (aura-std) is the stateful heart: it latches the
entry-cycle stop distance as the immutable R-denominator, marks against the
one-cycle-lagged fill (no look-ahead, C2), and emits a dense 14-column per-cycle
R-record (one row per eval, C8; the trade ledger is the closed_this_cycle
subset, the R-equity is cum_realized_r + unrealized_r). The Sizer (size = risk_budget / stop_distance, flat-1R) was the feed-forward sizing seam, and R is
size-invariant — scaling risk_budget leaves every realized_r unchanged
(pinned by a RED test); per the 2026-06-28 reframe the Sizer and size / volume
are removed from research (size is a deploy concept). summarize_r is a
post-run fold (sibling of summarize, not an in-graph node) → RMetrics (E[R],
SQN, win-rate, profit-factor, max-R-drawdown, conviction terciles, net-of-cost);
per the 2026-06-28 reframe it folds the cost-model's net-R rather than recomputing
a scalar cost. The RiskExecutor ships as a public aura-composites
composite-builder (risk_executor(StopRule, risk_budget)) with a
StopRule{Fixed,Vol} structural axis (C20); per the 2026-06-28 reframe its
Sizer interior and risk_budget arg are dropped / vestigial, the Veto stays a
documented seam, not a runtime node. The layer is operable from the CLI: aura run --harness <sma|macd|r-sma> — a compile-time selector over Rust-authored
harnesses (C9/C17) — folds summarize_r into RunMetrics.r, the r-sma
harness fanning one bias into both SimBroker (legacy pip) and the RiskExecutor
(R); an r_equity tap charts the by-trade R-equity. Composites live in the
dedicated aura-composites crate, so aura-engine's runtime dependency stays
aura-core-only and aura-std is an aura-engine [dev-dependencies] (the graph
stays acyclic).
[HISTORY — the built-in --harness selector was retired with the demos →
blueprint-data (#159, cuts 1b-4); run is now blueprint-driven — aura run <blueprint.json> over examples/r_*.json.]
Realization (2026-07-06 — the risk regime as a structural campaign axis, #210).
The StopRule{Fixed,Vol} structural axis is realized at the campaign-document
level as CampaignDoc.risk: [RiskRegime] (a serializable, content-addressable
mirror of the runtime enum — aura-research, sole variant Vol{length,k}, the
fixed-stop rule additive when needed). It is a kept-separate matrix axis, a
peer of instruments and windows: the executor keys the nominee map by
(strategy, window, regime), so generalize aggregates across instruments
within a regime, never across regimes. Regimes are therefore compared at
presentation, never argmax-selected across — a cross-regime E[R] argmax would
compare R-multiples in different R units (the stop defines 1R), so the legacy
verbs' --stop-length/--stop-k joint stop grid is retired as a campaign
methodology (finding the best regime = the same walk-forward / worst-case-R
validation, run once per regime, and picking the most robust — a comparison, not
a search). Each member manifest stamps its resolved stop (default included),
closing the C18 gap; absent/empty risk = one implicit default regime,
absent-serializing for content-id parity. Two default representations
coexist by design (#217): a dissolved sweep binds no regime at all
(risk: []), late-resolved per member by stop_rule_for_regime at run
time, while walkforward/mc/generalize — whose --stop-length/--stop-k
became optional — bind the default regime eagerly into the campaign
document (risk: [Vol{length:3,k:2.0}]). Same R behaviour either way, but
deliberately different document content ids: a stop-less verb invocation's
document equals its explicit --stop-length 3 --stop-k 2.0 spelling, not
a stop-less sweep's document. Deferred (documented): regime-aware
trace persistence — the trace re-run and cell-key dir naming still assume the
default stop, since CellRealization carries no regime (#212); the core
run/stamp/generalize path is unaffected.
Realization (cycle 0066). SQN is the operational single-number objective for
ranking an r-sma sweep family by signal quality — C12 axis-2 (argmax-metric)
over the C18 family store. metric_cmp (aura-registry) learns the
higher-is-better R metrics sqn, expectancy_r, net_expectancy_r; a member
with no r block sorts last (NEG_INFINITY). aura sweep --strategy r-sma
produces the rankable R family, each member folding summarize_r into
RunMetrics.r. The default grid varies only the signal (fast / slow SMA
lengths), holding the stop and sizing fixed: risk_budget is R-invariant and
bias.scale is sign-only under flat-1R, and — load-bearing — the stop defines
1R, so varying it would change what R means per member and break cross-member
SQN comparability. Each swept member's manifest records the fixed R-defining params
beside the floated knobs (reproducible from its own manifest, C18).
[HISTORY — the built-in --strategy sweep surface was retired with the demos →
blueprint-data (#159, cuts 1b-4); the rankable R sweep now runs as aura sweep <blueprint.json> --axis … over examples/r_sma_open.json.]
Realization (cycle 0067, #130 + #135). #130 (SQN100): RMetrics gains
sqn_normalized = (mean_R/stdev_R)·√(min(n, 100)) — the n-normalized "SQN score"
(SQN_CAP = 100), turnover-robust where raw sqn rewards trade count. It is an
opt-in rank key (Metric::SqnNormalized); raw sqn and the default ranker
stay byte-unchanged, and the field carries #[serde(default)] (C18). Below the cap
(n ≤ 100) it equals raw sqn exactly. #135 (r-sma --trace):
r_sma_sweep_family persists each member's equity / exposure / r_equity under
runs/traces/<n>/<member_key>/ via the same persist_traces_r the single run uses;
per-member --trace is symmetric across all three sweep strategies.
[HISTORY — the built-in --strategy sweep triple (sma / momentum / r-sma) was
retired with the demos → blueprint-data (#159, cuts 1b-4). The per-member
--trace symmetry was NOT carried to the aura sweep <blueprint.json> path — it
was silently dropped at #159/#220 (run_blueprint_sweep never wired persist;
let _ = persist), and #168 makes the surface honest: sweep/walkforward (like
the pre-existing run/mc) now refuse --trace outright. See the CLI---trace
retirement amendment below.]
Realization (cycle 0068, #115 — position-event derive). [The derivation
survives as the deploy / reconciliation layer per the 2026-06-28 reframe — its
"realistic brokers consuming it" goal is retired; money is the live-edge concern.]
derive_position_events(record, instrument_id) -> Vec<PositionEvent> (in
aura-analysis since cycle 0079, #136; originally aura-engine, beside
summarize_r) is the first difference of the executed book: a pure post-run
reduction over the PositionManagement dense record (read positionally as
type-erased Scalars, C7 SoA — no in-graph node, so the hot path stays
domain-free, C14), emitting a Buy / Sell at each open and a Close at each
exit, a reversal (or stop-then-same-cycle reopen) emitting Close then the
opposite open at one event_ts (close first — the C8 ">1 event per instant" case
that forces a derived table, not a per-eval output). The close sizes the
actual book (the closed position's stored volume), never an exposure delta.
instrument_id is a caller-supplied scalar (aura-analysis depends only on
aura-core). A position open at window end emits its open with no synthetic
Close (the table records actual executed events; summarize_r's force-close is
for the R metric only). The r_col ⟷ PM-record lockstep is guard-pinned for
direction too.
C11 — Generalized sources; record-then-replay determinism boundary
Guarantee. A source is anything that produces timestamped scalar streams —
market data (data-server) and non-financial sources (e.g. a news-agent node
emitting a bias) are treated identically. Anything nondeterministic, external,
or slow (LLM/news/web) is materialized into a recorded, timestamped stream
before it enters the engine; backtest replays the recording, live computes
fresh in real time and records it for future backtests. A bias enters as a value
held until the next event (firing policy A).
Forbids. Any live external call inside a backtest replay.
Why. It is the only model compatible with reproducible backtests — LLM calls
are nondeterministic and far too slow per-cycle. Per ~/.claude/CLAUDE.md,
external LLM (IONOS) calls happen only at the recording/live-source edge, with
explicit per-session consent, never inside a sim.
C12 — The atomic sim unit and the four orchestration axes
Guarantee. The atomic unit is (frozen topology + param-set + data-window + RNG-seed) → deterministic run → metrics. Parameters are typed, ranged, runtime
values injected at graph build (no recompile per param-set; the optimizer sees a
generic vector of typed ranges). Raw data is shared read-only across sims via
Arc<[T]> (data-server is built for this). Four axes orchestrate the atomic
unit: (1) param-sweep (grid/random), (2) optimization (argmax metric),
(3) walk-forward (rolling in-sample optimize + out-of-sample test),
(4) Monte-Carlo (N seeded realizations perturbing input). MC = sweep over
seeds; each realization is itself deterministic given its seed.
Realization (cycle 0041). The eager ingestion of cycle 0011 is no longer the
only path. Harness::run is re-typed to a producer seam — a Source trait
(peek/next, object-safe) the k-way merge drives — and a streaming
M1FieldSource (aura-ingest) pulls a data-server window lazily, borrowing
one Arc<[M1Parsed]> chunk per pull (zero-copy within a source) and
decoding each Scalar on demand: the source ring is resident O(one chunk),
not O(window length) — the measured resident_records() predicate, a per-source
bound, not whole-process RSS. (Data-server's FileCache retains each window's
parsed chunks read-only for the pass — ~56 B/record — so process residency is
O(records-touched); that is the replay-many sharing C12 wants — one window
parsed once across a sweep family — not a leak. The single-pass cost is tracked as
#95.) The eager load_m1_window/close_stream path is kept for bounded loads (the
gap closes by a streaming path existing, not by deleting the eager one). Realized
(2026-06-29, family cycles shipped). cross-sim Arc<[T]> sharing — one window shared
zero-copy across many disjoint sweep sims — is now in force: the sweep / Monte-Carlo /
walk-forward families (axes 2–4 above) build their members over one shared
Arc<DataServer> (one FileCache), so a window is parsed once and every member's
M1FieldSource borrows the same cached Arc<[M1Parsed]> chunks
(crates/aura-ingest/src/lib.rs:316). The single-pass parse cost stays tracked as #95.
Realization (cycles 0028, 0049). Axis 1 (param-sweep) is built. GridSpace
(0028) enumerates a cartesian lattice over discrete per-slot value-lists;
RandomSpace (0049) draws N seeded points over typed continuous ParamRanges
(I64 inclusive [lo,hi], F64 half-open [lo,hi)), validated against the
param-space before any run. Both implement the Space trait the disjoint
sweep / run_indexed core is generic over, so either enumeration runs through
one execution path (C1: results in enumeration order, not completion order). The
seeded sampler reuses the bit-stable SplitMix64 as a code-path-disjoint
instance from the data-edge seed RNG (the source-seam firewall, #52/#71: they
share only the u64 type, never a path).
Forbids. Baking a specific search strategy (Bayesian/genetic) into the
primitive — those are pluggable policies atop the atomic unit; recompiling on a
param change.
Why. A stable primitive + orchestration axes keeps "wahnsinnig schnell"
(embarrassingly parallel across the unit) cleanly separated from search policy.
Seed-as-input reconciles Monte-Carlo with C1. The "frozen topology" of the
atomic unit is one harness instance, selected by the harness's structural
axes (C20); the structural experiment matrix is the outer orchestration over
this dimension, the tuning sweep the inner (C19/C20).
C13 — Hot-reload is authoring-only; deploy is frozen
Guarantee. A node/strategy is authored as a native Rust cdylib,
hot-reloaded during the authoring loop (Rust-ABI; host and node built with the
same toolchain). The live/deploy bot is a statically-linked, versioned, frozen
artifact.
Forbids. Hot-swapping a running live bot; loading third-party / foreign-
toolchain plugins.
Why. Hot-reload makes the research loop fast; a live artifact must be frozen
and reproducible (audit trail: this bot = this commit). A sweep pays no
hot-reload tax — params are runtime data (C12), so the cdylib loads once.
Realization (cycle 0102 — the load boundary; per-invocation reload). The
authoring-loop half is realized: a project is an external cdylib crate loaded
per invocation through a two-tier #[repr(C)] descriptor (AURA_PROJECT,
aura-core::project) — a C-ABI stamp prefix (rustc version + aura-core
version, baked per consuming build) validated before any Rust-ABI field is
touched, then the vocabulary resolver + enumerable type-id list behind the
stamp gate. "Hot-reload" reads, in v1, as per-invocation load of the
freshest build: the author (Claude) runs cargo build, the next aura
invocation locates the artifact via cargo metadata (debug default,
--release opt-in) and loads it load-and-hold (leaked, never unloaded).
Scope boundary: load-and-hold is trivially sound only because the CLI is a
one-shot process — a future long-running host (the open local-server thread,
C22) must re-solve reload (host restart or subprocess isolation), never
in-process unload. Mismatch of either stamp refuses (exit 1) naming both
sides; the project vocabulary is charter-checked at load (::-namespaced ids,
no duplicates against std, list↔resolver cross-check) — the invariant-9
data-plane discipline of the C24 enforcement-shift note, now enforced at the
one seam.
C14 — Headless core, two faces
Guarantee. The engine is a UI-agnostic library. Two faces sit on it: a
programmatic/CLI face (the primary surface for the LLM and automation —
author a node, run a sim/sweep, emit structured metrics) and a visual face
for human exploration. Visualization is only a downstream consumer node on the
streams.
Forbids. Any UI/pixel knowledge inside the engine.
Why. The LLM drives programmatically, the human visually; a headless core
serves both. The visual face is the playground (C22) — a web frontend
served from disk-persisted traces (revised 2026-06, issue #101: the engine
writes recorded traces to disk, a browser charts them; supersedes the earlier
egui-native / in-process-zero-copy-from-SoA direction). It is staged after the
runnable substrate but is core to aura's identity, not optional. The pivot keeps
this contract's core intact — and arguably tightens it: a browser reading a
serialized trace file is a stricter "no UI knowledge in the engine" than egui
reaching zero-copy into the live SoA columns.
Realization (cycles 0098/0099, #175 — the CLI meets GNU/clig.dev conventions
via clap). The programmatic/CLI face's argument surface moved from a hand-rolled
argv parser to a clap derive parser (admitted under the C16 per-case dependency
policy — research-side, a dev-loop/compile tax, never a frozen-artifact tax;
invariant 8 untouched, the change is confined to aura-cli, a leaf binary the
frozen deploy artifact cannot pick up). One declarative source now yields scoped
aura <sub> --help, --version/-V, a per-flag Options section, and GNU
--flag=value / -- / long-option abbreviation. The exit-code partition is a
durable part of the automation contract, so a caller can branch on the failure
class without parsing stderr: exit 0 = success; exit 2 = usage error — a
command-line fault (clap parse errors + aura's post-parse argument-structure
validations, including the content of an argv-named blueprint file, which is
itself an argument); exit 1 = runtime failure — a well-formed command whose
needed environment / recorded state is missing, or bad piped stdin data. The four
dual-grammar subcommands (run/sweep/walkforward/mc) keep both grammars under one
token via an optional [blueprint] positional + a post-parse is_file()
dispatch; the execution layer is unchanged (arg-plumbing via thin *_from
adapters). Error-message casing is normalized to the clap house style —
every hand-rolled usage line reads Usage: aura <verb> … (#179, cycle 0101);
refusal diagnostics stay unprefixed (diagnostics are not usage lines). The
machine-first help surface (JSON/manifest help, stdin op-scripts) stays on the
#157/C21 track, not this cycle (settled as human/GNU convention compliance).
C15 — Resampling-as-node; sessions/calendars
Guarantee. A resampler is a node (finer stream → coarser bar stream),
clock-sensitive, emitting a completed bar only at the boundary (C2). Calendars
and instrument specs are metadata (non-scalar, beside the hot path); session
context is exposed as scalar streams via a SessionNode (bars_since_open: i64, in_session: bool, session_open_ts: timestamp). "3rd 15m candle after
session open" is then a plain node checking bars_since_open == 3.
Forbids. Streaming the calendar; special-casing session logic outside the
stream model.
Why. Keeps the line consistent — everything a signal needs arrives as a
stream; reference data feeds source/session nodes from beside the hot path.
Realization (instrument specs, 2026-06, #22 → #124). The "instrument specs are
metadata" half of this contract is realized by the recorded geometry sidecar:
instrument_geometry(server, symbol), over data-server's symbol_meta, returns
neutral broker-agnostic InstrumentGeometry (the raw provider JSON never enters this
repo) — non-scalar reference data held beside the hot path, keyed by symbol, feeding
the sim-optimal broker's pip divisor (C10). The real-path pip is sourced from
InstrumentGeometry.pip_size; refuse-don't-guess on absent geometry. History:
cycles 0022/0063 first carried a Rust-authored vetted floor (InstrumentSpec — a
single pip_size, later a six-field deploy-grade row instrument_id,
contract_size, pip_value_per_lot, min_lot, lot_step, quote_currency, plus
tick_size/digits), cross-checked against the sidecar geometry. Cycle 0074
removed that floor: with the sidecar geometry supplying the real-path pip, the
authored table was redundant, so InstrumentSpec / instrument_spec / the vetted
list were deleted. The speculative deploy-grade fields and the override/floor tiers
of the #124 hierarchy (tier 1 authored override, tier 3 authored floor) are
deferred until a consumer — the cost model's currency→R normalization (C10), or
the live deploy edge (real broker) — needs them, read from the sidecar geometry then. The quote_currency runtime-type transition is likewise
deferred to that consumer; #124 collapses to recorded sidecar geometry → refuse
in the meantime.
Realization (SessionNode — bars_since_open only, by design; #154). The
session-context half of this contract ships one scalar stream, not the three the
Guarantee lists: Session (crates/aura-std/src/session.rs) emits only
bars_since_open: i64 (tz-aware, DST-correct, baked Frankfurt open). This is a deliberate
narrowing pinned in the node's own contract — "there is no separate in-session bool gate,
bars_since_open alone is the contract": a downstream EqConst(== N) gate subsumes the
in_session: bool stream (pre-open instants give <= 0, which never match), and
session_open_ts: timestamp has no consumer. The two streams the Guarantee names remain
the original design intent, deferred until a consumer needs them (default-simple;
forward-queued as #154). The stream model is untouched — session context is still exposed
as scalar streams fed from beside the hot path.
C16 — Engine / project separation; three-tier node reuse
Guarantee. aura is the reusable engine; each research project is a
separate external repo that depends on aura via cargo (the game-engine / game
split). Node reuse is cargo-native, in three tiers: aura-std (universal
blocks, ship with the engine) / shared node crates (cross-project-reusable,
their own repos, pulled as cargo git deps) / project-local nodes/
(experimental, project-specific). A reusable node is an rlib dependency; the
hot-reload unit stays the project-side cdylib that composes it (consistent
with C13). Concretely a project is a Rust crate — a cdylib library of node /
strategy / experiment blueprints — plus a static Aura.toml (project context,
paths only: data archive root, runs dir — cycle 0102). During
research the aura host loads and runs it (C13 hot-reload); for deploy the
chosen strategy + broker freeze into a standalone binary. A project is
therefore always a Rust program built on the engine. Beyond the node-reuse
tiers, the engine workspace also carries non-node crates — aura-engine (the
run loop), aura-cli (the aura binary), and aura-ingest (the
data-source ingestion edge, cycle 0011, where the data-server external tree
enters). Dependency policy (amended 2026-06-10, cycle 0029). Dependencies
are admitted by deliberate, per-case review — what a crate pulls in weighed
against what it buys — with particular scrutiny for anything that enters the
frozen deploy artifact (C13: this bot = this commit). Well-established
standard crates (serde, rayon, …) pass that review and are used wherever
they do the job, including in the bot. There is no blanket zero-dependency
commitment and no blanket admission; hand-rolling what a vetted standard
crate already does is the anti-pattern, not the dependency. This strikes the
original "zero-external-dependency by commitment" clause and the
"aura-ingest is the sole external-dependency firewall" framing; aura-ingest
remains the data-source ingestion edge, no longer a dependency wall.
Forbids. Project-specific signals in the aura repo (it keeps at most
example/fixture nodes under examples/ for its own tests); a multi-project
manager inside aura; a bespoke node registry/marketplace (cargo + Gitea is the
package mechanism). (Dependency admission is governed by the per-case policy
above, not a blanket ban.)
Why. The engine/game split keeps the engine sharp and reusable while each
project versions its own research with its own forward-queue. Promotion
(local → shared → std) is the ordinary Rust reuse gradient, no new mechanism.
Realization (cycle 0079, #136 — the analysis leaf becomes its own crate). The
trading-domain analysis layer — the post-run reductions that are pure functions of a
run's recorded data (RunMetrics, RMetrics, the R reduction summarize_r /
r_metrics_from_rs, the position-event table PositionEvent / PositionAction /
derive_position_events, and the multiple-comparison hurdle math inv_norm_cdf /
expected_max_of_normals) — is extracted out of aura-engine's report module into a
dedicated aura-analysis crate (deps: aura-core + serde only; serde_json is a
dev-dependency, test-only). This sharpens the engine↔domain seam: the run loop's crate no
longer defines the trading metrics — it pub use-re-exports them for source
compatibility this cycle, and still hosts the trace-coupled summarize, RunReport,
RunManifest, and the columnar trace utils. The non-node engine-workspace crates are now
aura-engine (run loop), aura-cli (aura binary), aura-ingest (ingestion edge),
aura-composites (composite-builder convenience layer), and aura-analysis (post-run
domain reductions + selection provenance). Behaviour-preserving (C1): every serde shape
is byte-identical (C18 goldens green).
Realization (cycle 0080, #136 — engine-side extraction complete). The last
trading-domain types still defined in aura-engine's report module — the
selection-provenance types FamilySelection / SelectionMode — also move to
aura-analysis (the only non-cyclic home: RunManifest.selection needs the type and the
engine already depends on aura-analysis; the registry/CLI reach them unchanged via the
re-export). Behaviour-preserving (C1; suite 665/0). Settled (2026-06-27, user-ratified
in the #136 thread): the aura-registry Metric / metric_cmp / deflation vocabulary
stays in the registry by design — the registry is the trading selection layer, not
the domain-free engine, and its deflation statistics branch irreducibly on metric
identity (R-bootstrap vs. total_pips dispersion floor); forcing genericity would be a
one-implementor abstraction. Deferred to the World / C21 layer (explicitly not #136):
making RunReport generic over a metric type and turning the orchestration/registry layer
into a reusable domain-agnostic substrate — until then RunReport / RunManifest /
summarize stay trace-coupled in the engine.
Realization (cycle 0107, #198 — the campaign-execution leaf). The non-node engine
workspace gains aura-campaign: campaign-execution semantics (cell enumeration,
preflight, gate evaluation, winner selection, walk-forward rolling, realization assembly —
see C18) as a leaf library crate whose deps deliberately exclude aura-ingest /
aura-std / aura-composites; harness construction and data binding enter only through
the one-method MemberRunner seam, so consumers (the aura CLI today; the playground and
tests tomorrow) bind their own runners while the semantics live here once. It is
explicitly NOT C21's World (a project-side program): it realizes one campaign document
and owns no topology, no data sources, no UI.
C17 — Authoring surface
Guarantee. All logic — nodes, strategies, and experiments/harnesses —
is authored in native Rust through Claude Code + the skills pipeline: the
human describes, Claude writes the Rust, builds it, runs it via the aura CLI,
and reports metrics. Declarative config (Aura.toml) carries only static
project context (paths only: data archive root, runs dir — cycle 0102),
never logic. aura ships no embedded coding-LLM. IONOS LLMs are used only as a
runtime data source (news-agent bias, C11), gated by per-session consent,
never in the code path.
Forbids. An in-app LLM chat that generates node code inside aura; using
IONOS (weaker models) as the authoring brain.
Why. LLMs author Rust well in Claude Code — that is the fix to RustAst's
failure; making weaker models the coding brain reintroduces the very problem.
Keeps aura's scope an engine + playground, not an LLM-IDE.
Refinement (2026-06-29, #109 resolved — node logic is Rust; topology is data,
C24). "All logic … including experiments/harnesses … is Rust" governs
computation — a node's math, a meta-program's control flow — and that stays
native Rust (the RustAst lesson, sharpened: a small LLM cannot reliably apply a
computational DSL, and a strong one does not need one). It does not bind
topology — which node feeds which, the structural axes, the param-space — to
Rust source. Topology is a serializable data value the World owns (C24): a
non-Turing static DAG over the closed, compiled-in node vocabulary (C8/C16),
carrying no computation. This is not the RustAst trap re-opened: that trap is a
DSL the author must apply; a topology-data blueprint is machine-generated / owned
and applied by no one. The no-DSL guard therefore stands unbroken — it forbids a
computational experiment / strategy language, not a static topology-data format.
The experiment-matrix generator may stay Rust (C20); its output is
topology-data.
C18 — Project management: one repo = one project, plus a run registry
Guarantee. Management has two planes. (1) Code & forward-queue: git
(commit = identity; the frozen bot is a commit) + Gitea (ideas/hypotheses as
the forward-queue, a research thrust = a milestone, the
idea → experimental → validated → deployed label gradient). (2) Experiments
& results: an Aura-native run registry — one record per run = a manifest
(node-commit + params + data-window + seed + broker profile) + metrics,
queryable, with lineage (composite ← signals; run ← inputs). Determinism
(C1/C12) makes a run reproducible from its tiny manifest, so the registry stores
manifests + metrics and re-derives full results on demand. Depth: structured
(promotion/status, lineage, run-diff).
Forbids. Storing results not reproducible from a recorded manifest;
duplicating git/Gitea inside aura; a multi-project workspace manager.
Why. Comparing experiments over time is the heart of the research loop and
has no home in git/Gitea; determinism makes a structured registry cheap.
Sequencing: the walking skeleton emits a manifest + metrics per run from day
one; the registry/index is a later milestone over manifests that already exist.
Realization (cycle 0029 — the flat run registry). The experiments-&-results
plane shipped as aura-registry: an append-only JSONL store (runs/runs.jsonl),
one serde_json RunReport (RunManifest{commit, params, window, seed, broker} +
RunMetrics) per line, with a typed read-path (load) and best-first ranking
(rank_by/optimize). C9 holds — the registry depends on aura-engine, never
the reverse.
Realization (cycle 0045 — lineage as related records, #70). The lineage
depth is now realized as a family store: a sweep / Monte-Carlo / walk-forward
run (the C12 axes) is persisted as a set of related records — each a
FamilyRunRecord (a RunReport stamped with its family + run + kind +
ordinal) — in a sibling JSONL (families.jsonl), leaving the flat runs.jsonl
path and its append/load/rank_by/optimize API byte-for-byte unchanged.
the user-facing family_id = "{family}-{run}" handle is derived from the
stored family name plus a per-name run index (assigned as a numeric max+1 —
not a content hash; re-running the same family mints a fresh id). group_families is the round-trip that re-derives a family
from the stored links (re-listable / rankable as a unit — C21). The manifest is
the re-derivation recipe (#71): no input-stream blob / path / payload enters a
record, and a member's window is producer-supplied via
Source::bounds()/window_of (eager or streamed → byte-identical lineage),
never a materialized-Vec scan at the call site. CLI surface: aura mc,
aura runs families, aura runs family <id> [rank <metric>]; aura sweep /
walkforward / mc persist via append_family with an optional --name.
Realization (cycle 0078 — cross-instrument family + instrument lineage, #146).
The comparison axis (C12) is realized as a FamilyKind::CrossInstrument family:
aura generalize runs one candidate across an instrument list and persists the M
per-instrument runs via append_family, each member self-identifying through a new
first-class RunManifest.instrument lineage field (serde-widened with
skip_serializing_if, so legacy lines and every non-cross-instrument path stay
byte-identical — C14/C23). The cross-instrument generalization score (worst-case R
floor + sign-agreement + per-instrument breakdown) is a recomputable aggregate
over those members, not a persisted family-level record — distinct from #144/#145's
per-winner selection annotation on RunManifest.selection.
Deferred (Non-goals): replay-dedup (content-addressed identity shipped — #158,
cycle 0094, Realization below); the "run-diff"
depth and ranking families against each other (cross-family, vs. within-family);
and a live producer for the flat runs.jsonl standalone-run path — no CLI command
writes it (sweep/walkforward persist to the family store; aura run does not
persist). Resolved (#73, 2026-06): retired — aura runs list / rank dropped;
families (C21) subsume standalone over-time comparison. The aura-registry flat
lib API is retained: rank_by/optimize keep live consumers (optimize backs
walk-forward's in-sample step, rank_by backs runs family … rank); append/
load (the flat-store half) remain public API with no in-tree caller after
this retire — tested, available to external consumers, a latent dead-code surface
a later sweep may revisit. Unknown-id contract (ratified, Runway fieldtest
2026-06). aura runs family <id> treats an unknown-but-well-formed id as an
empty family (prints nothing, exit 0) — the same treat-as-empty discipline as
Registry::load reading a missing store as Ok(empty), and deliberately
distinct from the retired list/rank exit-2, which is argv-shape rejection
before any store access, not a found-nothing lookup. Tightening to a non-zero
no such family <id> exit (typo-safety) is an available future UX choice, not a
current contract.
Refinement (2026-06-29 — a generated run's topology lives in / is addressed by
its manifest, C24). The manifest identifies a run's topology today only via
commit (this engine + a hand-coded harness) — sufficient while harnesses are a
finite hand-coded menu. Once the World generates or structurally searches
topologies (C21/C24), commit no longer identifies the graph, so the manifest must
carry or content-address the topology-data to stay the re-derivation recipe
(C18's "reproducible from a recorded manifest"). This pulls the previously-deferred
content-addressed identity non-goal forward as the natural home for a generated
topology's identity. The format and the carrier are C24's design; recorded here as
the reproduction requirement it must meet.
Realization (2026-07-01, cycle 0094 — content-addressed reproduction of a generated
run, #158). A blueprint sweep's topology is now content-addressed: the canonical
blueprint_to_json bytes are stored once, keyed by the topology_hash the manifest
already carries, in a dumb bytes-by-key store beside the run registry
(runs/blueprints/<hash>.json — Registry::put_blueprint/get_blueprint, aura-registry;
no sha2, no parse — the caller owns the hash, and reproduction's bit-identical compare is
the integrity check). aura reproduce <family-id> re-derives every persisted member: load
the member's blueprint by its topology_hash, reconstruct the sweep point from the
recorded params, re-run through the same run_blueprint_member the live sweep uses
(so bit-identity is by construction, C1), and compare metrics — refuse-don't-guess on an
unknown id / missing stored blueprint (exit 2), DIVERGED → exit 1. The manifest + the
content-addressed store + the commit are the complete re-derivation recipe (C18). One
blueprint is stored per family (all members share the signal topology_hash — C11/C12
dedup). aura graph introspect --content-id exposes the same id for an op-script, via the
one content_id primitive topology_hash also uses (acc 1); a Tier-1 optional the
blueprint does not use leaves the id byte-stable (acc 3, composing #156/#164).
serde_json/float_roundtrip is enabled so stored f64 metrics round-trip exactly through
families.jsonl — the precondition for a bit-identical compare (C1). Signal-only this
cycle: the id covers the signal blueprint; the fixed r-sma scaffolding stays
commit-identified (it is not yet blueprint-data, C24). Whole-harness / structural-axis
content-addressing remains deferred. The debug-name-in-id question was settled additively
(cycle 0104, #171): the identity id — the canonical form with every non-load-bearing
debug symbol blanked (invariant 11), hashed through the same content_id primitive and
surfaced as aura graph introspect --identity-id beside --content-id (combinable) —
makes same-topology blueprints comparable across authoring paths, while the byte-exact
topology_hash keeps every role untouched (introspection-only: no manifest field, no
store key until a dedup consumer exists). Reproduction is proven on
synthetic (deterministic) data; recorded-dataset reproduction rides the DataServer seam
(#124). Cycle 0095 (#170): aura reproduce now also spans FamilyKind::MonteCarlo —
branching on the family kind, it reconstructs each member's seed-driven synthetic walk from
the recorded manifest.seed (the Sweep arm unchanged), so an aura mc family re-derives
bit-identically through the same run_blueprint_member path. Cycle 0097 (#173): reproduce
spans the third variant, FamilyKind::WalkForward — the same branch rebuilds each OOS member's
windowed slice from manifest.window (winner params via the shared manifest→cells recovery),
so an aura walkforward family re-derives bit-identically too. All three family kinds now
persist and reproduce through the one shared topology_hash+put_blueprint hook.
Realization (cycle 0106, #189 — research-artifact document stores). The registry's
content-addressed store family grew two siblings beside blueprints/: processes/ and
campaigns/ hold the two research-artifact document types shipped by the #188 role-model
pass — the process document (role 5: a named validation/eval methodology, a closed std
stage vocabulary wrapping shipped primitives) and the campaign document (role 6b:
persisted experiment intent — instruments × windows × strategy refs by content/identity id ×
param axes × process ref (content-id-only) × data-level presentation). Documents are
canonical JSON (format_version envelope, omit-defaults, no trailing newline) keyed by the
shared content-id primitive, now library-hosted (aura_research::content_id_of;
aura-cli's content_id/topology_hash delegate byte-identically — the id goldens pin the
move). Unlike put_blueprint (caller owns the hash), the document puts self-key from
canonical bytes; gets are Ok(None) treat-as-empty. The referential tier
(validate_campaign_refs) resolves process/strategy refs against the stores (identity refs
by store scan) and checks each campaign axis — name AND declared ScalarKind, the axis
carries its kind once — against the referenced blueprint's param_space. Campaign P1
control constructs (axes/gates/ladders per #188) carried intent only in this cycle: no
executor existed yet — executor need was re-tested against the intent-persistence
diagnosis (#189 triage, decided item 6; the cycle-0106 fieldtest F7 verdict was that
evidence), and the v1 executor shipped the next cycle (below).
Realization (cycle 0107, #198/#196 — the campaign executor and its realization store).
aura campaign run <file|content-id> executes a campaign (a file is register-then-run
sugar; the content id is canonical): zero-fault referential gate, then the process
pipeline — v1 executable shape std::sweep [std::gate]* [std::walk_forward]?
(std::monte_carlo/std::generalize refused loudly at preflight in this cycle; they
execute since cycle 0108, below) — runs once per (strategy, instrument, window) cell in
doc order. Execution
semantics live in the aura-campaign library crate (#198 home decision: reachable
beyond the CLI; NOT C21's project-side World): grid odometer over the campaign axes,
members through the engine sweep over a ListSpace (explicit point set beside
GridSpace/RandomSpace — a gate's survivor subset has no cartesian structure),
per-member gates via the 14-name member_metric roster (an R-predicate over a missing R
block fails conservatively), walk-forward re-rolled in the doc's epoch-ms unit
(WindowRoller; IS windows search ONLY the survivor points; OOS winner reports carry
manifest.selection), deflation nulls seeded from the doc's seed (C1: realization is a
pure function of doc + stores + data). Harness/data binding stays consumer-side behind the
one-method MemberRunner seam — the CLI binds the shipped loaded-blueprint reduce
convention with unique suffix-join of raw axis names onto the wrapped param_space. The
registry grew the campaign_runs.jsonl JSONL sibling (beside runs.jsonl /
families.jsonl): one thin CampaignRunRecord per run — campaign/process ids, seed, and
per-cell realized stage prefixes linking family ids, gate survivor ordinals, and sweep
selections — over untouched family records, run-counted per campaign id. Zero survivors
truncate a cell's realized prefix and exit 0 (a null result is a valid research result);
emit is honored (family_table/selection_report lines); persist_taps was deferred
LOUDLY on stderr in this cycle (the wiring shipped in cycle 0109, below). The blueprint on-ramp (#196) closes
the F5 authoring gap: aura graph register (store put keyed by content id == topology
hash), aura graph introspect --params (the RAW param_space namespace campaign axes
are validated against), and a blueprint-file mode on --content-id. The
std::walk_forward vocabulary was corrected to machinery-true fields
(in_sample_ms/out_of_sample_ms/step_ms/mode — WindowRoller's three lengths and
both RollModes; the shipped folds slot mapped to nothing the machinery accepts), with
a new ZeroWalkForwardLength intrinsic fault; wf-bearing process docs get new content ids
by design (the 0106 fieldtest corpus stays as the historical record). Known debt:
metric-roster triplication (still hand-maintained, but drift from the shipped
aura-analysis types is now test-caught by a cross-crate guard, #190;
single-source removal waits on #147), deflation-constant duplication (#199).
Realization (cycle 0108, #200 — the annotator stages execute). The v2 executable
shape is std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]? — an ordered optional annotator suffix, each at most once,
std::generalize strictly last; the executor preflight refuses what the intrinsic tier
deliberately admits ([sweep, mc, walk_forward] is intrinsically valid — the tier
boundary is test-pinned on both sides), plus three new static guards
(single-instrument generalize, non-R generalize metric via the registry's
check_r_metric, zero mc resamples/block_len). Both annotators are terminal
(unanimous #200 triage): nothing flows out of them; filtering stays the gate's monopoly.
std::monte_carlo bootstraps the stage's incoming R-evidence with one semantics,
input-shaped by position — after a walk_forward, ONE r_bootstrap over the wf family's
pooled per-window OOS trade_rs in roll order (PooledOos); after sweep/gates, one
r_bootstrap per surviving member's fresh in-memory series (PerSurvivor, ordinals into
the population family; a zero-trade member records the engine's defined all-zero
degenerate) — seeded from the campaign doc's seed (C1; trade_rs is #[serde(skip)],
so annotators run in-executor or not at all). std::generalize executes at campaign
scope: after all cells, per (strategy, window) the per-cell nominees (last wf
window's OOS report, else the sweep winner; none on gate truncation) across instruments
feed the shipped generalization() when ≥ 2 exist — divergent per-instrument winners
are exposed via their params, never averaged away; a shortfall is recorded, not computed
around. Realization: StageRealization.bootstrap (StageBootstrap::PerSurvivor | PooledOos) and CampaignRunRecord.generalizations (CampaignGeneralization keyed
strategy × window with winners/missing), both serde-default sparse — pre-0108
campaign_runs.jsonl lines parse unchanged (C14/C23). No document content id moved
(introspection doc-strings dropped "in v1" only). Noted debt: the mc arm detects the wf
family by the stringly block == "std::walk_forward" literal (a pre-existing lockstep
pattern with the realization's block strings).
Realization (cycle 0109, #201 — persist_taps wired). Campaign presentation persists
traces: the tap namespace is a closed vocabulary of the wrap convention's four sink
names (equity/exposure/r_equity/net_r_equity; aura_research::tap_vocabulary,
intrinsic DocFault::UnknownTap — the escalation for a new observable is a new
vocabulary entry or an authored blueprint sink, never an open node-path namespace).
Scope is the per-cell nominee only: after the pipeline settles, the CLI re-runs each
nominee once in non-reduce mode (all four channels drained; windowed to the nominee
manifest's own ns bounds) and asserts metrics equality against the recorded nominee —
the C1 drift alarm, hard refusal on divergence (the reproduce precedent, enforced).
Traces land in the existing TraceStore as
traces/{campaign8}-{run}/{strategy8}-{instrument}-w{n}/{tap}.json
(ensure_name_free(Family) once; window ordinal doc-derived), chartable by the unchanged
viewer. The record carries ONE sparse pointer, CampaignRunRecord.trace_name
(Some("{campaign8}-{run}") iff the doc requests taps — the claim-sentinel contract:
execute claims, append_campaign_run composes the name via the single-sourced
derive_trace_name, execute mirrors it onto the returned copy). Loud lines replace
the retired deferral: per-cell no-nominee skip, per-run unproducible-tap skip
(net_r_equity needs a cost leg; the campaign runner wires none), one summary.
aura-campaign stays trace-agnostic (the MemberRunner seam is unchanged; the stamp is a
pure name derivation). Noted debt: aura chart over the campaign family ROOT (cells
spanning instruments) is untested/semantically undefined — only per-cell read-back is
pinned.
Guarantee. Construction is a distinct phase, recursive at every level. Each
node type has a factory params → sized concrete node (e.g. SMA(length)
sizes its ring buffer). A blueprint is the param-generic, input-role-generic
graph-as-data produced by running a Rust builder (C9); it carries free numeric
params (declared ranges) and free input roles. The bootstrap binds
(blueprint + param-set + data bindings + seed) into a concrete, frozen
instance — buffers sized, topology fixed. This is precisely the "wiring /
graph build" that C7 ("sized at wiring", "topology frozen per sim") and C12
("params injected at graph build") already reference. The same machinery applies
recursively up to the harness (C20). A sweep builds many instances from one
blueprint; instances are disjoint (C1).
Forbids. Params that change topology (a topology change is a different
blueprint — Fork A, C7 "frozen"); resizing buffers after bootstrap; running a
sim against an un-bootstrapped blueprint.
Why. Separating the param-generic blueprint from the param-bound instance is
what makes one strategy reusable across a whole sweep and lets the optimizer
mutate "the 20" by rebuilding an instance (cheap; no recompile, C12) instead
of rewriting code. Naming the build phase makes the implicit "wiring" of C7/C12
explicit.
Refinement (Construction-layer milestone, 2026-06-05). This binding is a
compilation: the param-generic, named blueprint (source) is lowered to a flat,
type-erased flat graph (C7) wired by raw index, not by name
(Edge { from, to, slot, from_field }): composite boundaries dissolve entirely,
and field / role names are demoted to non-load-bearing debug symbols (as
FieldSpec.name already is). "No recompile" above means no Rust /
cdylib rebuild (C12/C13: the cdylib loads once); re-deriving an instance per
param-set is a cheap graph re-compilation, not a code recompile. Naming the
build phase a compilation makes its successor explicit: the flat graph is the target
of behaviour-preserving optimisation (C23).
Realization (cycle 0016 — param-set injection). The bootstrap now binds an
injected param-set, realizing C12's "params injected at graph build (the optimizer
sees a generic vector of typed ranges)" and C19's "factory params → sized node"
literally: a blueprint leaf is value-empty — BlueprintNode::Leaf holds a
LeafFactory { name, params, build } recipe, not a built node — and the value lives
only in the injected vector (no baked default), so the blueprint stays a pure
param-generic recipe. (Renamed in cycle 0024: BlueprintNode::Primitive holds a
PrimitiveBuilder { name, schema, build } — the recipe now carries the full
signature, see the C8 0024 realization; bootstrap_with_params/compile_with_params
moved onto Composite when struct Blueprint collapsed into it, see the C19 0024
realization.) bootstrap_with_params(Vec<Scalar>) /
compile_with_params build each leaf through its own constructor (the single
sizing/validation gate) from its kind-checked slice while lowering (build-then-
wire), consuming the vector slot-by-slot in the same depth-first walk
param_space() projects — so the two share one traversal (subsuming the #34 dual-
traversal hazard) and the value reaches the node at the slot the sweep enumerates.
Arity is checked up front (param_space().len()); a wrong-kind or wrong-length
vector is a typed CompileError::{ParamKindMismatch, ParamArity} (the typed-value
check C8 deferred). The lowering/edge/source rewrite is structurally unchanged, so
the flat graph stays bit-identical for a given point (C23, the correctness
invariant). The value domain (e.g. length ≥ 1) stays the constructor's own
assert; the search-range is still the run's (#32/C20). One value-empty leaf
detail for C22: the blueprint view (pre-run, param-generic) labels a leaf by bare
type (PrimitiveBuilder::label, was LeafFactory::label, → [SMA]) — the
value-bearing SMA(2) of C8's render-
label refinement now appears only in the compiled view (built nodes, Node::label).
Realization (cycle 0017 — blueprint render = main graph + definitions). The
aura graph blueprint view (C9 graph-as-data, #13) renders the authored
structure as a program with subroutines: a flat main graph wiring the
harness with each composite shown as a single opaque node [name], plus a
where: section that defines each distinct composite type once (its
interior with named input-entry nodes and outputs folded onto their producers as
name := … bindings (render refined through cycles 0019–0022; originally
[in:k]/[out] port markers); deduped by name(), collected
recursively so nested composites are opaque nodes with their own definitions). This
supersedes #13's original cluster-box model and is the durable split this view
realizes: blueprint = source (composites as named subroutines, body once) vs.
compiled = inlined machine form (C23, boundary dissolved). The substantive cause
for retiring cluster boxes is a real renderer defect — ascii-dag 0.9.1's subgraph
level-centering rounds sibling x-positions with /2, overlapping wide sibling
labels (width/parity-sensitive, no config/padding dodge surviving unequal-width
siblings); the flat layout is collision-free, and both views now build flat
graphs only. The model also scales (blueprint size tracks top-level wiring, not
inlined node count) and removes #13's nested-composite unimplemented! (the
definitions pass recurses). The interactive enter/focus counterpart (a composite
collapsed to a navigable node) is the playground's, parked as a separate concern
(#37); the static CLI keeps the all-at-once definitions form (#38).
Realization (cycle 0024 — the root is the fully-bound composite; the flat graph is
a named type). struct Blueprint is deleted: the root graph IS a Composite,
and compile_with_params/bootstrap_with_params/param_space are its methods.
What distinguished the root — its bound data sources — is now a property of its input
roles: Role carries source: Option<ScalarKind> (None = an open interior port,
wired by the enclosing graph; Some(kind) = a bound ingestion feed). A composite is
runnable iff every root role is bound (C3: sources bind at ingestion only); an
open root role is a compile-time CompileError::UnboundRootRole. So the "main graph"
is no longer a separate kind — only the composite all of whose roles are
source-bound, governed by the same conditions as any other node. Compilation now
targets a named type: compile validates structurally pre-build (via
signature(), no node constructed — an ill-typed wiring is caught before any build
closure fires) and emits FlatGraph { nodes, signatures, sources, edges } — the
C23 flat graph, now first-class — which Harness::bootstrap consumes (kinds/firing
from the carried signatures, buffer depth from lookbacks()). The per-flat-node
signature travels beside the node, so bootstrap reads it without a built-node
schema() call (which no longer exists, see the C8 0024 realization).
Realization (cycle 0026 — graph render redesign: model + WASM-Graphviz viewer,
#51). aura graph no longer renders ASCII. The render path is now two pieces:
a read-only model serializer (aura_engine::model_to_json, iteration 1) that
walks the root composite + every distinct composite type into a deterministic,
hand-rolled JSON model (C14, golden-tested; the engine's last hand-rolled JSON
writer after RunReport::to_json moved to serde in cycle 0033; the
swapped-param mis-wire property moved here from the old compiled-view test), and a
self-contained HTML viewer (aura-cli::render::render_html, iteration 2) that
inlines that model, the ported prototype viewer JS, and a vendored Graphviz-WASM
blob into one page emitted to stdout. Layout/SVG happen in the browser via
WebAssembly — aura ships no layout engine and stays a serializer (C9: graph-as-data,
no eval/build on the path). The viewer is a render asset (C10 — no node/strategy
logic, no DSL); it labels every input pin from the model's now-real names (C23
debug symbols, named in cycle 0027) and colours wires by the four scalar base
types (C4). This retires ascii-dag and its adapter (graph.rs), the
--compiled/--macd flag plumbing, and the invented #Sf/:=/histogram →
notation — superseding the cycle-0017 flat-ascii model and its renderer-defect
workaround. The DOT/SVG are Graphviz-version-dependent and not golden-tested; the
deterministic JSON model is the asserted contract.
Realization (cycle 0034 — structural-constant bind: a knob removed from
param_space, #55). PrimitiveBuilder::bind(slot, value) adds the third param
category beside the topology factory-arg (C7/C19) and the tuning param (the
cycle-0016 value-pin): a structural constant. The cycle-0016 binding pins a
value in the injected vector while the knob stays in param_space (a tuning
param the sweep varies); bind instead removes the slot from param_space
entirely — the knob is gone, not fixed. The discriminator is the #55
deform-vs-tune test: a value whose variation yields another valid point of the
same strategy is a tuning param (stays in param_space); a value whose
variation deforms the strategy into a different one (e.g. the 2 of an
"SMA2-entry" bound to its two-candle construction) is a structural constant
(bound out), so a sweep never enumerates deformed strategies as valid family
members. Mechanically bind shrinks the builder's declared param surface
(schema.params) and wraps its build closure to re-splice the 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, and chained binds reconstruct the correct positional
vector because each layer computes its slot index relative to the param list it
sees. C23 is unaffected: bind resolves the param name to a position at
authoring time (the by-name authoring address space, the 0032 amendment) and
the flat graph stays wired by raw index — the name never reaches it. The
complementary question — exporting a named frozen strategy (all/most knobs
bound) as a reusable blueprint value — is deferred (#60); bind ships only the
per-knob overlay, no registry (C9/C10 intact).
C20 — Strategy ↔ harness; the harness is the root sim graph
Guarantee. A strategy is a reusable composite-node blueprint (C9):
broker-, data-, and viz-independent, with inputs declared as named roles
(symbol-agnostic where possible) and the bias stream (C10) as output.
A harness (the experimental setup) is the root sim graph — sources bound
to the strategy's input roles + the strategy + attached broker node(s) + sinks —
and is itself produced by the bootstrap (C19). A harness instance is C1's
disjoint unit (RustAst's "root scope"). The harness has two kinds of
parameterization: structural axes (which strategy, which instrument(s),
which broker(s), which window, which risk regime) whose variation selects
different instances — the experiment matrix (the risk regime is the fourth
axis, realized 2026-07-06 / #210 as CampaignDoc.risk; see the C10 realization
for its kept-separate, compared-not-selected semantics); and tuning params
(the strategy's numeric params)
swept within a fixed structure (Fork A). The same strategy blueprint is reused
across backtest, sweep, visual workspaces, and the frozen live bot — each a
different harness. Both strategy and harness/experiment are authored in Rust
via builder APIs (C17); the experiment matrix is ordinary Rust control flow
(loops/conditionals), not a config schema. Ontologically, a node (incl. a
strategy composite) is an open fragment — free input roles + ≤1 output
(C8/C9) — that does not run alone; a harness is the closed root graph: a
strategy with its input roles bound to sources and its output terminated in
broker/sink nodes, under a clock. A harness is therefore not a node (no free
inputs, no output; it does not fit eval) — it is the closure that runs, C1's
disjoint unit / the root scope. Harnesses do not nest as nodes; the World (C21)
orchestrates them as objects.
Forbids. Embedding data sources / brokers / sinks inside a strategy; a
declarative experiment mini-DSL (logic is Rust — C17); modelling the harness as
a node (it is the closed root scope, not an open composable node).
Why. Reusability needs the strategy to be a context-free blueprint that many
harnesses embed. Modelling the harness as a root graph keeps it within the one
Node/graph abstraction (C9) and makes "10 strategies in one environment" and
"one strategy × N instruments" plain nested loops over the structural axes. Rust
authoring (not config) preserves full programmatic power — conditional/adaptive
matrices, generated axes, custom wiring — and avoids re-introducing the DSL trap
C17 rejects.
Realization (GER40 session-breakout blueprint milestone, 2026-06-17 — refs
#94/#96/#97, spec 0051). Made concrete on the first real-data strategy: a
hand-wired FlatGraph is not a shippable strategy — it carries no
param_space(), so the World families (sweep / walk_forward / compare, C21)
cannot consume it without a hand re-author (the friction the GER40 deep-dive
fieldtest surfaced). The canonical shippable form is the Composite
blueprint (the authoring/source level, C9/C19); the FlatGraph is only its
compiled substrate (C23). The breakout now ships as
ger40_breakout_blueprint(bar_period, …) whose param_space() is exactly its
tuning knobs ({entry_bar.target, exit_bar.target}); the bar period is a
construction argument binding Resample + Session together — a structural
matrix axis (C12: a different period is a different strategy, not a sweep
point), never a param_space entry, so a sweep cannot desync the two clocks.
This is the concrete instance of the structural-axis-vs-tuning-param split
above (and delay.lag, a C8 structural constant, is bound out of the space).
Refinement (2026-06-29 — experiment-matrix members are topology-data, C24).
"The experiment matrix is ordinary Rust control flow, not a config schema" holds for
the generator — the loop / conditional that enumerates structural variants may
stay Rust (C17). But each member it yields is a topology-data value (C24),
not a hand-coded blueprint nor engine-baked source: a structural axis selects among
data blueprints the World constructs, mutates, and serializes. This is what makes
structural variation first-class and searchable rather than a hand-enumerated
menu — HarnessKind and the per-strategy *_sweep_family functions in aura-cli
are the pre-C24 scaffolding (topology-as-engine-source, a C16 tension), retired as
C24 + the project-as-crate layer land. [C26 realization, 2026-07-10 (#231): the
scaffolding's last data weld — wrap_r's hard-wired price←close role and the
M1Field::Close-only open sites — is retired; a strategy's input roles now bind
archive columns by name (C26). wrap_r's remaining R-scaffolding retirement
stays #159.]
Refinement (2026-07-03, #188/#189 — experiment INTENT is campaign-document data).
The "ordinary Rust control flow, not a config schema" clause and the forbids-clause
"declarative experiment mini-DSL" are scoped by the #188 role-model pass: they forbid an
open, logic-bearing experiment language (the RustAst trap), not the closed-vocabulary
campaign document (C25/C18) that now carries persisted experiment intent — data
windows × strategy ids × param axes × process ref under the total, declarative P1
construct tier (bounded axes, gates, ladders; no variables, no general recursion, no
unbounded iteration — invariant 5's no-free-feedback move applied one level up). A
generator that enumerates structural variants may still be plain Rust (role 2/6a
technique); what it yields, and what a campaign declares, is data. Genuinely new logic
(metrics, analysis blocks) escalates to a new Rust block (role 2), never to a freetext
hole in the artifact.
C21 — The World: the meta-level is the product
Guarantee. Above the harness sits the World — the project's program / "game" (C16: a Rust crate). Within it, harnesses are dynamically constructible, first-class objects: meta-programs (walk-forward, sweep, optimize, Monte-Carlo — C12's axes) construct harness instances at runtime via the bootstrap (C19), run them disjointly in parallel (C1), aggregate / compare their results, and discard the transient instances. A walk-forward rolls windows → bootstraps a harness per window → stitches out-of-sample equity + parameter stability into one meta-result. Orchestrating families of harnesses is first-class, not a headless afterthought; the run registry (C18) is the World's memory. Forbids. Relegating multi-harness orchestration (walk-forward / sweep / comparison) to second-class headless-only status; treating the single backtest as the product. Why. What happens within one harness — backtest a strategy → equity — is commodity; every quant system covers it. aura exists for the meta-level: a programmable space where harnesses are dynamically built and families of them orchestrated and explored. The deterministic single-harness engine (C1–C20) is the substrate; the World is the product. Refinement (2026-06-29, #109 resolved — the World owns topology as data, C24). "Harnesses are dynamically constructible, first-class objects" is sharpened: their topology is a serializable data value the World owns (C24), not Rust source. This is the precondition for the World's full ambition — it can generate, mutate, structurally search (genetic / NEAT-style graph operators, the open search-policy thread), compare, and serialize-for-reproduction (C18) families that vary structure, not only numeric params. A param sweep over a fixed skeleton is the degenerate case; varying the skeleton itself needs topology-as-value. The game-engine principle (C16) made literal: the engine owns the scene / blueprint graph as content, instantiates and runs it, the way aura owns harness topology as data.
C22 — The playground is a trace explorer; sinks are the recording mechanism
Guarantee. The World is a program: nothing is displayable until it runs,
and its harnesses are transient machinery (built, run, discarded — C21). The only
durable, displayable substance is the recorded trace, captured by sinks —
pure consumer nodes (C8) that persist a stream (equity, position events, a node's
output) into the run registry (C18). Displayable = exactly what a sink
recorded; with no sink, only input params + summary metrics remain. The
playground is therefore an execution viewer / trace explorer, and it
plays any harness (it is the harness player, not a harness, never bound to a
default one): before a run it shows the program structure (graph-as-data, C9) +
param knobs; during a run, live sink streams; after a run, recorded traces +
metrics from the registry — including meta-views (stitched walk-forward, sweep
surfaces, multi-strategy / instrument comparison). Observability is explicit
and selective — you instrument what you want to see; the choice of sinks is
part of the experiment. The engine ships sample harnesses with sinks so a
newcomer sees a populated trace immediately; a fresh project is empty until
built, run, and instrumented.
Forbids. A scene-editor model that assumes a persistent populated world;
constructing or wiring topology in the UI (topology is Rust + hot-reload,
C9/C17 — the UI reflects the live graph-as-data and tunes runtime params via
sliders, C12); retaining un-sinked data; binding the playground to a single /
default harness.
Why. A program has no scene-at-rest to inspect; what persists is what it
records. Making sinks the one recording-and-observability mechanism keeps "what
can I see?" answerable by "what did I instrument?", and keeps the engine
UI-agnostic (C14). Live param tuning (runtime values, no topology change —
C12 / C19 Fork A) gives the interactive feel without a wiring DSL.
Realization (cycle 0006). Sinks-as-recording-mechanism is realized at the
substrate level: a recorded trace is exactly what a recording node pushed out of
the graph (no engine recording registry; the constructing World holds each
recording node's destination). The engine's single observe: usize affordance is
removed — Harness::run returns () and recording is a node-side concern, so one
run records many streams (one per recording node) instead of exactly one row.
Recorded streams are sparse and timestamped (a record per fired cycle, tagged
ctx.now()), matching a trace of timestamped events (C18). No new contract; the
Harness API change (observe removed, run -> ()) is recorded here.
Realization (the web-from-disk visual face, #101). The C14/C22 web seam shipped
(amendment 3b56efb): a recording run persists each drained tap as a columnar (SoA,
C7) ColumnarTrace to runs/traces/<name>/ (aura-registry::TraceStore, beside the
run registry's runs.jsonl), reachable via aura run [--real <SYM> …] --trace <name>; aura chart <name> [--panels] reads them back, aligns all taps on a
synthetic-union timestamp spine via the post-run join_on_ts (C3 — not in-graph;
C1 — pure, no live external call), and emits a static self-contained uPlot page
(vendored like render_html's Graphviz-WASM). The engine stays headless (C14):
encoding lives in aura-engine (ColumnarTrace, struct→JSON only), file I/O in
aura-registry, rendering in aura-cli (render_chart_html + chart-viewer.js).
First cut only — overlay (per-series y-scale) / timestamp-aligned panels; the served
page injected the chart series but no run-context header (the run manifest is
persisted on disk in index.json, deliberately not surfaced in that first cut — a
ratified scope call). The header (#102) and serve-time decimation (#108) landed in
cut 2 (see the served-page-hardening amendment below); the families-comparison view
landed too (#107). A local server and replay-clock controls remain open (see "Open
architectural threads").
Amendment (family-member traces, #104, d3cb5f8). The disk-trace layout extends
to family runs: aura sweep|mc|walkforward --trace <name> persists each member as
a nested standalone run-dir runs/traces/<name>/<member_key>/, reusing
persist_traces/TraceStore verbatim (engine untouched — persistence is a
side-effect inside each per-member closure). The member_key is content-derived and
deterministic — sweep: the varying axes rendered as a filesystem-portable
directory component (<axis-name>-<value> tokens joined by _, charset
[A-Za-z0-9._-], case-less values, length-capped with an FNV fallback), MC
seed{N}, walk-forward oos{ns} — never
a runtime ordinal, because members run in parallel under the engine's Fn + Sync
HOFs, where a counter would be schedule-dependent (C1); concurrent TraceStore::write
targets disjoint member dirs, so it is lock-free. Opt-in: without --trace,
stdout/registry are byte-unchanged. Because TraceStore resolves <name>/<member_key>
as a subpath, aura chart <name>/<member_key> charts any single member with no
view-side change — the write-side precondition for the family-comparison view
(overlay / small-multiples across members) — now realized (#107; see the
families-comparison amendment below).
Amendment (CLI --trace retired, #168 / #224). The CLI-verb --trace story above (single-run run --trace, amendment 3b56efb; per-member sweep|mc|walkforward --trace, #104) describes capabilities that no longer reach the code. run and mc refuse --trace (structurally parseable, refused at dispatch); the per-member family --trace was never wired to the blueprint sweep (run_blueprint_sweep's let _ = persist) and was silently dropped when the welded --strategy triple retired (#159/#220). #168 makes the surface honest — sweep/walkforward --trace now refuse up front (exit 2, forward-pointer to #224). The single live TraceStore::write site is the campaign presentation.persist_taps → runs/traces/<name>/ (persist_campaign_traces); aura chart <name> reads that back. Restoring CLI-side per-member trace-writing is deferred to #224.
Amendment (real-data family source, #106, 8e5d14b). The family runs gain an
opt-in real-data source axis: aura sweep|walkforward --real <SYMBOL> [--from <ms>] [--to <ms>] streams real M1 close bars from the data-server archive (the #71
M1FieldSource seam — C6 record-then-replay: a pre-recorded archive, never a live
call mid-sim) instead of the built-in synthetic stream, per family member. A
CLI-side DataSource provider (synthetic | real) supplies each member's source,
pip (resolved from the recorded geometry sidecar (instrument_geometry) — C10
refuse-don't-guess; a symbol with no recorded geometry exits 2 before any data
access), window, and — for walk-forward — its WindowRoller sizes
(synthetic: bar-index 24/12/12; real: fixed calendar-time 90d/30d/30d in ns; CLI
flags for these are deferred). The engine, ingest, and registry are untouched (C9 —
the whole change is in aura-cli); the synthetic path is byte-unchanged.
MC is excluded (#106 Fork A): its seed varies a synthetic price-walk
realization (C12 axis 4), which is undefined over real data's single realization —
aura mc --real refuses with exit 2; a real MC needs a bootstrap-resampling axis
(its own cycle). The real walk-forward member key oos{from.0} widens from a small
synthetic bar index to an epoch-ns integer (still portable, collision-free). The
built-in demo strategy's length grid is likewise data-kind-dependent
(DataSource::strategy_lengths): synthetic keeps the short lengths that fit the
18/60-bar demo streams (trend SMA {2,3}×{4,5}, MACD 2/4/3), real uses realistic
M1 lengths (trend {50,100}×{200,400}, MACD 12/26/9) so the cross is a genuine
trend signal over tens of thousands of bars, not noise. This — like the roller
sizes — is a demo-strategy calibration patch; the real answer is project-authored
strategies (C9: a project crate owns its own grid), deferred to the project-env
work.
Amendment (families-comparison view, #107, 4c64feb). The family-comparison
view the #104 amendment left open is now realized. aura chart <name>
classifies the name on disk (TraceStore::name_kind: a top-level index.json is a
single run; else member subdirs with index.json are a family; else not-found) and,
for a family, overlays one tap (default equity, --tap to pick) of every
member in one self-contained uPlot page: build_comparison_chart_data emits one
labelled series per member, all on a single shared y-scale — members measure one
identical quantity, so a shared scale is what makes them comparable, unlike the
single-run overlay of different taps (per-series scales). The series align on the
same union-ts spine via join_on_ts, so one mechanism yields three correct
readings: sweep/MC members share the data window (a true overlay), walk-forward
members are disjoint OOS windows (null-complementary → the stitched curve). The view
consumes a set of runs (&[FamilyMember] from TraceStore::read_family, sorted
by key for determinism, C1); a family is its first producer — the deliberate first
step toward the programmable analysis meta-level (C21) without rebuilding the
orchestration axes (that is its own later cycle). Name resolution is made a total
function by the write-guard TraceStore::ensure_name_free, called once per tracing
command (run/run <bp.json>/run --real → Run; sweep/mc/walkforward → Family)
to refuse cross-kind name reuse — so the one ambiguous on-disk state (a name used by
both a run and a family) is unreachable. Every error path exits 2, never panics
(C18/C10 refuse-don't-guess). Engine untouched (C9/C14): the whole change is
aura-registry (the resolver + classifier + guard) + aura-cli (the builder +
emit_chart name-kind branch + --tap); the single-run page is byte-unchanged when
no --tap is given. Still deferred: multi-column tap selection
(build_comparison_chart_data projects column 0 — the comparison taps in scope are
single-column f64; #47); and the orchestration-composability rebuild (sweep/MC/WFO as
composable meta-level tools).
Amendment (served-page hardening — cut 2, #102 + #108, 476342d). The two deferred
follow-ons the #107 amendment named are realized, both confined to aura-cli (engine
- registry untouched, C14). Run-context header (#102): a
ChartMeta(kind/name/commit/window/broker/seed/taps, + member count for a family, + bound params for a single run) is built from theRunManifestinbuild_chart_data/build_comparison_chart_data, injected intowindow.AURA_TRACES.meta, and rendered by a purebuildHeaderinchart-viewer.js(headless.mjs-guarded, likebuildCharts). The family window is the span across members(min from, max to)— the only reading that labels a disjoint walk-forward family's true OOS coverage (it collapses to the shared window for sweep/MC). Decimation (#108): a puredecimate(ChartData, buckets) -> ChartDatamin-max transform thins the served page to ≤ ~2·CHART_DECIMATE_BUCKETSspine slots (per-bucket min+max, nulls preserved), applied inemit_charton both paths — a multi-year M1 family now renders a few-thousand-point page instead of 100s of MB; full recorded data stays on disk (view-only), and the walk-forward null-fill page collapses for free. Deterministic (C1): pure bucketing over the sorted-deduped spine, no-op under budget. Refinement (#111,2957561): the per-bucket reduction is tap-aware — an envelope series (equity) keeps min/max, but a bounded level series (the C10 bias level — pre-reframe: exposure — ∈[-1,+1]) reduces by per-bucket mean (aReduceKindon eachSeries, set byreduce_for_tap). Without it min/max made every multi-year exposure a solid -1..+1 band (each bucket straddles many sign flips), reading as per-point oscillation; the mean shows the net/duty-cycle level. Deferred refinements: rendering the min/max envelope honestly as range bars / OHLC rather than a polyline (#112); a--widthbudget flag and true intra-bucket min/max ordering for the non-default continuous x-mode (#110). Refinement (2026-06-29 — the visual face is orthogonal and optional; it does not motivate C24). The blueprint data format (C24) is required by C21 (generation / structural search) and C18 (reproduction of a generated run) at the CLI level, autonomously — independent of any visual surface. The playground's form (web or egui, editor or viewer, or not built at all) is therefore downstream and optional, and topology authoring does not live in it. This severs an earlier conflation that treated the playground as where topology-editing would live. C22's "never a scene editor" is refined to its true content — no persistent scene-at-rest (a World is a program, C21) — which does not forbid a future blueprint editor over the World-owned topology-data (C24); should the playground ever become one, the data format is already the substrate it round-trips, not a new mechanism. Refinement (2026-07-03, #188 — visual surfaces are stateless projections; C25). The role-model pass generalizes the point beyond the playground: for every artifact class (blueprints, op-scripts, process/campaign documents), the canonical, complete form is text, every operation is executable headless, and any visual surface — viewer or editor — is a stateless projection that reads/writes that same canonical form and adds no semantics. Read-only projections (viewers, this contract's trace explorer) rank far above write editors in value; whether an editor is ever built is secondary, because the Blockly litmus test (C25) already disciplines each vocabulary to be palette-generatable without one. The shipped face is the web-from-disk front (revised 2026-06, #101 — not egui): disk-persisted traces rendered as self-contained HTML chart pages.
C23 — Graph compilation and behaviour-preserving optimisation
Guarantee. The bootstrap (C19) is a compilation: it lowers a param-generic,
named blueprint (the authoring source — nodes, composites, strategy, harness;
C8/C9/C20) into a flat, type-erased FlatGraph — the frozen runnable
instance (C7) — wired by raw index, not by name (Edge { from, to, slot, from_field }). Composites are inlined at this step (C9): the composite
boundary dissolves entirely (there is no composite in the flat graph). The
blueprint's field / input-role names are non-load-bearing — the wiring
resolves by index, and names survive at most as informative debug symbols
(exactly as FieldSpec.name already is, C8), kept for tracing / rendering (C9
graph-as-data, #13) but carrying no run semantics. The flat graph is then the target of behaviour-preserving
optimisation — any transform that leaves every observable sink trace
bit-identical (C1 is the correctness invariant) — on two levels:
- intra-graph (within one graph): common-subexpression elimination — two identical nodes (same type, same bound params, same input source) merge to one with output fan-out, so fractal composition (C9) costs no redundant compute — and dead-node elimination — a node on no path to a sink is dropped. Pure-consumer sinks (C8, no output) are never CSE candidates, so their out-of-graph side effects are preserved by construction.
- across the sweep family (over the family of flat graphs one blueprint yields
under a param sweep, C12): the sub-graph whose nodes carry no swept param and
whose inputs are all sweep-invariant (transitively from the sources — same
data window) is loop-invariant w.r.t. the sweep and is computed once; its
output is materialised as a recorded stream (C11) shared read-only across the
sweep instances (
Arc<[T]>, C12). Only the parameter-dependent suffix re-runs per sweep point — loop-invariant code motion over the sweep loop. Forbids. Any "optimisation" that changes an observable trace (it breaks C1); optimising over a representation that is not graph-as-data — an opaque trait-object interior (the rejected nested-composite reading of C9) cannot be analysed or rewritten across its boundary, so it forecloses both levels. Why. Determinism (C1) is not merely an audit property; it is the licence for these rewrites — a behaviour-preserving transform is only meaningful because "same input → bit-identical run" makes "same result" decidable, and a sweep-invariant sub-graph is reproducible enough to compute once and share. The flat graph representation (C19) is the necessary condition: only a flat, inspectable graph-as-data — with each node's declared param-ranges (C8) — lets the compiler identify identical sub-expressions, dead nodes, and the sweep-invariant frontier. This is precisely why a composite is an inlining (C9) and not a runtime sub-engine: the flat graph is what makes the whole graph optimisable. Status (Construction-layer milestone). The flat graph representation — blueprint → inline / compile → flat instance — is the load-bearing substrate built first; the optimisation passes (intra-graph CSE/DCE, sweep-invariant hoisting) are deferred, behaviour-preserving follow-on work, each gated by a "flat graph with pass ≡ flat graph without pass, bit-identical run" test (C1). The sweep-level pass further presupposes per-node param declarations (C8 — landed in cycle 0015 asname+kind; the swept range still pending #32/C20) and the sweep orchestration itself (C12/C21). Amendment (cycle 0032 — param-namespace injectivity is part of the compilation). The bootstrap-as-compilation now includes one structural gate:check_param_namespace_injectiveover theparam_space()name projection (see C9 / C12-C19), run before name resolution. It reads the boundary name projection — the authoring address space — and rejects a duplicated path (DuplicateParamPath). Node names stay non-load-bearing: they qualify the param path at construction and are dropped at lowering (the flat graph is wired by raw index, unchanged). The check makes the by-name authoring address space injective; it does not make names load-bearing in the flat graph.
C24 — The blueprint is a serializable, World-owned data value (the topology data format)
Guarantee. A blueprint — the param-generic, named graph-as-data a Rust
builder produces (C9/C19) — is a first-class serializable data value with a
stable format, and the engine has a load path (data → blueprint → FlatGraph,
C23), not only the Rust-builder construction path. Topology is therefore a value
the World owns (C21): constructed, generated, mutated, structurally
searched, serialized for reproduction (C18), and (optionally) rendered or
edited by a visual face (C22). model_to_json (C9) is the existing reverse half
(blueprint → data, for render); this contract closes the loop. The format carries
topology + structural axes + the param-space, referencing nodes by their
compiled-in type identity — the closed, typed node vocabulary of aura-std + the
project cdylib (C8/C16) — and carries no node logic (computation is compiled
Rust, C17). It is non-Turing-complete by construction (a static DAG, no control
flow), so it structurally cannot become a second RustAst.
Forbids. Putting node logic / computation in the format (the RustAst trap —
logic is Rust, C17); a Turing-complete or control-flow-bearing blueprint format
(it is static data; the generator that emits it may be Rust, C20, but the artifact
is not a language); a by-name node marketplace / registry (the vocabulary is the
compiled-in closed set referenced by type identity — the format is not a distribution
mechanism, C9/C16); treating the visual face (C22) as the motivation or the
authoring brain (the format is required by C21/C18 at the CLI level, independent of
any UI); a generated / searched run whose topology is not recoverable from its
manifest (C18 reproduction); baking topology as engine source compiled into the
binary (the hard-wired aura-cli harnesses are pre-C24 scaffolding, a C16 tension,
retired as this lands). [C26, 2026-07-10 (#231): the single-price data weld inside
the surviving wrap_r scaffolding is retired — input roles bind archive columns
by name; the wrapper's remaining R-scaffolding retirement stays #159.]
Why. The World (C21) is the product, and it cannot orchestrate, structurally
search, or reproduce families that vary topology while topology stays opaque
Rust source baked into the engine. The game-engine principle (C16's engine / game
split) makes it concrete: the engine is compiled native systems; the "game" is
content — and topology is content, a data value the engine owns, serializes,
instantiates, and mutates, exactly as a game engine owns its scene / prefab /
blueprint graphs. The #109 fork was never "who types the wiring" — a small LLM
cannot reliably apply a DSL and a strong one does not need one, so a
manifest-as-authoring-surface dies on both horns (C17). It is "is topology a
compile-time source artifact or a runtime, serializable, World-owned value?",
and determinism (C1) + reproduction (C18) + structural search (the open search-policy
thread) force the value. The engine already treats topology as a runtime value
(C9 graph-as-data, C19 "cheap graph re-compilation, not a code recompile", C23 the
flat graph as the optimisation target); C24 only adds that the value serializes out
of Rust and loads back in.
Status (2026-06-29; first cut shipped — cycle 0087 / #155, d5602ec;
construction service shipped — cycle 0088 / #157). The
principle is settled (this contract, ratified in an in-context design discussion
— the #109 resolution). The first cut now ships: a Composite blueprint
serializes to a canonical, versioned data value (format_version envelope,
omit-defaults JSON) and loads back (data → blueprint → FlatGraph) via
blueprint_to_json / blueprint_from_json (aura-engine::blueprint_serde),
referencing nodes by compiled-in type identity through an injected resolver
whose concrete closed match over the aura-std vocabulary lives outside the engine
(aura-std::std_vocabulary) — the engine stays domain-free, no node registry
(invariant 9). Acceptance met: a serialized blueprint runs bit-identical (C1) to
its Rust-built twin. model_to_json (C9) remains the render half; this closes the
loop for the round-trippable vocabulary. Cycle 0088 (#157) adds the
introspectable construction service: a declarative, replayable by-identifier
op-script (aura graph build / introspect over a JSON op-list, the engine
GraphSession / replay) builds a runnable blueprint through validated ops — the
engine's construction gates split into eager per-op checks (name resolution, edge
kind-match, the double-wire arm, param bind, and acyclicity — a connect that would close a
cycle is rejected eagerly, #161) and holistic finalize checks (wiring
totality, param-namespace injectivity, root-role boundness), both cadences calling
the same extracted predicates (edge_kind_check, the shared resolution helpers,
check_root_roles_bound — no second validator) — plus build-free introspection over
the closed vocabulary (types, a node's ports/kinds, a partial document's unwired
slots). It emits the #155 blueprint; acceptance met: a graph built purely through
the ops compiles identical (C1) to its Rust-built twin, an invalid op is rejected at
the op naming the cause, introspection answers without a build. The engine Op stays
serde-free (the wire DTO is CLI-side); the vocabulary's enumerable companion
(std_vocabulary_types) lives in aura-std (no registry, invariant 9). Cycle
0089 follow-up (#161 / #162, after the cycle-0088 fieldtest). The eager acyclicity
gate above (GraphSession::closes_cycle, a reachability check at the closing
connect) closed a fieldtest-found hole where graph build accepted a non-DAG
op-list (invariant 5 / C9). Lockstep: it is a second home of invariant-5
alongside the bootstrap Kahn sort (harness.rs, BootstrapError::Cycle); the two
must co-evolve when the explicit delay/register node — invariant-5's sole legal
feedback — lands, or a valid delay-feedback graph the bootstrap accepts would be
rejected at construction. The holistic finalize faults now also read
by-identifier (#162): finish() translates the index-carrying CompileErrors
(UnconnectedPort / RoleKindMismatch / UnboundRootRole) into by-identifier
OpError variants — still calling the unchanged holistic gates (the
no-second-validator lockstep preserved), only translating their result.
Cycle 0090 (#156) codifies the forward-compat two-tier discipline. Tier-1
(additive-optional) is serde-default silent-ignore with no format_version bump
(a new optional field defaults to prior behaviour, C1) — now proven by
unknown_optional_field_is_tolerated_byte_identically (an unknown optional key
loads byte-identically and runs bit-identically). Tier-2 (must-understand: a new
node type, edge semantics, or structural-axis kind) bumps format_version so an
old reader refuses cleanly (LoadError::UnsupportedVersion / UnknownNodeType,
already green). The per-section required-flag scheme is deferred (no current
Tier-2 section to validate it; recorded on #156).
Pre-ship dormancy (#61, 2026-07-10): until the first external ship there are
no out-of-repo readers — reader and writer change atomically in one commit —
so the Tier-2 bump discipline is dormant and structurally-semantic additions
(the gangs section) land as additive-optional fields of v1; the first ship
consciously freezes v1, gangs included, and activates the bump discipline.
Milestone delivered — 2026-06-30, cycle 0090 — the serializable format + loader + construction service. A green end-to-end milestone fieldtest (fieldtests/milestone-topology-as-data/) proves the full author → serialize → load → construct → introspect → reproduce story from the public surface alone, 0 behavioural bugs; the round-trippable format (#155), the construction service (#157), and the forward-compat two-tier discipline (#156) all shipped. Polish filed forward: op-script grammar docs + a stale example (#163), the canonical trailing-newline / Composite value ergonomics (#164), CLI discoverability of build/introspect (#159).
Realization (2026-06-30, cycle 0092 — runs are built FROM blueprint-data, #165).
aura run <blueprint.json> now loads a serialized signal blueprint
(blueprint_from_json → the closed std_vocabulary; an unknown type fails clean as
UnknownNodeType, the data-plane face of invariant 9), wraps it in the r-sma run
scaffolding (sinks / broker / data supplied at run, not serialized — C24's deferred
set), runs it, and emits a RunReport bit-identical (C1) to its Rust-built twin —
proven by loaded_signal_runs_bit_identical_to_rust_built. The RunManifest now carries
a topology_hash: SHA256 of the canonical (#164) blueprint_to_json, the #158
reproducibility anchor, a Tier-1 optional field (#156). The hash + helper live
research-side (aura-cli + sha2), off the frozen engine (invariant 8). This is the
keystone of the World/C21 milestone: topology-as-data is now runnable, not only
serializable. The harness wrap was made shareable — r_sma_graph() =
wrap_r(sma_signal(...)), so the Rust path and the data path are the same seam
over the same signal (a behaviour-preserving C19/C23 restructure). Scope note: the hash
covers the signal only (the fixed scaffolding is identified by commit); a content-id
distinguishing harness-structural variants is a #158/#166 concern once scaffolding varies.
Realization (2026-07-01, cycle 0093 — the World orchestrates FAMILIES from blueprint-data,
#166). aura sweep <blueprint.json> --axis <name>=<csv> loads an open signal blueprint,
grids the by-name axes against its param_space(), builds each member through cycle-1's
wrap_r seam, and aggregates to a FamilyKind::Sweep family — byte-identical in shape
to the hard-wired sweep (append_family / sweep_member_reports reused verbatim). This is the
C21 step beyond a single run: the World now constructs and orchestrates families of
harnesses from topology-data, not just one. Each member manifest carries the shared
topology_hash (one signal topology, only params vary; member_key distinguishes members) —
reproducible per C18. Two design facts: a sweep needs an open blueprint (a fully-bound one
has an empty param_space, distinct from cycle-1's bound run fixture), and the signal is
re-loaded per member from its serialized doc (the Composite is !Clone — its
PrimitiveBuilders hold Box<dyn Fn> build closures, #164 — and bootstrap_with_cells
consumes the graph). Monte-Carlo over a loaded blueprint shipped (cycle 0095, #170). aura mc <blueprint.json> --seeds N runs a closed blueprint across N seeds — each seed a distinct
synthetic price walk (synthetic_walk_sources, the mc_family SyntheticSpec pattern), the
draws disjoint-parallel via the engine monte_carlo seam (invariant 1) — and aggregates to a
FamilyKind::MonteCarlo family with one stored blueprint per family (the C18 hook), so it
aura reproduces bit-identically (C1). MC binds no axis, so it needs a closed blueprint
(the sweep's open/closed distinction inverted); an open one returns a named Err (rendered
exit-2 at the run_blueprint_mc boundary, the sweep sibling's contract — no hidden exit in the
pure builder). IS-refit walk-forward shipped (cycle 0097, #173). aura walkforward <blueprint.json> --axis <name>=<csv> [--select argmax|plateau:mean|plateau:worst] re-optimizes
the loaded blueprint's params over the user --axis grid (the #169 prefixed names) on each
24/12/12 IS window, selects the winner by sqn_normalized (the hard-wired arm's metric +
select_winner reused verbatim), runs it out-of-sample, and aggregates to a
FamilyKind::WalkForward family — persisted + content-addressed + aura reproduce bit-identical
(the read-side WalkForward branch rebuilds each OOS window from manifest.window). One
substitution deep from the hard-wired r-sma WF arm (the loaded blueprint via
blueprint_axis_probe replaces the built-in strategy); a bad --axis is a clean in-closure
exit 2, never a panic. This is Arm A (the settled direction): the loaded IS-optimizing form
honestly carries the walkforward name. Reduce-mode members are R-measured (oos_r the
meaningful summary; stitched pip-equity empty, C10). The synthetic-walk DGP
is the machinery, not trader-grade MC statistics; a real-data block-bootstrap — and the
synthetic_walk_sources len:60↔warm-up coupling it retires (a deep-lookback closed blueprint
warms poorly → silent-vacuous draws today) — ride #172. Axis-name discovery shipped (cycle
0096, #169): aura sweep <blueprint.json> --list-axes lists a loaded blueprint's open
sweepable knobs (one <name>:<kind> per line, param_space() order), then exits — the names it
prints are exactly what --axis binds. Every listed name is mandatory on a sweep /
walkforward: the blueprint must be fully bound before it runs, so a subset grid is refused with
the missing knob named (BindError::MissingKnob) and there is no default — pin a knob you do not
want to vary with a single-value axis (--axis <name>=<one-value>). A single blueprint_axis_probe helper now single-sources
the wrapped probe (wrap_r(loaded_signal).param_space()) for the sweep terminal, the MC
closed-check, AND the listing (three former inline copies → one), so listed == swept by
construction (and stays so across #159's harness retirement — the listing tracks whatever the
sweep actually resolves, never a second source of truth). The names are prefixed by the current
r-sma wrapping (sma_signal.fast.length, the nested-composite prefix), not the raw
param_space — which is why the discovery lives on the sweep verb (it owns the wrapping), not
graph introspect. CLI --trace is refused on all verbs (#168 for sweep/walkforward; run/mc already refused); the live trace-writer is the campaign presentation.persist_taps (persist_campaign_traces), and restoring per-member CLI traces is tracked by #224.
Content-addressed reproduction shipped (cycle 0094, #158, C18): topology_hash
landed cycle 0092; re-deriving a member's FlatGraph from a stored manifest + the
content-addressed blueprint store (aura reproduce) shipped cycle 0094 (see C18
Realization). What remains is a content-id that covers structural-axis / whole-harness
variants (the scaffolding is not yet blueprint-data); the debug-name-in-id question is
settled by cycle 0104's identity id (#171: blueprint_identity_json + graph introspect --identity-id, an additive sibling — the byte-exact content id keeps the
store/reproduce roles; introspection-only until a dedup consumer exists). Still deferred: retiring the pre-C24
hard-wired aura-cli harnesses (HarnessKind, run_r_sma, *_sweep_family) once
the project-as-crate layer lands (#159, paired with #157's data-authoring surface). Out of the first cut's round-trippable set
(deliberate; fails clean as UnknownNodeType, never a silent wrong graph): recording
sinks (capture an mpsc::Sender — runtime identity, not param-generic data, C19) and
construction-arg builders (LinComb / CostSum / SimBroker / Session —
structural-axis args, a C20 concern), additively addable later (#156). The
project-as-crate load boundary landed in cycle 0102 (Aura.toml discovery +
cdylib loading + merged project ∪ std vocabulary — see the C13 realization
note; the aura new scaffolder followed in cycle 0103 — one command emits a
buildable project whose blueprint runs through the merged vocabulary); of the
two layers this paragraph used to name as sequencing-coupled, what remains
open is the composable-orchestration thread (#109): topology-as-data is
the substrate it stands on. Canonical project shape (#181, resolved
2026-07-02): the aura new templates (scaffold.rs) are the canonical
authoring shape and evolve with the engine; the cycle-0102 demo-project
fixture is an intentionally frozen known-good twin for the load-boundary
tests. The two are deliberately not lockstep-guarded: no consumer requires
them to match, each is e2e-guarded on its fitness for purpose (build →
descriptor load → charter check → deterministic run — blueprint wiring
content is pinned in neither, stated honestly), and an equality guard would
convert every deliberate template improvement into forced churn of the frozen
fixture — the same cross-purpose coupling that rules out regenerating the
fixture from the scaffolder.
Op-script grammar (aura graph build, the #157 wire surface). The construction
op-script is a JSON array of ops, each object internally tagged by "op",
replayed in order; nodes are referenced by identifier, ports as dotted
<identifier>.<port>. The seven verbs:
source—{"op":"source","role":<str>,"kind":<ScalarKind>}— declare a root source role producing a base column ofkind.input—{"op":"input","role":<str>}— declare a root input role (kind inferred from the slots it feeds).add—{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}— add a node of compiled-in type identitytype;nameis its identifier (mirrors the builder's.named(...); defaults to the lowercased type label, so two unnamed nodes of one type collide);bindsets params.feed—{"op":"feed","role":<str>,"into":[<port>,…]}— fan a root role into interior input slots.connect—{"op":"connect","from":<port>,"to":<port>}— wire an interior output field to an input slot; aconnectclosing a cycle is rejected eagerly (invariant 5).expose—{"op":"expose","from":<port>,"as":<str>}— promote an interior output field to a boundary output, aliased byas— the only verb that keepsas(a real alias, not a naming; contrastadd'sname).gang—{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}— fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space andasreplaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound).
Value forms are the #155 representations: a Scalar bind value is the typed tag form
{"I64":2} / {"F64":0.5} / {"Bool":true} / {"Timestamp":<i64>}, and kind is
the capitalized ScalarKind ("F64"). Worked example:
fieldtests/cycle-0088-construction-op-script/c0088_1_sma_crossover.json
(an SMA-crossover bias). The surface is introspectable build-free:
aura graph introspect --vocabulary | --node <T> | --unwired. (Surfacing this same
grammar in aura graph build --help rides with the CLI-discoverability work, #159.)
Canonical form (#164). The canonical blueprint artifact is exactly the bytes
blueprint_to_json returns — a JSON value with no trailing newline.
aura graph build emits those bytes verbatim (it does not frame them with a display
newline via println!), so the CLI and library emit paths are byte-identical — a
prerequisite for content-addressed topology identity (#158, a hash over the canonical
form). The canonical JSON is also the blueprint's equality / identity surface:
Composite / PrimitiveBuilder deliberately carry no in-memory PartialEq /
Debug (the recipe holds a build: Box<dyn Fn> closure — non-comparable,
non-printable; equality could only be defined over the serialized data, of which
blueprint_to_json is already the single source — a second in-memory notion would be
a drift hazard against the very form #158 content-addresses). The loader stays lenient
(a trailing newline on input still parses, Tier-1 robustness).
Enforcement shift — invariant 9 on the data plane (2026-06-30, cycle-0091 analysis,
adversarially verified). Lifting topology onto the data plane relocates where the
engine/project boundary (invariant 9, no bespoke node registry/marketplace) is enforced,
without changing what it forbids. Pre-C24 a blueprint referencing another project's
node was a compile error — topology was in-process Rust, so cross-boundary node
resolution was structurally impossible, compiler-guaranteed for free. Post-C24 that
same reference is a type-id string in a serialized blueprint, refused only by the
injected resolver's closed set (UnknownNodeType). The anti-NIH rationale survives intact
(node logic stays cargo+Gitea-only; the format distributes topology-as-content, never
logic — C17), and std_vocabulary stays a compiled-in dispatch table, not a registry — but
its enforcement degrades from compiler-guaranteed to resolver-seam discipline. Two
consequences: (1) #160 (the guard that the closed vocabulary stays honest) is no longer
droppable hygiene — it guards the data-plane face of the boundary (its own drift direction
still fails safe, UnknownNodeType). Delivered cycle 0105: the
std_vocabulary_roster! macro expands the resolver match and the enumerable
std_vocabulary_types() list from one roster, closing the resolver-vs-list drift mode by
construction; the residual (a new zero-arg node never rostered at all) stays fail-safe,
guarded by the one-line maintainer surface plus the count pins (the in-crate shape test and
aura-cli's cross-boundary --vocabulary count e2e). (2) The injected per-project resolver (C16) is
the genuinely new, deferred invariant-9 surface: nothing structurally stops a project
resolver from accepting arbitrary type-ids, or a project from layering a blueprint
marketplace (store + type-discovery API) on top of the format — one layer above the
engine contract. Invariant 9 holds at the engine; the project/ecosystem boundary (what
an injected resolver may resolve, where closed-set discipline draws the line) is a
World/C21-layer charter point, not an engine fix. Invariant 8 (frozen) is likewise
reframed by C24/C21: from "topology baked at build time" to "the World may generate
topology at runtime (research plane), the chosen blueprint is frozen into the deploy
artifact, and any run is deterministic once instantiated" (C1).
C25 — The role model: nine authoring roles, cut by artifact + surface + iteration cost
Guarantee. aura's user-facing design is organized by roles, cut by the
artifact a role owns, the surface/language it works in, and the iteration cost it
tolerates — never by org chart. One trader — or an LLM actor, which is the point of
the whole authoring design — must be able to fill every role; the separation
separates surfaces with different iteration costs and failure modes, not people,
and switching roles is a visible act. The nine roles (ratified 2026-07-03, #188):
1 system programmer (the engine; Rust, this repo; all invariants),
2 extension dev (native nodes + analysis blocks; Rust in project/shared crates;
C7/C8, determinism in eval), 3 toolchain dev (playground/viewer/skills
pipeline; vendor-side; authoring ergonomics), 4 data curator (recorded streams,
sidecars, derived/news recordings; CLI + recording edge; C2/C3/C6),
5 methodology designer (validation/eval process documents — "the game
rules"; closed data vocabulary; anti-false-discovery), 6a strategy designer
(blueprint topologies; data — op-script/blueprint JSON, the graph idiom; C5/C7),
6b campaign designer (campaign documents — persisted experiment intent;
data, the pipeline/block idiom; C1), 7 player (owns nothing — explores traces,
tunes runtime params; the playground, C22/C12; read-only by design), and
8 operator/auditor (frozen bots, reconciliation, money management; deploy-edge
CLI, manifests, reproduce; C13). The 6a/6b split follows invariant 12's tier
boundary (node/harness vs World); the interface between them is the id machinery
(content id = byte-exact, identity id = topological). Param gradient: 6a defines
topology + open params, 6b spans spaces over them, 7 moves inside those spaces
live. A role is recognizable when four things exist: a named artifact type it
owns, a surface addressed to it, an entry path, and its invariants enforced at its
boundary. Text-first / headless-first is an invariant, not a taste (user,
2026-07-03: editors must never be the only way; LLM operability demands it): every
artifact class has a canonical, complete, text-serialized form; every operation is
executable headless via CLI/API; visual surfaces are stateless projections that
read/write the same canonical form and add no semantics — read-only projections
(viewers) rank far above write editors. The Blockly litmus test is the
acceptance criterion for every artifact vocabulary, editor or no editor: a block
palette must be generatable from the vocabulary — every block with typed slots,
every valid composition snappable, every invalid one not.
Forbids. An artifact class whose only authoring path is a visual editor or a
Rust compile cycle when the role's iteration cost demands data (the role-6b
lesson: a tiny campaign change must never cost a compile); stringly-typed fields,
implicit inter-block coupling, or "arbitrary JSON here" holes in a role's
vocabulary (they fail the litmus test); collapsing the 6a/6b idioms into one
surface (dataflow boxes-and-wires vs sequential pipeline blocks are different
languages).
Why. The game-engine analogy that seeded aura (C16) extends to its people:
engines separate system programmers, toolchain devs, asset creators, level
designers, and players because their artifacts, tools, and iteration loops
differ — a design that forces one role's technique on another (Rust for campaign
tweaks, an editor as the only entry) mis-prices iteration exactly where research
throughput lives. Realization: cycle 0106 (#189) shipped roles 5/6b their
artifacts (process/campaign documents, C18 realization note); roles 4/7/8 remain
technically present but faceless (no addressed verb families yet); role homes in
the project layout and docs-by-role are open (#192 context).
C26 — Harness input binding: role names bind archive columns
Guarantee. A strategy blueprint declares WHICH data it consumes as the NAMES
of its root input roles: a role whose name is in the closed column
vocabulary — open, high, low, close, spread, volume, plus price
as the backward-compatible alias of close — binds by default to that column of
the campaign cell's (or run invocation's) instrument. A campaign document may
override the name default per role via the additive data.bindings block
(role name → column name; serde default + skip-if-empty, so binding-less
documents keep their content ids) — the 6b rebind seam: the blueprint's content
identity never changes. Resolution (aura-cli's binding module,
resolve_binding) produces ONE ordered plan consumed by BOTH halves of the old
weld: the columns are opened (aura_ingest::open_columns, the generalization of
open_ohlc/#92) and the wrapped root roles declared (wrap_r) in the same
canonical order — M1Field declaration order (open, high, low, close, spread,
volume), filtered to the consumed set — which is the C4 merge tie-break order,
so role i receives source i by construction. A close column is always part of
the plan (shared when the strategy consumes close/price, else opened for the
broker/executor pair alone). The vocabulary is Blockly-litmus-clean (C25):
role-name slots draw from a closed enum, the bindings block is typed
role → column with no free-form holes, and validation is two-tier — values
against the column vocabulary in the intrinsic tier (validate_campaign), keys
against the strategies' input_roles() in the resolver tier
(validate_campaign_refs).
Forbids. Guessing a column for an unknown role name (refuse with the
vocabulary + the override remedy, never default); opening columns in any order
other than the canonical declaration order (the C4 tie-break contract);
free-form or logic-bearing binding values (the C17/C25 line — a binding value is
a typed column reference, nothing else); fabricating synthetic OHLC when a
multi-column strategy meets synthetic data (the walk generates a close series
only — refuse with the --real remedy).
Extension point. When recorded non-price sources land (the #71 Source seam),
the binding VALUE-space grows additively from archive columns to recorded-stream
references — a root role like sentiment becomes bindable to a recorded stream
then, and until then refuses with the vocabulary-naming message. The role-name
key-space and the campaign bindings carrier are unchanged by that growth.
Why. The single-price weld (wrap_r's hard-wired price←close and the six
M1Field::Close-only open sites) made every strategy monocular regardless of
what its blueprint declared; binding by role name keeps the blueprint the single
source of its own data needs (C24: topology-as-data carries its input contract),
lets a campaign re-aim a strategy without touching its content id (C18
identity), and pins opening and declaring to one shared, canonically-ordered
plan so the two halves cannot drift (C1/C4 determinism).
Open architectural threads not yet resolved
- Playground & World UI surface — the playground is core (C22); the surface
is a web frontend served from disk-persisted traces (revised 2026-06, issue
#101; not egui). Settled for the first cut: raw per-tap trace persistence to
disk (columnar/SoA form, C7) + serve-time
join_on_tsalignment + a static self-contained HTML chart page (uPlot, vendored like therender_htmlGraphviz-WASM blob), feeds overlaid or timestamp-aligned in panels. The families-comparison view —aura chart <family>overlaying one tap across a family's members on a shared y-scale — shipped (#107, see the C22 amendment). Still open: richer comparison meta-views (sweep-surface heatmaps, cross-family / multi-strategy comparison, multi-column tap selection #47), a local server, and the run/replay clock controls. - Parameter-space search strategies (Bayesian/genetic) — pluggable policies atop the atomic sim unit (C12), not yet designed. Search over params sits atop the atomic unit; search over structure (graph mutation / crossover — NEAT-style) additionally needs topology-as-data (C24).
aura newscaffolder and the experiment-builder API —aura newscaffolds a Rust project crate (node / strategy blueprints) against the engine (C16/C20); the experiment-builder API surface (harness wiring, structural axes, sweep combinators) is not yet designed.Aura.toml's schema was settled paths-only in cycle 0102 (data archive root, runs dir — see the C13 realization note); the load boundary itself shipped there.- The analysis meta-level — composable orchestration + project-as-crate authoring
(tracked: #109). aura's differentiator (C21) had two unbuilt halves: (1) the
orchestration axes (sweep / Monte-Carlo / walk-forward) becoming composable tools
a user wires into an analysis workflow, rather than today's hard-wired CLI verbs
(
sweep_family/walkforward_familyinaura-cli) — still open; and (2) the project-as-crate authoring layer above — its load boundary landed in cycle 0102 (Aura.tomldiscovery + cdylib loading + merged vocabulary, C13 realization note) and theaura newscaffolder in cycle 0103; of that layer only the experiment-builder API remains open (C16/C17). On the meta-level, authoring a strategy is wiring existing nodes (C9 composition) + defining the analysis framework (C21), not writing new nodes; the user wants it driven via the CLI, later the interactive server / playground (C14/C22). Resolved (2026-06-29) → C24. The fork was mis-posed as "(a) run-a-Rust-experiment-via-CLI vs (b) wire-nodes-via-CLI". The real axis is topology = compile-time Rust source vs runtime, serializable, World-owned data value, settled as the value (C24, the game-engine principle): node logic stays Rust (C17), topology becomes data the World generates / serializes / reproduces. "Wire-nodes-via-CLI" is dropped — interactive wiring through the CLI is a non-goal; it collapses to a file + parser, which is C24's load path. What remains under #109: the blueprint data format itself (C24 Status — its own brainstorm / milestone), and the composable-orchestration half — the axes (sweep / MC / walk-forward) becoming tools applied to a World-constructed harness instead of the hard-wiredaura-cliverbs (sweep_family/walkforward_family). Status (cycle 0106, #189 — the artifact half shipped v1). The #188 role-model pass re-cut the "experiment-builder API" reading of this thread (role-6b work mis-typed in role-2 technique): experiment intent is now data, not a Rust API. Cycle 0106 shipped both document vocabularies (process document, campaign document —aura-research), two-tier validation, the op-script-precedent introspection contract (aura process|campaign introspect --vocabulary/--block/--unwired/--content-id), and content-addressed stores (C18 realization note) — authorable and checkable headless, no compile, no run. Status update (cycles 0107–0110). The executor question resolved and shipped (cycles 0107/0108, #198/#200:aura-campaignexecutes the v2 pipeline shape over theMemberRunnerseam); the #188 amendment package landed 2026-07-03 (C25 role-model entry, C20/C22 refinements, invariant-10 clarification). The "once it carries" dissolution of the hard-wired verbs (user decision 2026-07-03 on #188) is running as the milestone "Verb dissolution" (#210): the fork triage of 2026-07-04 decided its design (dissolve the real-data blueprint branches only, built-in/synthetic branches stay verb-wired until #159; generated documents auto-registered; full behaviour parity modulo the recorded additive instrument stamp), and the verb set dissolved in the #210-decision-7 order: cycle 0110 tookaura sweep <bp.json> --real …(enabled by thestd::sweepselection group becoming optional — all-or-nothing, selection-free = terminal-stage-only; wire form and every stored content id unchanged), then generalize, walkforward, and mc's R-bootstrap path, each now sugar over a generated, content-addressed campaign document through the one executor, plus a structural risk-regime axis (CampaignDoc.risk: [RiskRegime], solevol{length,k}variant — the stop compared as a matrix dimension, stamped into every member manifest, never argmax-swept). Ad-hoc verb intent no longer evaporates into shell history — the #188 diagnosis's cure applied to the verbs themselves. The dissolved form is per-verb, by intended scope (ratified at milestone close, fieldtest 2026-07-07): for sweep it is the blueprint file (<bp.json> --real), whileaura sweep --strategy r-sma --realstays the inline built-in path (its member lines carry no instrument/topology_hash/selection stamp and register no campaign document) — the built-in--strategydemo surface is #159's hard-wired-harness retirement target, not the dissolution's; for generalize/walkforward/mc the dissolved form is now the same generic grammar as sweep — an arbitrary blueprint positional plus--realand repeatable--axis <wrapped-name>=<csv>(#220). [HISTORY — superseded in two steps. #159 (cuts 1b-4, post-2026-07-07) retired the built-in--strategysweep/demo surface: the inlineaura sweep --strategy r-sma --realpath no longer exists. #220 then removed the verbs' weld — at milestone close the dissolved form of generalize/walkforward/mc was still the welded--strategy r-sma --real; #220 made the three verbs blueprint-generic (arbitrary blueprint + arbitrary--axis; mc keeps the synthetic--seedsfamily unchanged beside the new--real --axiscampaign mode; generalize takes--real <SYM1,SYM2,…>, >=2, with one value per axis), deleted the welded--strategygrammar (clap rejects it, exit 2), dissolvedRGrid, and unified the verbs on the #214 invocation struct. The surviving form everywhere isaura <verb> <blueprint.json> --real … --axis …over examples/r_*.json.] Milestone closed 2026-07-07 on a green end-to-end fieldtest (0 bugs; behaviour preservation, campaign-substrate reach-through, and the risk-regime axis all confirmed); residual findings are discoverability/ergonomics follow-ups (#216 risk-axis discoverability, #217 verb knob asymmetry, #218 no-project store litter). - Inferential honesty of the World — family-selection without false-discovery
control (tracked: milestone "Inferential validation (defend against false
discovery at sweep scale)", #144 / #145 / #146; adjacent #139). The World's
massively-parallel sweep (C12 axes 1–2, C21) is aura's differentiator and its
most direct route to false discovery:
optimize/rank_byselect the single best family member by a bare argmax, with no penalty for the number of configurations tried, no preference for a robust parameter neighbourhood over a sharp in-sample peak, and no cross-instrument generalization read;mcis a descriptive seed-sweep, not an inferential significance test. The hygiene invariants keep one backtest honest (C1 determinism, C2 no look-ahead); they do not make a selection across a family honest. Recognized direction: the selection/aggregation layer must deflate a winner for the family size (#144), prefer a plateau over a peak (#145), and score cross-instrument generalization (#146), with a per-candidate out-of-sample bootstrap as the adjacent significance read (#139, landed cycle 0075). Distinct from the two threads above — neither orchestration composability (#109) nor a search policy (Bayesian/ genetic), but the statistical validity of the selection itself. Not a C-invariant until built; recorded as the World's third half — now structurally complete (all three pieces built, cycle 0078). Status (cycle 0076): the trials-deflation piece (#144) landed —optimize_deflated(aura-registry) wrapsoptimizeand records, additively on the winning member's manifest (C18, the newRunManifest.selection), a deflated score + an overfit probability for the family size, without changing which member wins (C23;optimize/rank_bystay a bare argmax — the deflation is recorded provenance, not a re-ranking). The R arm is a centred moving-block reality-check (reusing ther_bootstrapkernel); thetotal_pipsarm a closed-form expected-max-of-K dispersion floor. Status (cycle 0077): the plateau-over-peak piece (#145) landed —optimize_plateau(aura-registry) argmaxes the neighbourhood-smoothed metric surface (mean or worst-case over each member's closed mixed-radix grid neighbourhood) instead of the bare peak, recorded on the sameRunManifest.selectioncarrier, now an orthogonal rule × annotation:SelectionMode::{Argmax, PlateauMean, PlateauWorst}with either the deflation annotation (#144) or the plateau annotation (neighbourhood_score/n_neighbours). It is opt-in via a--selectflag — default argmax stays byte-identical (C23). The grid lattice surfaces from the engine (SweepBinder::sweep_with_lattice); the policy stays in aura-registry withwalk_forwardselection-agnostic (C9). Selection is now a pluggable objective (bare argmax → trials-deflated → plateau), not a single hard-wired argmax. Status (cycle 0078): the last piece — cross-instrument generalization (#146) — landed, completing the inferential half. Unlike #144/#145 (which select + annotate a within-family winner onRunManifest.selection), #146 is an aggregator/ validator, not a selector: theaura generalizesubcommand runs a brought candidate across an instrument list andgeneralization(aura-registry) reduces the per-instrument R-metrics to a worst-case floor (min over instruments — the cross-instrument sibling ofPlateauMode::Worst, R-only per C10) + a sign-agreement count + the per-instrument breakdown. It is a recomputable aggregate over a newFamilyKind::CrossInstrumentfamily (C12's comparison axis, realized), each member self-identifying via the newRunManifest.instrumentlineage field (C18) — not stamped onFamilySelection(there is no within-family winner to annotate). The inferential half is now structurally built; the per-candidate OOS bootstrap (#139, cycle 0075) is its adjacent significance read. aura-stdcontents — substantively populated (~30 node modules): SMA/EMA, arithmetic (Add/Sub/Mul/Sqrt/LinComb), logic (And/Gt/Latch/EqConst), theDelayz⁻¹ register,Resample,RollingMin/RollingMax,Session, the R chain (Bias/FixedStop/Sizer/PositionManagement), the legacySimBrokerpip yardstick, the cost-model graph (CostNode/CostRunner/CostSum+ConstantCost/VolSlippageCost/CarryCost), and theRecorder/GatedRecorder/SeriesReducersinks. Further blocks land demand-driven as the walking-skeleton needs them.strategies/split — a later split, inside a project, of top-level strategies from reusable building blocks innodes/; not a day-1 cut.- Sequencing — the runnable single-harness substrate comes first (walking skeleton: a closed harness that runs deterministically and records via a sink); the World/meta layer (C21) and the playground trace-explorer (C22) are the differentiating layers that follow — you orchestrate and visualize a thing that must first run once.