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,
|
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 } }]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user