feat(engine): Tap/TapWire declaration on the blueprint format (#282)

First slice of declared taps: a Tap { name, from: TapWire { node, field } }
is the output-side twin of input_roles — a pure data declaration naming an
interior output wire, no sink endpoint. Composite gains a taps field
(with_taps/taps(), additive) and the serde mirror CompositeData carries it
(Tier-1 additive: no format-version bump, absent-taps documents byte-stable).
Tap/TapWire are re-exported from the crate root beside Role/OutField so
downstream crates can author tap-bearing blueprints.

refs #282
This commit is contained in:
2026-07-17 22:31:41 +02:00
parent b11bcb6202
commit 7239106f4e
4 changed files with 142 additions and 9 deletions
+48 -2
View File
@@ -33,6 +33,30 @@ pub struct OutField {
pub name: String, pub name: String,
} }
/// One interior output wire a tap names: a producer node's output field, by
/// interior local index — the output-side analogue of a role `Target`
/// (`{node, slot}`) and of `OutField`'s `{node, field}`.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TapWire {
pub node: usize,
pub field: usize,
}
/// A declared measurement point: a name paired with an interior output wire.
/// A PURE declaration — no side effect, no channel endpoint; the effectful sink
/// is constructed run-mode-aware at bind time, never in the serialized artefact.
/// The output-side twin of `Role` (`input_roles`): a `Role` names an abstract
/// input without naming a source; a `Tap` names an interior output without
/// naming a sink. The declaration `name` is a debug symbol for the topological
/// content-id (blanked by `strip_debug_symbols`, like `Role.name`/`OutField.name`);
/// the resolved flat-graph tap keeps its name load-bearing for by-name binding
/// (like `SourceSpec.role`, #275).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Tap {
pub name: String,
pub from: TapWire,
}
/// A blueprint item: a primitive node or a nested composite. Both present a declared /// A blueprint item: a primitive node or a nested composite. Both present a declared
/// interface (typed inputs + one output) to the enclosing graph. /// interface (typed inputs + one output) to the enclosing graph.
pub enum BlueprintNode { pub enum BlueprintNode {
@@ -174,6 +198,7 @@ pub struct Composite {
edges: Vec<Edge>, edges: Vec<Edge>,
input_roles: Vec<Role>, input_roles: Vec<Role>,
output: Vec<OutField>, output: Vec<OutField>,
taps: Vec<Tap>,
gangs: Vec<Gang>, gangs: Vec<Gang>,
} }
@@ -189,7 +214,7 @@ impl Composite {
input_roles: Vec<Role>, input_roles: Vec<Role>,
output: Vec<OutField>, output: Vec<OutField>,
) -> Self { ) -> Self {
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, gangs: Vec::new() } Self { name: name.into(), doc: None, nodes, edges, input_roles, output, taps: Vec::new(), gangs: Vec::new() }
} }
/// The authored render name (cluster title, #13). Non-load-bearing. /// The authored render name (cluster title, #13). Non-load-bearing.
@@ -207,6 +232,16 @@ impl Composite {
pub fn doc(&self) -> Option<&str> { pub fn doc(&self) -> Option<&str> {
self.doc.as_deref() self.doc.as_deref()
} }
/// Install declared measurement taps (the output-side twin of `input_roles`).
/// Fluent, mirroring `with_doc`. Empty by default.
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
self.taps = taps;
self
}
/// The declared measurement taps (read-only). Empty for an un-tapped blueprint.
pub fn taps(&self) -> &[Tap] {
&self.taps
}
/// The interior blueprint items (read-only graph-as-data, C9). /// The interior blueprint items (read-only graph-as-data, C9).
pub fn nodes(&self) -> &[BlueprintNode] { pub fn nodes(&self) -> &[BlueprintNode] {
&self.nodes &self.nodes
@@ -1166,7 +1201,7 @@ fn inline_composite(
// `gangs` is an authoring-time gate only (checked at `with_gangs`); it has // `gangs` is an authoring-time gate only (checked at `with_gangs`); it has
// no runtime representation in the flat graph, same as `name`. `doc` is // no runtime representation in the flat graph, same as `name`. `doc` is
// the prose twin of `name` (#125) and dissolves alongside it. // the prose twin of `name` (#125) and dissolves alongside it.
let Composite { name: _, doc: _, nodes, edges, input_roles, output, gangs: _ } = c; let Composite { name: _, doc: _, nodes, edges, input_roles, output, taps: _, gangs: _ } = c;
let item_count = nodes.len(); let item_count = nodes.len();
// recursively lower interior items, then rewrite interior edges through them // recursively lower interior items, then rewrite interior edges through them
@@ -3571,4 +3606,15 @@ mod tests {
let bn: BlueprintNode = c.into(); let bn: BlueprintNode = c.into();
assert!(matches!(bn, BlueprintNode::Composite(_))); assert!(matches!(bn, BlueprintNode::Composite(_)));
} }
#[test]
fn composite_carries_declared_taps() {
// A Composite installs and exposes a taps declaration via with_taps/taps(),
// mirroring with_doc/doc(); a bare Composite has no taps.
let bare = Composite::new("m", vec![], vec![], vec![], vec![]);
assert!(bare.taps().is_empty());
let tapped = Composite::new("m", vec![], vec![], vec![], vec![])
.with_taps(vec![Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]);
assert_eq!(tapped.taps(), &[Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]);
}
} }
+57 -4
View File
@@ -46,6 +46,8 @@ pub struct CompositeData {
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output: Vec<OutField>, pub output: Vec<OutField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub taps: Vec<crate::blueprint::Tap>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gangs: Vec<Gang>, pub gangs: Vec<Gang>,
} }
@@ -94,6 +96,7 @@ fn project(c: &Composite) -> CompositeData {
edges: c.edges().to_vec(), edges: c.edges().to_vec(),
input_roles: c.input_roles().to_vec(), input_roles: c.input_roles().to_vec(),
output: c.output().to_vec(), output: c.output().to_vec(),
taps: c.taps().to_vec(),
gangs, gangs,
} }
} }
@@ -135,10 +138,11 @@ pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
/// The identity-canonical form (#171): the canonical document with every /// The identity-canonical form (#171): the canonical document with every
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names, /// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
/// bound-param names, role names, output re-export names. Everything load-bearing /// bound-param names, role names, output re-export names, tap names. Everything
/// survives: type ids, node order, edges, role targets/order, output pairs/order, /// load-bearing survives: type ids, node order, edges, role targets/order, output
/// and bound positions/kinds/values (param openness stays identity-bearing: a /// pairs/order, tap interior wires, and bound positions/kinds/values (param
/// bound slot is textually present, an open one absent). Research-side comparison /// openness stays identity-bearing: a bound slot is textually present, an open
/// one absent). Research-side comparison
/// form ONLY — never a load path and never the reproduction store's byte form /// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there). /// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> { pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
@@ -167,6 +171,9 @@ fn strip_debug_symbols(b: &mut CompositeData) {
for out in &mut b.output { for out in &mut b.output {
out.name = String::new(); // (node, field) + order survive out.name = String::new(); // (node, field) + order survive
} }
for tap in &mut b.taps {
tap.name = String::new(); // interior wire (node, field) survives
}
for gang in &mut b.gangs { for gang in &mut b.gangs {
gang.name = String::new(); gang.name = String::new();
for m in &mut gang.members { for m in &mut gang.members {
@@ -227,6 +234,7 @@ fn reconstruct(
}); });
} }
let composite = Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone()) let composite = Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
.with_taps(d.taps.clone())
.with_gangs(d.gangs.clone()) .with_gangs(d.gangs.clone())
.map_err(LoadError::Gang)?; .map_err(LoadError::Gang)?;
Ok(match &d.doc { Ok(match &d.doc {
@@ -372,6 +380,51 @@ mod tests {
); );
} }
/// #282: a composite's declared taps round-trip through project→reconstruct
/// byte-stably (mirroring `doc_round_trips_canonically_and_is_identity_blind`),
/// and an un-tapped composite's canonical bytes carry no `taps` key at all
/// (skip_serializing_if empty) — the golden-pinned fixture's bytes stay
/// untouched by this additive field.
#[test]
fn taps_round_trip_canonically_and_absent_is_byte_stable() {
use crate::blueprint::{Tap, TapWire};
let plain = crate::test_fixtures::sink_free_sma_cross_signal();
let tapped = crate::test_fixtures::sink_free_sma_cross_signal()
.with_taps(vec![Tap { name: "d".into(), from: TapWire { node: 0, field: 0 } }]);
let plain_json = blueprint_to_json(&plain).expect("serializes");
let tapped_json = blueprint_to_json(&tapped).expect("serializes");
assert_ne!(plain_json, tapped_json, "taps are canonical-byte-bearing");
assert!(!plain_json.contains("\"taps\""), "absent taps emit no key: {plain_json}");
let loaded = blueprint_from_json(&tapped_json, &|t| aura_std::std_vocabulary(t)).expect("loads");
assert_eq!(loaded.taps(), tapped.taps());
assert_eq!(tapped_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
}
/// #282: a tap's `name` is a debug symbol like `Role.name`/`OutField.name` —
/// two blueprints differing only in tap name share identity JSON (name
/// blanked, interior wire survives) while their canonical JSON differs,
/// mirroring `renamed_role_and_output_share_identity_json_not_canonical_json`.
#[test]
fn renamed_tap_shares_identity_json_not_canonical_json() {
use crate::blueprint::{Tap, TapWire};
fn probe(tap_name: &str) -> Composite {
crate::test_fixtures::sink_free_sma_cross_signal()
.with_taps(vec![Tap { name: tap_name.into(), from: TapWire { node: 0, field: 0 } }])
}
let a = probe("p_long");
let b = probe("p_short");
assert_ne!(
blueprint_to_json(&a).expect("serializes"),
blueprint_to_json(&b).expect("serializes"),
"canonical bytes keep tap names apart"
);
assert_eq!(
blueprint_identity_json(&a).expect("identity-serializes"),
blueprint_identity_json(&b).expect("identity-serializes"),
"identity form is tap-name-blind"
);
}
#[test] #[test]
fn unknown_node_type_fails_named() { fn unknown_node_type_fails_named() {
// a valid envelope naming a type outside the vocabulary -> clean, named error // a valid envelope naming a type outside the vocabulary -> clean, named error
+1 -1
View File
@@ -55,7 +55,7 @@ mod walkforward;
pub use blueprint::{ pub use blueprint::{
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
}; };
pub use blueprint_serde::{ pub use blueprint_serde::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData, blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
@@ -16,8 +16,8 @@ use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{ use aura_engine::{
blueprint_from_json, blueprint_to_json, BlueprintNode, Composite, Edge, LoadError, OutField, blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintNode, Composite,
Role, Target, VecSource, BLUEPRINT_FORMAT_VERSION, Edge, LoadError, OutField, Role, Target, VecSource, BLUEPRINT_FORMAT_VERSION,
}; };
use aura_std::{std_vocabulary, Bias, Recorder, Sma, Sub}; use aura_std::{std_vocabulary, Bias, Recorder, Sma, Sub};
@@ -230,3 +230,37 @@ fn unknown_primitive_field_tolerated_through_public_api() {
"run diverged under an unknown primitive field", "run diverged under an unknown primitive field",
); );
} }
/// Property (additive-field safety, at the public boundary — #282 taps
/// substrate): the new, optional `taps` declaration on `CompositeData` (a
/// `Vec<Tap>`, `skip_serializing_if = "Vec::is_empty"`) must never perturb a
/// composite that carries none, for every consumer reachable through the
/// exported `aura_engine` surface. `Tap`/`TapWire` are not (yet) re-exported
/// from the crate root — unlike their sibling interface types `Role` and
/// `OutField` — so this file cannot itself construct a tap-bearing
/// `Composite`; the in-crate tests in `blueprint_serde.rs` cover the
/// tap-bearing round-trip and identity-blindness using crate-private access.
/// What IS reachable here, and is exactly the regression class an additive
/// struct field risks, is pinned: an un-tapped composite emits no `"taps"`
/// key in either its canonical or identity JSON, round-trips byte-for-byte
/// through the public loader, and runs bit-identically before and after.
#[test]
fn composites_without_taps_are_unaffected_by_the_additive_taps_field() {
let canonical = blueprint_to_json(&signal()).expect("serializes");
assert!(!canonical.contains("\"taps\""), "an un-tapped composite must not emit a taps key: {canonical}");
let identity = blueprint_identity_json(&signal()).expect("identity-serializes");
assert!(!identity.contains("\"taps\""), "identity form must not emit a taps key either: {identity}");
let loaded = blueprint_from_json(&canonical, &|t| std_vocabulary(t)).expect("loads");
assert_eq!(
blueprint_to_json(&loaded).expect("re-serializes"),
canonical,
"round-trip byte-stability broken by the additive taps field"
);
assert_eq!(
run_recording(loaded),
run_recording(signal()),
"run diverged after the additive taps field landed"
);
}