spec: 0105 std-vocabulary roster macro (boss-signed)

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
This commit is contained in:
2026-07-02 21:36:27 +02:00
parent 7b429f931a
commit 103a82dc2e
@@ -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<PrimitiveBuilder> {
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<PrimitiveBuilder>
└─ 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.