Commit Graph

7 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

closes #1
refs walking-skeleton

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

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

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

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

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

refs walking-skeleton

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:21:42 +02:00
Brummel dd5c3fad96 feat(engine): the deterministic single-source sim loop
Cycle 0003: aura-engine's first real content — a Sim that runs a wired DAG of
nodes deterministically, cycle by cycle. First point in the project where
authored nodes execute against data, not just under a hand-fed Ctx.

- `Sim` (aura-engine) — a bootstrapped, frozen root graph: a flat Vec<NodeBox>
  (each node owns its input columns, the cycle-0002 shape) + an index edge
  table, topologically ordered (Kahn). `bootstrap` sizes every input column from
  its node's schema, kind-checks each edge and source target, and rejects
  directed cycles — C7's "type check paid once at wiring" generalized to the
  whole topology (`BootstrapError::{KindMismatch, BadIndex, Cycle}`).
- `run` — the deterministic loop: per record, forward the source value into its
  target slots, evaluate nodes in topo order, capture the observed node's output
  at eval time, and forward each `Some` output into its consumers' input columns
  (a `None` forwards nothing — the structural seed of sample-and-hold). The loop
  destructures `&mut self` into disjoint field borrows and allocates nothing on
  the per-cycle path. This is the flat, monomorphized sharpening of RustAst's
  reference-counted, interior-mutable observer push graph (C1/C7).
- `Edge` / `Target` (aura-engine) — producer->consumer and source->consumer wiring.
- `Sub` (aura-std) — a 2-input f64-difference node, so the loop is proven on a
  real fan-out + join DAG (source -> {SMA(2), SMA(4)} -> Sub), with a determinism
  assertion (C1: a second identical run is bit-identical).

Engine library depends only on aura-core; a test-only dev-dependency on aura-std
lets the integration test wire real Sma/Sub nodes. Sim carries a hand-written,
node-opaque `Debug` impl (Box<dyn Node> is not Debug; the impl prints
nodes.len() + the index/topology fields, needed by the bootstrap-rejection tests'
`unwrap_err`).

Deliberately deferred (recorded as decisions): freshness-gated recompute /
sample-and-hold (C5 -> cycle 0004, with the second source that makes it testable);
the explicit monotonic cycle_id counter (its first reader is freshness, 0004);
the Source trait + data-server ingestion + k-way merge (C3/C11); the builder API
(C19); the real sink + run registry (C18/C22).

Gates green: cargo build/test (26: 18 aura-core + 3 aura-std + 5 aura-engine)/
clippy -D warnings clean; surface-purity grep (no dyn-Any / Rc / RefCell) clean.

refs walking-skeleton
2026-06-03 13:02:47 +02:00