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:
@@ -33,6 +33,30 @@ pub struct OutField {
|
||||
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
|
||||
/// interface (typed inputs + one output) to the enclosing graph.
|
||||
pub enum BlueprintNode {
|
||||
@@ -174,6 +198,7 @@ pub struct Composite {
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Role>,
|
||||
output: Vec<OutField>,
|
||||
taps: Vec<Tap>,
|
||||
gangs: Vec<Gang>,
|
||||
}
|
||||
|
||||
@@ -189,7 +214,7 @@ impl Composite {
|
||||
input_roles: Vec<Role>,
|
||||
output: Vec<OutField>,
|
||||
) -> 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.
|
||||
@@ -207,6 +232,16 @@ impl Composite {
|
||||
pub fn doc(&self) -> Option<&str> {
|
||||
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).
|
||||
pub fn nodes(&self) -> &[BlueprintNode] {
|
||||
&self.nodes
|
||||
@@ -1166,7 +1201,7 @@ fn inline_composite(
|
||||
// `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
|
||||
// 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();
|
||||
|
||||
// recursively lower interior items, then rewrite interior edges through them
|
||||
@@ -3571,4 +3606,15 @@ mod tests {
|
||||
let bn: BlueprintNode = c.into();
|
||||
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 } }]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct CompositeData {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output: Vec<OutField>,
|
||||
#[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>,
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@ fn project(c: &Composite) -> CompositeData {
|
||||
edges: c.edges().to_vec(),
|
||||
input_roles: c.input_roles().to_vec(),
|
||||
output: c.output().to_vec(),
|
||||
taps: c.taps().to_vec(),
|
||||
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
|
||||
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
|
||||
/// bound-param names, role names, output re-export names. Everything load-bearing
|
||||
/// survives: type ids, node order, edges, role targets/order, output pairs/order,
|
||||
/// and bound positions/kinds/values (param openness stays identity-bearing: a
|
||||
/// bound slot is textually present, an open one absent). Research-side comparison
|
||||
/// bound-param names, role names, output re-export names, tap names. Everything
|
||||
/// load-bearing survives: type ids, node order, edges, role targets/order, output
|
||||
/// pairs/order, tap interior wires, and bound positions/kinds/values (param
|
||||
/// 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
|
||||
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
|
||||
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 {
|
||||
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 {
|
||||
gang.name = String::new();
|
||||
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())
|
||||
.with_taps(d.taps.clone())
|
||||
.with_gangs(d.gangs.clone())
|
||||
.map_err(LoadError::Gang)?;
|
||||
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]
|
||||
fn unknown_node_type_fails_named() {
|
||||
// a valid envelope naming a type outside the vocabulary -> clean, named error
|
||||
|
||||
@@ -55,7 +55,7 @@ mod walkforward;
|
||||
|
||||
pub use blueprint::{
|
||||
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::{
|
||||
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_engine::{
|
||||
blueprint_from_json, blueprint_to_json, BlueprintNode, Composite, Edge, LoadError, OutField,
|
||||
Role, Target, VecSource, BLUEPRINT_FORMAT_VERSION,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintNode, Composite,
|
||||
Edge, LoadError, OutField, Role, Target, VecSource, BLUEPRINT_FORMAT_VERSION,
|
||||
};
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user