feat(0088): §B construction op-script surface — GraphSession/Op/replay + introspection

Iteration-1 §B of the construction service (#157), on the §A shared predicates
(ea1ca32): a declarative, replayable by-identifier op-script that builds a
runnable blueprint through validated ops, reusing the engine's existing gates at
two cadences — no second validator (C24).

- construction.rs: `Op` (Source/Input/Add/Feed/Connect/Expose, 1:1 with the
  document, dotted by-identifier ports), `OpError` (by-identifier causes),
  `GraphSession` (per-op-fallible accumulator) and the free `replay` driver
  (stop-at-first-failing-op, naming it by `(op_index, OpError)`).
  - Eager (per-op): name resolution + edge_kind_check + the >1-cover
    DoubleWiredPort arm + try_bind — all calling the §A shared predicates.
  - Holistic (finish): the 0-cover totality arm, param-namespace injectivity, and
    root-role boundness — the SAME unchanged engine gates
    (check_param_namespace_injective / validate_wiring), now at end-of-document.
  - Build-free introspection: `unwired()` (a partial document's still-open slots).
- aura-std std_vocabulary_types(): the enumerable companion to the closed
  std_vocabulary match (a `fn(&str)->Option<…>` resolver cannot be listed),
  lockstep-tested in the list->resolver direction; the reverse fails safe (#160).
- aura-engine exports replay/GraphSession/Op/OpError + re-exports BindOpError.

Acceptance (engine level): a sink-free price->fast/slow SMA->Sub->Bias signal
root built through the ops compiles to a FlatGraph byte-identical (edges +
sources) to its GraphBuilder twin (C1); an invalid op is rejected at the op,
named; vocabulary + unwired slots answer without a build. Pinned by
construction.rs unit tests + a construction_e2e.rs integration test over the
public seam the iteration-2 CLI will consume.

Notable judgement calls folded in from the implement loop: feed is all-or-nothing
(two-phase resolve-then-commit, no partial wiring on a mid-fan-out fault); the
node identifier is stamped onto the builder (`named(&id)`) so two same-type nodes
get distinct param paths and do not trip the injectivity gate prematurely; a
module-level `#![allow(dead_code)]` covers the public surface until the
iteration-2 CLI consumes it. Full suite green; clippy --all-targets -D warnings
clean.

Iteration 2 (the JSON op-list encoding + `aura graph build`/`introspect` CLI)
remains; #157 stays open. refs #157
This commit is contained in:
2026-06-29 20:28:41 +02:00
parent ea1ca32dbd
commit 27ac4dc537
5 changed files with 789 additions and 2 deletions
+1 -1
View File
@@ -83,5 +83,5 @@ pub use sma::Sma;
pub use sqrt::Sqrt;
pub use stop_rule::FixedStop;
pub use sub::Sub;
pub use vocabulary::std_vocabulary;
pub use vocabulary::{std_vocabulary, std_vocabulary_types};
pub use vol_slippage_cost::VolSlippageCost;
+41
View File
@@ -55,6 +55,22 @@ pub fn std_vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
})
}
/// 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. Kept in
/// lockstep with the `std_vocabulary` match arms by maintainer discipline — a
/// type added there must be added here. The reverse (an arm with no entry here)
/// is not test-enforced and cannot be, from a `fn` resolver; it fails safe (the
/// type stays load-resolvable, only unlisted; #160).
pub fn std_vocabulary_types() -> &'static [&'static str] {
&[
"Add", "And", "Bias", "CarryCost", "ConstantCost", "Delay", "EMA",
"EqConst", "FixedStop", "Gt", "Latch", "LongOnly", "Mul",
"PositionManagement", "Resample", "RollingMax", "RollingMin", "Sizer",
"SMA", "Sqrt", "Sub", "VolSlippageCost",
]
}
#[cfg(test)]
mod tests {
use super::std_vocabulary;
@@ -107,4 +123,29 @@ mod tests {
assert!(std_vocabulary("LinComb").is_none());
assert!(std_vocabulary("Recorder").is_none());
}
#[test]
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
use super::std_vocabulary_types;
// every listed type resolves to a builder carrying that exact label
for &type_id in std_vocabulary_types() {
let builder = std_vocabulary(type_id)
.unwrap_or_else(|| panic!("{type_id} listed but unresolvable"));
assert_eq!(
builder.label(),
type_id,
"listed type must round-trip to its label"
);
}
// the list is the WHOLE resolvable set: a couple of known non-members stay out
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
assert!(!std_vocabulary_types().contains(&"nope"));
// count guard: pins the list at exactly 22 entries, so a dropped or added
// list entry trips this; with the round-trip loop above, the list is
// exactly the 22 listed resolvable keys. The reverse drift — a resolver
// arm with no list entry — is not catchable from a `fn(&str)` resolver
// (not enumerable); it fails safe (load-resolvable, only unlisted; #160).
assert_eq!(std_vocabulary_types().len(), 22);
}
}