diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 2cfabeb..2532415 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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, input_roles: Vec, output: Vec, + taps: Vec, gangs: Vec, } @@ -189,7 +214,7 @@ impl Composite { input_roles: Vec, output: Vec, ) -> 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) -> 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 } }]); + } } diff --git a/crates/aura-engine/src/blueprint_serde.rs b/crates/aura-engine/src/blueprint_serde.rs index a6ae04f..7eb3d59 100644 --- a/crates/aura-engine/src/blueprint_serde.rs +++ b/crates/aura-engine/src/blueprint_serde.rs @@ -46,6 +46,8 @@ pub struct CompositeData { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub output: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub taps: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub gangs: Vec, } @@ -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 { /// 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 { @@ -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 diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 324f6e5..abdafcc 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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, diff --git a/crates/aura-engine/tests/blueprint_serde_e2e.rs b/crates/aura-engine/tests/blueprint_serde_e2e.rs index 1caf235..aebc759 100644 --- a/crates/aura-engine/tests/blueprint_serde_e2e.rs +++ b/crates/aura-engine/tests/blueprint_serde_e2e.rs @@ -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`, `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" + ); +}