Commit Graph

8 Commits

Author SHA1 Message Date
claude a32dc38d18 feat(std): meaning lines for all 27 shipped node schemas
C29 compile/unit seam, task 3 of the self-description plan: every
aura-std NodeSchema literal threads its one-line doc. Four texts were
corrected against the actual eval/finalize semantics rather than taken
from the plan table verbatim: Delay is a lag-N register (not one-step),
GatedRecorder flushes an ungated final row at finalize, Latch is a
level-sensitive set/reset register (captures no input), SeriesReducer
emits its single summary row at finalize (not per cycle).

Gate: cargo build -p aura-std --lib clean; full std test run follows at
the all-crates gate once strategy/backtest/engine thread their sites
(the earlier per-crate test gate was unsatisfiable in isolation --
std's dev-deps pull crates whose sites belong to later tasks).

refs #316
2026-07-23 15:25:16 +02:00
Brummel 43716be10e refactor(aura-engine): Cell into the construction path — param-plane base/frontend split
Cell becomes the carrier of the construction path; Scalar narrows to the author/render boundaries. The validated/enumerated param point carries no redundant kind (it lives once, in the declared param-space); at the author edge the kind is a checksum — two independent sources, the typed value vs. the slot — so the self-describing Scalar stays there. The name->slot binding is dynamic (C23), so the check is necessarily a runtime one and the value must self-describe for it.

Base/frontend split (the AnyColumn push/push_cell pattern one level up): compile_with_cells / bootstrap_with_cells are the kind-check-free base; compile_with_params / bootstrap_with_params are the frontend that adds only the per-value kind checksum, strips to cells (new Scalar::cell(), the partner of Scalar::from_cell), and delegates. lower_items loses its per-primitive kind-check; PrimitiveBuilder::build and the std node builders (sma/ema/exposure/lincomb) read cells.

Boundary: construction -> Cell (PrimitiveBuilder::build, lower_items, GridSpace.axes/points, SweepPoint.params, the sweep closure). Author edges stay Scalar (GridSpace::new, bind, compile_with_params/bootstrap_with_params). walkforward chosen_params stays a self-describing Scalar report record (Option A — WalkForwardResult carries no space); the cell winner is reconstructed once at the WindowRun site via from_cell.

injective-check moved ahead of the arity-check in the frontend to preserve the pre-split error order (DuplicateParamPath before ParamArity). The lossy i64->f64 param projection (scalar_as_f64 / scalar_as_param_f64) is deliberately untouched — a separate follow-up.

Behaviour-preserving (C1): build --all-targets / test / clippy -D warnings / doc all clean across the workspace.
2026-06-16 16:22:17 +02:00
Brummel 82635fa259 refactor: Cell becomes the hot-path value carrier (C7/C8 realization)
Motivation
----------
The C7 realization note (cd3d1ca / 049f22a) deferred this step: with
Scalar split into { kind, cell }, the tag-free Cell could become the
hot-path carrier, but eval and the edges still flowed the fatter
self-describing Scalar. This lands the swap.

Change
------
- Node::eval now returns Option<&[Cell]>; every node out-buffer is
  [Cell; N]. The inter-node forward copies the row into a Vec<Cell> and
  pushes each field via a new branch-free, infallible AnyColumn::push_cell
  — the edge from_field->slot kind match is verified once at bootstrap
  (C7), so the runtime push needs no per-value kind check. It only removes
  a Result the forward path already .expect()ed.
- Scalar stays 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 fallible
  AnyColumn::push is retained for ingestion + the kind-guard tests.

Trade
-----
The per-value runtime kind check on node OUTPUT is removed: a node that
builds a wrong-kind cell pushes raw bits with no panic. This 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 eval test. Cross-node kind agreement still rests
on the bootstrap kind-check (bootstrap_rejects_*_kind_mismatch, green).

Tests
-----
Behaviour-preserving (C1): 285 green, 0 red. The only test-expectation
edits are carrier re-spellings (Scalar::f64(x) -> Cell::from_f64(x)) of
identical values; test column writes and out-of-graph channel rows stay
Scalar (the spec's three-role rule). Adds an AnyColumn::push_cell
round-trip test plus a cross-kind end-to-end forward test (a mixed
f64/i64 record whose bit patterns diverge across kinds — pins the i64
forward path the f64-only tests cannot catch). Ledger C7/C8 notes updated.

Gates: cargo build / test / clippy --all-targets -D warnings / doc
--workspace all clean.

closes #74
2026-06-16 13:17:49 +02:00
Brummel cd3d1ca9ed refactor(aura-core): split Scalar into a tag-free Cell + ScalarKind
Motivation
----------
`Scalar` was a tagged enum (I64/F64/Bool/Ts), so every scalar value
physically carried its own kind tag. But the kind is already known from
the schema/port/column the value flows through (C7: the type is a
property of the column, not of the value — the hot path is already
columnar `Column<T>`, and `AnyColumn::get` *reconstructs* the tag from
the column on the way out). The per-value tag was therefore redundant
with the kind the surrounding context already holds.

That redundancy had three costs:

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

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

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

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

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

Verified: cargo build --workspace --all-targets, cargo clippy --workspace
--all-targets -- -D warnings, cargo test --workspace — all green.
2026-06-16 12:12:52 +02:00
Brummel a8bd52eaff refactor(aura-core): own FieldSpec.name to drop the derive_signature heap leak
FieldSpec.name was &'static str, forcing derive_signature to Box::leak
every re-exported composite OutField name. Because compile_with_params
reruns derive_signature per sweep point, an N-point sweep over an
M-output-field composite leaked N×M boundary names unbounded — a scaling
wall the moment the World/sweep layer compiles families per point.

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

Behaviour-preserving: signature contents unchanged, all 231 tests green.
2026-06-14 22:21:15 +02:00
Brummel e304dbaae1 feat(aura-core): name input ports
PortSpec gains a non-load-bearing `name: String`, so an input port is named just
as FieldSpec.name (output) and ParamSpec.name (param) already are — input ports
were the lone unnamed member of the node signature. Identity stays positional by
slot (C23); the name is render/debug only, never read by bootstrap or the run
loop. PortSpec drops Copy (String is not Copy), exactly as ParamSpec already does.

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

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

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

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

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

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

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

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

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

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

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

closes #43 #36
2026-06-09 12:49:22 +02:00
Brummel ae1d4564f5 feat(aura-std): EMA node (SMA-seeded, O(1) per cycle)
Add Ema, the std-lib's first recursive/stateful producer: exponential moving average with alpha = 2/(length+1). The building block MACD (and DEMA, MACD-histogram, etc.) needs, and a worked example of a node whose param sizes its math, not its input window.

Seeding is ratified as SMA-seeded (ta-lib convention): the EMA stays silent until it has seen length samples, seeds with their SMA, then runs the recurrence. Chosen over a first-value seed for three substantive reasons -- warm-up consistency with Sma (both emit None until length samples), parity with the EMA traders expect (ta-lib MACD seeds this way), and statistical honesty (no average claimed from a single observation). A first-value seed was the proof-of-concept convenience; rejected.

Performance: recursive state means lookback = 1 (length sizes alpha, not the window); eval is O(1) time and state via ema += alpha*(x - ema), with no hot-path allocation (the output buffer is reused).

Tested: warm-up-then-smooth (a step input separates it from a plain SMA), length-1 identity, label, and factory/schema agreement.
2026-06-07 23:20:25 +02:00