From 103a82dc2e9687acb9678ca31085abf55d34442c Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 2 Jul 2026 21:36:27 +0200 Subject: [PATCH] spec: 0105 std-vocabulary roster macro (boss-signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boss-signed via grounding-check PASS (fresh-context agent): all seven load-bearing current-behaviour assumptions ratified by named green tests — the two vocabulary unit tests (label round-trip + negatives; count==22 + non-members), the PrimitiveBuilder::label() oracle, and the four consumer pins (graph introspect --vocabulary e2e, engine loader UnknownNodeType, the 0102 merged-vocabulary charter suite, the canonical golden). Design basis: the mechanism recorded on #160 (declarative macro as single source for resolver match + enumerable list) plus the cycle-0105 reconciliation comment there (single pair-generating invocation; the test's inline roster copy replaced by iterating the generated list with label() as independent oracle; the un-rostered-node residual accepted as fail-safe and documented; public surface byte-identical). refs #180, refs #160 --- .../specs/0105-std-vocabulary-roster-macro.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/specs/0105-std-vocabulary-roster-macro.md diff --git a/docs/specs/0105-std-vocabulary-roster-macro.md b/docs/specs/0105-std-vocabulary-roster-macro.md new file mode 100644 index 0000000..a083c5f --- /dev/null +++ b/docs/specs/0105-std-vocabulary-roster-macro.md @@ -0,0 +1,201 @@ +# The std-vocabulary roster macro — Design Spec + +**Date:** 2026-07-02 +**Status:** Draft — awaiting sign-off (boss run: grounding-check gate) +**Authors:** orchestrator + Claude +**Cycle:** 0105 — milestone "Project environment — the project-as-crate +authoring loop" (#180); work item #160 (fork resolutions: the cycle-0105 +reconciliation comment on #160) + +## Goal + +Collapse the three hand-kept copies of the std-vocabulary roster — the +`std_vocabulary` match arms, the `std_vocabulary_types()` list, and the +test's inline 22-key list (`crates/aura-std/src/vocabulary.rs`) — into +**one declarative-macro source**, so the resolver and the enumerable list +cannot drift apart (the #160 failure mode: a node resolvable but silently +absent from `aura graph introspect --vocabulary`, or vice versa). +Behaviour is **byte-preserving**: both fn signatures, the 22 type ids, and +their order are unchanged; every consumer (loader resolvers, build-free +introspection, the 0102 merged-vocabulary charter checks) is untouched. +The vocabulary stays a **closed, compiled-in set** (domain invariant 9 / +C24) — a `macro_rules!` expansion is compile-time; no registry, no +distributed-slice machinery, no build script. + +## Architecture + +One new private `macro_rules! std_vocabulary_roster` in +`crates/aura-std/src/vocabulary.rs`, invoked exactly once with the +`"TypeId" => Type` pairs (current 22, current order). The expansion +produces both public fns with their doc comments. The unit tests stop +carrying their own roster copy: the round-trip test iterates the generated +`std_vocabulary_types()` (the independent oracle remains +`PrimitiveBuilder::label()` — a typo'd roster key still fails the +lookup-label agreement, so the test is not self-referential), and the +shape test keeps the count pin (`== 22`, deliberate friction: any roster +change forces a conscious test update) plus the non-member checks. + +**Accepted residual (documented at the roster site, per the #160 +reconciliation):** a new zero-arg node never added to the roster at all +remains unguarded — no enumeration of zero-arg builders exists to test +against — and fails safe in both directions (clean +`LoadError::UnknownNodeType` on load; merely absent from `--vocabulary`). +The practical guard is the shrunk maintainer surface: adding a node to the +vocabulary is exactly one roster line instead of three coordinated edits. + +## Concrete code shapes + +### The maintainer-facing surface (the acceptance-criterion evidence) + +Adding a future zero-arg node `Foo` to the vocabulary is one line in one +place — both surfaces (resolver + enumerable list) gain it atomically: + +```rust +std_vocabulary_roster! { + "Add" => Add, + "And" => And, + // ... + "Foo" => Foo, // <- the whole vocabulary edit (#160) + // ... + "VolSlippageCost" => VolSlippageCost, +} +``` + +(plus the count-pin bump `22 -> 23` in the shape test — the deliberate +tripwire that makes the roster change a conscious act). + +### The macro (secondary, before → after) + +Before: two hand-written fns whose bodies each carry the full roster +(`vocabulary.rs:30-56` match, `:65-72` list), kept in lockstep "by +maintainer discipline" per the current `std_vocabulary_types` doc comment. + +After — the two fns are generated from one roster: + +```rust +/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg +/// node, expanded into BOTH the `std_vocabulary` match and the +/// `std_vocabulary_types` list — the two surfaces cannot drift apart, and +/// adding a zero-arg node to the vocabulary is exactly one line here. What +/// this cannot guard: a new zero-arg node never rostered at all — that fails +/// safe (clean `LoadError::UnknownNodeType` on load, merely absent from +/// `--vocabulary`; #160). +macro_rules! std_vocabulary_roster { + ($($type_id:literal => $ty:ty),+ $(,)?) => { + /// Resolve a serialized node type identity (the `PrimitiveBuilder::label`, + /// e.g. `"SMA"`) to a fresh, unbound builder from the node's own factory. + /// Returns `None` for any identity outside the zero-arg `aura-std` + /// vocabulary. + pub fn std_vocabulary(type_id: &str) -> Option { + Some(match type_id { + $($type_id => <$ty>::builder(),)+ + _ => return None, + }) + } + + /// The enumerable companion to [`std_vocabulary`]: the closed set of + /// zero-arg type identities the resolver answers. A `fn(&str)->Option<…>` + /// resolver cannot be listed, so build-free vocabulary introspection + /// (#157) reads this. Generated from the same roster as the match arms + /// (#160) — the two surfaces agree by construction. + pub fn std_vocabulary_types() -> &'static [&'static str] { + &[$($type_id),+] + } + }; +} + +std_vocabulary_roster! { + "Add" => Add, + "And" => And, + "Bias" => Bias, + "CarryCost" => CarryCost, + "ConstantCost" => ConstantCost, + "Delay" => Delay, + "EMA" => Ema, + "EqConst" => EqConst, + "FixedStop" => FixedStop, + "Gt" => Gt, + "Latch" => Latch, + "LongOnly" => LongOnly, + "Mul" => Mul, + "PositionManagement" => PositionManagement, + "Resample" => Resample, + "RollingMax" => RollingMax, + "RollingMin" => RollingMin, + "Sizer" => Sizer, + "SMA" => Sma, + "Sqrt" => Sqrt, + "Sub" => Sub, + "VolSlippageCost" => VolSlippageCost, +} +``` + +The `use crate::{...}` import list and the module doc (`:1-18`) stay; the +module doc's scope note (construction-arg builders and sinks deliberately +absent) continues to hold — the macro changes how the roster is *written*, +not what is in it. + +### The tests (secondary, before → after) + +`std_vocabulary_resolves_known_and_rejects_unknown` drops its inline +22-key array and iterates `super::std_vocabulary_types()`; the label +round-trip assertion and the three negative probes (`"nope"`, +`"LinComb"`, `"Recorder"`) are unchanged. +`std_vocabulary_types_lists_exactly_the_resolvable_keys` keeps the count +pin (`assert_eq!(std_vocabulary_types().len(), 22)`) and the non-member +checks, and drops its per-key round-trip loop (now the first test's job +over the same generated list); its comment records that list ↔ resolver +agreement holds by construction since the roster macro. + +## Components + +| Component | Crate | New/changed | +|---|---|---| +| `std_vocabulary_roster!` macro + single invocation | aura-std (`vocabulary.rs`) | new (private) | +| `std_vocabulary` / `std_vocabulary_types` | aura-std | regenerated, signatures + bytes-out unchanged | +| The two unit tests | aura-std | reshaped (no inline roster copy) | + +Untouched by design: every consumer — `blueprint_from_json` resolver +injection sites, `aura graph introspect --vocabulary` (via +`project::Env::type_ids`), the 0102 project-vocabulary merge and charter +checks, all e2e tests. + +## Data flow + +``` +std_vocabulary_roster! { "TypeId" => Type, ... } (ONE source) + ├─ expands ─► pub fn std_vocabulary(&str) -> Option + └─ expands ─► pub fn std_vocabulary_types() -> &'static [&'static str] +``` + +## Error handling + +No new failure modes: the macro is a compile-time expansion; a malformed +roster line is a compile error. Runtime behaviour (Some/None, the list) +is byte-identical to today. + +## Testing strategy + +1. **Unit (aura-std, reshaped as above):** label round-trip over the + generated list + negative probes; count pin `== 22` + non-member + checks. +2. **Regression:** full workspace suite green **unchanged** — no test + count change beyond the reshapes (no new tests, none removed); in + particular the consumers' pins stay green: + `graph_introspect_vocabulary_lists_the_node_types` (e2e), + `unknown_node_type_fails_named` (engine loader), the + `tests/project_load.rs` merged-vocabulary suite (aura-cli), and the + canonical golden. + +## Acceptance criteria + +1. Adding a zero-arg node to the vocabulary is exactly one roster line + (plus the conscious count-pin bump) — shown above; the three-copy + lockstep is gone from the file. +2. `std_vocabulary` and `std_vocabulary_types` are byte-identical in + behaviour: same signatures, same 22 ids, same order; the full suite is + green unchanged. +3. The vocabulary remains a closed, compiled-in set (invariant 9 / C24): + no registry, no build script, no runtime registration. +4. The accepted residual (an un-rostered new node, fails safe) is + documented at the roster site — the deferral recorded on #160.