From cf18921ad98e492c0b44c54f203850593afeaa66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 03:00:10 +0000 Subject: [PATCH] Tier-2 captures: `func!([fee] move |p| p - fee)` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature that turns "the mechanism works" into "the workload works". A closed closure's body resolves anywhere, so tier 1 could splice it directly. A capturing one cannot: `move |p| p * fee` refers to a binding that exists only in the wiring, so the generator had to refuse it. That made the case §7 justifies the whole project with — per-instrument pipelines built from a config — only half expressible: per-instrument *topology* and *data* configs worked, per-instrument *parameters* did not. Found by writing the example, not by reasoning about it. `func!` now takes an explicit capture list. Each name is recorded by **value**, rendered through `EmitLiteral`, and `emittable_src` wraps the body in a block that re-materialises them: let fee = 2.5f64; func!([fee] move |p: &f64| p - fee).emittable_src() // "{ let fee = 2.5f64; move |p: &f64| p - fee }" `Stream::with_src` records that emittable form rather than the bare body, so `NodeInfo::src` is now `Option` — a tier-2 quotation assembles its text at wiring time instead of carrying a token. `tests/codegen_emission.rs` generates a two-instrument desk end to end, each leg with its own frozen fee, and asserts parity on values and tick times. The expected artifact is a real `nitro!` block in the file, so the re-materialised capture blocks are proven valid Rust by the file compiling — not by inspection. Bounded by `EmitLiteral`, so a capture of an arbitrary struct is a compile error at the `func!` call site, where it can be understood. And frozen, per §3: the emitted block carries the value quoted at generation time. What is still not caught: an *undeclared* capture. §3 catches it by coercing through a fn pointer, which this macro cannot do — the coercion names an arity (`fn(&_) -> _`), so it would make `func!` unusable for `join` and `fold`. It surfaces instead as a pass-2 compile error, with a breadcrumb pointing at the wiring. D28 updated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X4eojqRWBJ6uK5v5JDzmkd --- crates/wingfoil/src/codegen.rs | 2 +- crates/wingfoil/src/fluent.rs | 12 ++- crates/wingfoil/src/interp.rs | 25 +++-- crates/wingfoil/src/quote.rs | 122 +++++++++++++++++++--- crates/wingfoil/tests/codegen_emission.rs | 109 ++++++++++++++++++- crates/wingfoil/tests/quotation.rs | 107 +++++++++++++++++-- docs/deviation-register.md | 2 +- 7 files changed, 339 insertions(+), 40 deletions(-) diff --git a/crates/wingfoil/src/codegen.rs b/crates/wingfoil/src/codegen.rs index 2268fc18d..3f62e8cb8 100644 --- a/crates/wingfoil/src/codegen.rs +++ b/crates/wingfoil/src/codegen.rs @@ -275,7 +275,7 @@ pub fn emit_with_tail( /// The config arguments of one node, in call order: data first, then the /// closure — matching every such signature in the catalog (`fold(seed, f)`). fn config_args(n: &NodeInfo) -> String { - match (n.cfg_src.as_deref(), n.src) { + match (n.cfg_src.as_deref(), n.src.as_deref()) { (Some(cfg), Some(src)) => format!("{cfg}, {src}"), (Some(cfg), None) => cfg.to_string(), (None, Some(src)) => src.to_string(), diff --git a/crates/wingfoil/src/fluent.rs b/crates/wingfoil/src/fluent.rs index 237e290bf..06045f09f 100644 --- a/crates/wingfoil/src/fluent.rs +++ b/crates/wingfoil/src/fluent.rs @@ -684,7 +684,7 @@ impl Stream { /// let double = func!(|i: &u64| i * 2); /// let doubled = ticks.map(double.f).with_src(&double); /// - /// assert_eq!(Some("|i: &u64| i * 2"), doubled.src()); + /// assert_eq!(Some("|i: &u64| i * 2"), doubled.src().as_deref()); /// ``` /// /// **One method, every op.** It annotates the node a stream refers to, so @@ -701,16 +701,18 @@ impl Stream { "invariant: annotating a Stream after GraphBuilder::build(); the \ graph is already consumed. Annotate before calling build()" ); - self.inner - .borrow_mut() - .set_node_src(self.handle.index(), quoted.src, quoted.loc); + self.inner.borrow_mut().set_node_src( + self.handle.index(), + quoted.emittable_src(), + quoted.loc, + ); self.clone() } /// The source text recorded for this node by [`with_src`](Self::with_src), /// if any. `None` means the wiring did not quote the closure — not that the /// node has none. - pub fn src(&self) -> Option<&'static str> { + pub fn src(&self) -> Option { self.inner.borrow().node_src(self.handle.index()) } diff --git a/crates/wingfoil/src/interp.rs b/crates/wingfoil/src/interp.rs index 513b7aa27..feabe25f6 100644 --- a/crates/wingfoil/src/interp.rs +++ b/crates/wingfoil/src/interp.rs @@ -429,9 +429,10 @@ struct NodeRt { /// ordinary closure — the engine erases those, and no traversal can get /// them back (see [`crate::quote`]). /// - /// Type-free `&'static str`, so it survives on a node whose value and - /// config types are long gone. - src: Option<&'static str>, + /// A `String` rather than `&'static str` because a tier-2 quotation + /// assembles its captures into the text (`{ let fee = 2.5f64; move |p| .. }`), + /// which is computed at wiring time rather than being a token. + src: Option, /// `(file, line)` of the same quotation, for pointing a reader at the /// wiring that produced this node. loc: Option<(&'static str, u32)>, @@ -500,7 +501,11 @@ pub struct NodeInfo { /// What this node computes, verbatim, when the wiring quoted its closure /// with [`func!`](crate::func). `None` for an unquoted closure — not /// "no closure": the engine erased it and no traversal can recover it. - pub src: Option<&'static str>, + /// + /// For a tier-2 quotation this is the **emittable** form — the body wrapped + /// in a block that re-materialises each capture — not the bare body, which + /// would only resolve where it was written. + pub src: Option, /// `(file, line)` of that quotation. pub loc: Option<(&'static str, u32)>, /// The op's `#[op(build = …)]` method name (`"map"`) — what an emitter has @@ -1353,7 +1358,7 @@ impl Builder { /// [`Stream::with_src`](crate::fluent::Stream::with_src): the user wires the /// node, *then* annotates the stream it produced, and further nodes may /// have been wired in between. - pub(crate) fn set_node_src(&mut self, idx: usize, src: &'static str, loc: (&'static str, u32)) { + pub(crate) fn set_node_src(&mut self, idx: usize, src: String, loc: (&'static str, u32)) { let node = self .nodes .get_mut(idx) @@ -1378,8 +1383,8 @@ impl Builder { } /// The recorded source text of node `idx`, if its wiring quoted it. - pub(crate) fn node_src(&self, idx: usize) -> Option<&'static str> { - self.nodes.get(idx).and_then(|n| n.src) + pub(crate) fn node_src(&self, idx: usize) -> Option { + self.nodes.get(idx).and_then(|n| n.src.clone()) } /// A type-free description of every node, in wiring order — the @@ -1399,7 +1404,7 @@ impl Builder { active_ups: n.active_ups.clone(), passive_ups: n.passive_ups.clone(), activation: n.activation, - src: n.src, + src: n.src.clone(), loc: n.loc, build: n.build, cfg_src: n.cfg_src.clone(), @@ -3420,7 +3425,7 @@ impl Runner { /// let runner = g.build(); /// /// let nodes = runner.describe(); - /// assert_eq!(Some("|i: &u64| i * 2"), nodes.last().unwrap().src); + /// assert_eq!(Some("|i: &u64| i * 2"), nodes.last().unwrap().src.as_deref()); /// ``` pub fn describe(&self) -> Vec { self.nodes @@ -3432,7 +3437,7 @@ impl Runner { active_ups: n.active_ups.clone(), passive_ups: n.passive_ups.clone(), activation: n.activation, - src: n.src, + src: n.src.clone(), loc: n.loc, build: n.build, cfg_src: n.cfg_src.clone(), diff --git a/crates/wingfoil/src/quote.rs b/crates/wingfoil/src/quote.rs index a3ca3ef0e..bd165f059 100644 --- a/crates/wingfoil/src/quote.rs +++ b/crates/wingfoil/src/quote.rs @@ -49,7 +49,7 @@ //! let double = func!(|i: &u64| i * 2); //! let doubled = ticks.map(double.f).with_src(&double); //! -//! assert_eq!(Some("|i: &u64| i * 2"), doubled.src()); +//! assert_eq!(Some("|i: &u64| i * 2"), doubled.src().as_deref()); //! ``` //! //! That is one new method ([`Stream::with_src`](crate::fluent::Stream::with_src)) @@ -61,12 +61,26 @@ use std::fmt; +/// One captured binding, recorded by name and by **value**. +/// +/// The value is rendered through [`EmitLiteral`](crate::emit::EmitLiteral) at +/// quotation time, so a +/// generator can re-materialise the binding wherever it splices the body: +/// `{ let fee = 2.5f64; move |p| p * fee }`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Capture { + /// The binding's name, exactly as the closure body refers to it. + pub name: &'static str, + /// Its value at quotation time, as Rust source. + pub value: String, +} + /// A closure paired with the source text it was written as. /// /// Constructed only by [`func!`], which is what makes the two agree. Pass the /// closure on as `q.f` and record the quotation with /// [`Stream::with_src`](crate::fluent::Stream::with_src). -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct QuotedFn { /// The real closure — handed to the op exactly as an unquoted one would /// be, so nothing about execution changes. @@ -76,6 +90,41 @@ pub struct QuotedFn { /// `(file, line)` where it was written, for mapping a node back to the /// wiring that produced it. pub loc: (&'static str, u32), + /// The bindings the closure captures, by name and rendered value. Empty + /// for a closed closure — the common case. + pub captures: Vec, +} + +impl QuotedFn { + /// The closure as a self-contained expression: the body if it is closed, + /// or a block that re-materialises each capture first. + /// + /// ``` + /// use wingfoil::func; + /// + /// let fee = 2.5f64; + /// let q = func!([fee] move |p: &f64| p * fee); + /// assert_eq!( + /// "{ let fee = 2.5f64; move |p: &f64| p * fee }", + /// q.emittable_src(), + /// ); + /// ``` + /// + /// This is what makes a capturing closure splice-able at all. The body + /// alone refers to `fee`, which resolves only where it was written; wrapped + /// in its own bindings it resolves anywhere — at the cost §3 names, that + /// the values are **frozen** at quotation time. + pub fn emittable_src(&self) -> String { + if self.captures.is_empty() { + return self.src.to_string(); + } + let lets: Vec = self + .captures + .iter() + .map(|c| format!("let {} = {};", c.name, c.value)) + .collect(); + format!("{{ {} {} }}", lets.join(" "), self.src) + } } impl fmt::Debug for QuotedFn { @@ -103,28 +152,71 @@ impl fmt::Debug for QuotedFn { /// stream re-printed as `| x : & f64 | x * 2.0`. Which is the point — §5 wants /// a generated artifact to be reviewable plain Rust. /// -/// # Captures +/// # Captures — tier 1 and tier 2 +/// +/// A **closed** closure needs nothing extra: its body resolves anywhere. +/// +/// A **capturing** closure does not. `move |p| p * fee` refers to `fee`, which +/// exists only where it was written; splice that body elsewhere and it fails to +/// resolve, or worse, resolves to a different binding. So a capture must be +/// declared, and its *value* recorded: +/// +/// ``` +/// use wingfoil::func; +/// +/// let fee = 2.5f64; +/// let q = func!([fee] move |p: &f64| p * fee); +/// +/// assert_eq!(7.5, (q.f)(&3.0)); +/// assert_eq!( +/// "{ let fee = 2.5f64; move |p: &f64| p * fee }", +/// q.emittable_src(), +/// ); +/// ``` +/// +/// Each name in the list is rendered through +/// [`EmitLiteral`](crate::emit::EmitLiteral), so captures are bounded by what +/// that trait covers — primitives, `String`, `Duration`, `NanoTime`, and their +/// containers. A capture of some arbitrary struct is a compile error at the +/// `func!` call site, which is where it can be understood. /// -/// A quoted closure may capture, and the capture is **not** recorded, so `src` -/// is then a body referencing names that exist only at the original site. Fine -/// for introspection — the text is still what ran — but such a node is not -/// *emittable*: splicing the body elsewhere would fail to resolve, or worse, -/// resolve to a different binding. +/// **This freezes the capture** (§3). The emitted block carries the value the +/// closure was quoted with, so changing it means regenerating. That is partial +/// evaluation and usually the point — a per-instrument fee baked into a +/// per-instrument pipeline — but it is also how a stale threshold ships. /// -/// §3 enforces closedness by coercing the expansion through a fn pointer, which -/// rejects capturing closures at the call site. That is **not done here**, -/// deliberately: the coercion has to name an arity (`fn(&_) -> _`), so it would -/// have made `func!` unusable for `join` and `fold`. Closedness is therefore -/// checked where the requirement actually bites — in the generator, which knows -/// it is about to splice — and §3's tier-2 explicit capture lists remain the -/// route to emittable captures. See `docs/deviation-register.md`. +/// An **undeclared** capture is not detected here: `func!(move |p| p * fee)` +/// records a body that will not resolve elsewhere. §3 catches this by coercing +/// the expansion through a fn pointer, which this macro cannot do — the +/// coercion has to name an arity (`fn(&_) -> _`), which would make `func!` +/// unusable for `join` and `fold`. The check therefore lives where it bites: +/// pass 2 fails to compile the artifact, with a breadcrumb pointing at the +/// wiring. Recorded in `docs/deviation-register.md`. #[macro_export] macro_rules! func { + // Tier 2: an explicit capture list. Each name is recorded by *value*, + // rendered through `EmitLiteral`, so the generator can re-materialise the + // binding where it splices the body. + ([$($cap:ident),+ $(,)?] $f:expr) => { + $crate::quote::QuotedFn { + f: $f, + src: stringify!($f), + loc: (file!(), line!()), + captures: ::std::vec![$( + $crate::quote::Capture { + name: stringify!($cap), + value: $crate::emit::EmitLiteral::emit_literal(&$cap), + } + ),+], + } + }; + // Tier 1: a closed closure. ($f:expr) => { $crate::quote::QuotedFn { f: $f, src: stringify!($f), loc: (file!(), line!()), + captures: ::std::vec::Vec::new(), } }; } diff --git a/crates/wingfoil/tests/codegen_emission.rs b/crates/wingfoil/tests/codegen_emission.rs index c2ab09f51..eb4d161e9 100644 --- a/crates/wingfoil/tests/codegen_emission.rs +++ b/crates/wingfoil/tests/codegen_emission.rs @@ -202,7 +202,7 @@ fn an_erased_closure_is_refused_not_silently_emitted() { let nodes = g.describe(); assert!(!nodes[1].takes_closure_cfg, "count has no closure config"); assert!(nodes[2].takes_closure_cfg, "map does"); - assert_eq!(None, nodes[2].src, "and it was not quoted"); + assert_eq!(None, nodes[2].src.as_deref(), "and it was not quoted"); let err = refusals(&nodes); assert_eq!(1, err.len(), "only the map is ineligible: {err:?}"); @@ -487,3 +487,110 @@ fn the_emitted_tail_is_the_returned_output_not_the_last_node() { "tail must be the returned map, not the sink:\n{src}" ); } + +// --------------------------------------------------------------------------- +// The motivating workload: per-instrument pipelines with per-instrument +// *parameters*, which is what tier-2 captures exist for. +// --------------------------------------------------------------------------- + +// The artifact the generator must produce for the two-instrument desk below. +// A real `nitro!` block, so the file compiling proves the emitted text — the +// re-materialised capture blocks included — is valid wiring source. +wingfoil::nitro! { + fn desk_target(g: &GraphBuilder) -> Stream { + let n0 = g.ticker(::core::time::Duration::new(0u64, 1000000u32)); + let n1 = n0.count(); + let n2 = n1.map(|n: &u64| *n as f64 * 100.0); + let n3 = n2.map({ let fee = 2.5f64; move |p: &f64| p - fee }); + let n4 = g.ticker(::core::time::Duration::new(0u64, 5000000u32)); + let n5 = n4.count(); + let n6 = n5.map(|n: &u64| *n as f64 * 100.0); + let n7 = n6.map({ let fee = 1.0f64; move |p: &f64| p - fee }); + let n8 = n3.join(&n7, |x: &f64, y: &f64| x + y); + n8 + } +} + +/// **Per-instrument parameters, end to end.** Before tier 2 this graph could +/// not be generated at all: the fee closure captures, so its body referenced a +/// binding that exists only in the wiring, and the generator refused it. Now +/// each leg carries its own frozen fee. +/// +/// This is the shape §7 justifies the whole project with — per-instrument +/// pipelines built from a config file — and it is the case the earlier +/// per-instrument demo could not express. +#[test] +fn a_per_instrument_desk_generates_with_its_own_parameters() { + struct Instrument { + tick: Duration, + fee: f64, + } + let cfg = [ + Instrument { + tick: Duration::from_millis(1), + fee: 2.5, + }, + Instrument { + tick: Duration::from_millis(5), + fee: 1.0, + }, + ]; + + let wire = |g: &GraphBuilder| { + let to_px = func!(|n: &u64| *n as f64 * 100.0); + let legs: Vec> = cfg + .iter() + .map(|inst| { + let px = g + .ticker(inst.tick) + .with_cfg(&inst.tick) + .count() + .map(to_px.f) + .with_src(&to_px); + // The per-instrument parameter, declared and frozen. + let fee = inst.fee; + let net = func!([fee] move |p: &f64| p - fee); + px.map(net.f).with_src(&net) + }) + .collect(); + legs.into_iter() + .reduce(|a, b| { + let add = func!(|x: &f64, y: &f64| x + y); + a.join(&b, add.f).with_src(&add) + }) + .expect("at least one instrument") + }; + + let src = codegen::generate("desk_target", "f64", wire).expect("desk must be emittable"); + let expected = "\ +wingfoil::nitro! { + fn desk_target(g: &GraphBuilder) -> Stream { + let n0 = g.ticker(::core::time::Duration::new(0u64, 1000000u32)); + let n1 = n0.count(); + let n2 = n1.map(|n: &u64| *n as f64 * 100.0); + let n3 = n2.map({ let fee = 2.5f64; move |p: &f64| p - fee }); + let n4 = g.ticker(::core::time::Duration::new(0u64, 5000000u32)); + let n5 = n4.count(); + let n6 = n5.map(|n: &u64| *n as f64 * 100.0); + let n7 = n6.map({ let fee = 1.0f64; move |p: &f64| p - fee }); + let n8 = n3.join(&n7, |x: &f64, y: &f64| x + y); + n8 + } +}"; + assert_eq!(expected, src); + + // Parity: the artifact computes what the graph it came from computes. + let g = GraphBuilder::new(); + let acc = wire(&g).with_time().accumulate(); + let mut runner = g.build(); + runner.run(HISTORICAL, RunFor::Cycles(6)).unwrap(); + let source_graph = runner.value(acc); + + let g2 = GraphBuilder::new(); + let acc2 = desk_target::nested(&g2).with_time().accumulate(); + let mut runner2 = g2.build(); + runner2.run(HISTORICAL, RunFor::Cycles(6)).unwrap(); + + assert_eq!(source_graph, runner2.value(acc2)); + assert!(!source_graph.is_empty(), "the desk produced values"); +} diff --git a/crates/wingfoil/tests/quotation.rs b/crates/wingfoil/tests/quotation.rs index c59307a1a..f1cb9bc46 100644 --- a/crates/wingfoil/tests/quotation.rs +++ b/crates/wingfoil/tests/quotation.rs @@ -91,7 +91,7 @@ fn with_src_records_against_the_node_and_reads_back() { let double = func!(|i: &u64| i * 2); let doubled = ticks.map(double.f).with_src(&double); - assert_eq!(Some("|i: &u64| i * 2"), doubled.src()); + assert_eq!(Some("|i: &u64| i * 2"), doubled.src().as_deref()); // An unannotated node reports nothing — `None` means "not quoted", not // "no closure": the engine erased it and no traversal can get it back. assert_eq!(None, ticks.src()); @@ -114,9 +114,12 @@ fn with_src_covers_ops_of_different_arities() { let total = func!(|acc: &mut u64, v: &u64| *acc += v); let folded = joined.fold(0u64, total.f).with_src(&total); - assert_eq!(Some("|i: &u64| i * 10"), scaled.src()); - assert_eq!(Some("|a: &u64, b: &u64| a + b"), joined.src()); - assert_eq!(Some("|acc: &mut u64, v: &u64| *acc += v"), folded.src()); + assert_eq!(Some("|i: &u64| i * 10"), scaled.src().as_deref()); + assert_eq!(Some("|a: &u64, b: &u64| a + b"), joined.src().as_deref()); + assert_eq!( + Some("|acc: &mut u64, v: &u64| *acc += v"), + folded.src().as_deref() + ); } /// Quotation is wiring-time metadata only — it must not change what the graph @@ -182,7 +185,7 @@ fn describe_reports_topology_and_sources() { let mapped = nodes.last().expect("three nodes"); assert_eq!(vec![1usize], mapped.active_ups, "map reads count"); - assert_eq!(Some("|i: &u64| i * 2"), mapped.src); + assert_eq!(Some("|i: &u64| i * 2"), mapped.src.as_deref()); assert_eq!( Some(true), mapped.loc.map(|(f, _)| f.ends_with("quotation.rs")) @@ -202,7 +205,7 @@ fn describe_works_before_build() { let nodes = g.describe(); assert_eq!(3, nodes.len()); - assert_eq!(Some("|i: &u64| i * 2"), nodes[2].src); + assert_eq!(Some("|i: &u64| i * 2"), nodes[2].src.as_deref()); } /// The shape a generator's pass 1 actually consumes: wiring driven by *data*, @@ -229,7 +232,7 @@ fn a_procedurally_wired_graph_describes_every_generated_node() { // ticker + count + (scale, double) per factor. assert_eq!(2 + 2 * factors.len(), nodes.len()); - let quoted: Vec<_> = nodes.iter().filter_map(|n| n.src).collect(); + let quoted: Vec<_> = nodes.iter().filter_map(|n| n.src.as_deref()).collect(); assert_eq!( vec!["|i: &u64| i * 2"; factors.len()], quoted, @@ -248,3 +251,93 @@ fn a_procedurally_wired_graph_describes_every_generated_node() { "capturing closures stay invisible to traversal" ); } + +// --------------------------------------------------------------------------- +// Tier 2: explicit capture lists. +// --------------------------------------------------------------------------- + +/// A **closed** closure's body resolves anywhere. A capturing one does not: +/// `move |p| p * fee` refers to a binding that exists only where it was +/// written. Declaring the capture records its *value*, so the body can be +/// spliced inside a block that re-materialises it. +#[test] +fn a_declared_capture_is_recorded_by_value() { + let fee = 2.5f64; + let q = func!([fee] move |p: &f64| p * fee); + + assert_eq!(7.5, (q.f)(&3.0), "the closure still runs, unchanged"); + assert_eq!(1, q.captures.len()); + assert_eq!("fee", q.captures[0].name); + assert_eq!("2.5f64", q.captures[0].value); + assert_eq!( + "{ let fee = 2.5f64; move |p: &f64| p * fee }", + q.emittable_src() + ); +} + +/// Several captures, and the emitted block binds them all in declaration order. +#[test] +fn multiple_captures_are_all_bound() { + let lo = 1u64; + let hi = 9u64; + let q = func!([lo, hi] move |v: &u64| v.clamp(&lo, &hi).to_owned()); + + assert_eq!(9, (q.f)(&100)); + assert_eq!(1, (q.f)(&0)); + assert_eq!( + "{ let lo = 1u64; let hi = 9u64; move |v: &u64| v.clamp(&lo, &hi).to_owned() }", + q.emittable_src() + ); +} + +/// The re-materialised block computes what the original closure did — the same +/// no-drift property tier 1 has, extended over the captures. Written out as +/// real source, so the file compiling proves the block is valid Rust. +#[test] +fn a_rematerialised_capture_computes_what_the_original_did() { + let fee = 2.5f64; + let q = func!([fee] move |p: &f64| p * fee); + + // Exactly what `emittable_src` produces, spliced at a different call site + // where `fee` is *not* in scope as the same binding. + let respliced = { + let fee = 2.5f64; + move |p: &f64| p * fee + }; + assert_eq!( + "{ let fee = 2.5f64; move |p: &f64| p * fee }", + q.emittable_src() + ); + for probe in [0.0f64, 1.5, -3.25, 1e6] { + assert_eq!((q.f)(&probe), respliced(&probe), "diverged at {probe}"); + } +} + +/// A tier-1 quotation records no captures and emits its body bare, so the +/// common case pays nothing for tier 2 existing. +#[test] +fn a_closed_closure_records_no_captures() { + let q = func!(|p: &f64| p * 2.0); + assert!(q.captures.is_empty()); + assert_eq!("|p: &f64| p * 2.0", q.emittable_src()); +} + +/// Captures reach the node, so a graph reports the emittable form — not the +/// bare body, which would not resolve where the artifact is compiled. +#[test] +fn with_src_records_the_emittable_form_of_a_capture() { + let g = GraphBuilder::new(); + let fee = 0.25f64; + let adjust = func!([fee] move |p: &f64| p - fee); + let px = g + .ticker(PERIOD) + .count() + .map(|i: &u64| *i as f64) + .map(adjust.f) + .with_src(&adjust); + + assert_eq!( + Some("{ let fee = 0.25f64; move |p: &f64| p - fee }"), + px.src().as_deref() + ); +} diff --git a/docs/deviation-register.md b/docs/deviation-register.md index 8f27d16e7..37f809508 100644 --- a/docs/deviation-register.md +++ b/docs/deviation-register.md @@ -254,7 +254,7 @@ their "source takes a `GraphBuilder`/`RunMode` and returns `Result`" bullets are | D25 | **fix: wingfoil validates the session where legacy does not — sequence numbers, CheckSum, BodyLength framing, and Reject.** Legacy parses tag 34 and never compares it (a gap passes through silently), never validates an inbound CheckSum, frames by scanning for `\x0110=` rather than using BodyLength, and never sends a Reject. | 🟡 | **wingfoil is the superset** — new capability, not a parity gap, so nothing regresses. It is flagged 🟡 rather than 🟢 because it is the one place the two trees genuinely *behave differently on the same input*: legacy accepts a malformed or out-of-sequence feed, wingfoil does not (ResendRequest + a `FixSessionStatus::SequenceGap`, or Logout-and-terminate as FIX 4.4 requires). Deliberate — silently passing a sequence gap is undetectable data loss on order flow. Both trees still *drop* a garbled frame rather than answering it. `adapters/fix.rs` deviations 4–5, 7; #704. | | D26 | **fix: sequence-number persistence is opt-in.** Legacy is in-memory only and always sends `ResetSeqNumFlag=Y`. | 🟢 | That remains wingfoil's **default** (`FixSeqNumStore::Reset`), so the out-of-the-box conversation with a venue is unchanged; `FixSeqNumStore::File` is additive. Same row covers the smaller additions alongside it — `SendingTime` is parsed into `FixMessage::sending_time` rather than left at `NanoTime::ZERO`, and repeating groups are addressable via `FixMessage::groups`. `adapters/fix.rs` deviations 6–7; #704. | | D27 | **otlp: `otlp_spans` takes its arguments in a different order.** wingfoil is `otlp_spans(span_name, config, attrs)`; legacy was `otlp_spans(config, span_name, attrs)`. | 🟢 | Deliberate — it groups the two `&'static str`-ish leading args before the config. Recorded rather than left implicit because it is the **only** place a ported adapter changes a legacy call's argument *order* rather than its shape, so a porting user meets it as a bare type error with nothing pointing at the cause. Every span capability is otherwise preserved. `adapters/otlp.rs` deviation 3. | -| D28 | **quotation: `func!` does not enforce closedness, and quoted closures reach ops through `Stream::with_src` rather than an `OpFn` config bound.** `docs/wired-graph-codegen-decision.md` §3–§4 specifies both: the `func!` expansion coerces through a fn pointer so a *capturing* closure fails at the call site, and every closure-config op is bound by an `OpFn` trait with a blanket impl for `Fn` plus one for `QuotedFn`, so `map` and friends accept either form through one signature. | 🟢 | **The `OpFn` bound is not implementable, and the fn-pointer coercion is not general.** rustc propagates closure *signature* inference only from `Fn`/`FnMut`/`FnOnce` bounds; behind any other trait a closure literal loses parameter-type inference *and* higher-ranked lifetime inference. Measured on this catalog: ~370 errors across 41 targets, and the residue after reverting the fluent layer to `Fn` bounds is entirely **inside `nitro!` blocks** — `compiled()` emits closure literals into forwarders whose bounds come from the op, so the macro's whole inference-rooting mechanism depends on that bound being an `Fn` bound. The coercion fails separately: `fn(&_) -> _` names an arity, so it only covers `OpFn1`-shaped ops and would leave `join` and `fold` unquotable. **Instead**: ops keep their `Fn` bounds and never see a `QuotedFn`; the quotation is unwrapped at the fluent layer (`map(q.f)`) and the source recorded against the *node* via `Stream::with_src`, read back by `Runner::describe`. One method covers the whole catalog — built-in and user ops alike — instead of a quoted twin per op, and it puts the metadata where a traversal looks. Closedness moves to the generator (#726 step 3), which is where the requirement actually bites: it is the thing about to splice a body into another scope. §3's tier-2 explicit capture lists remain the route to emittable captures. `crates/wingfoil/src/quote.rs`; `tests/quotation.rs`; #726. | +| D28 | **quotation: `func!` does not enforce closedness, and quoted closures reach ops through `Stream::with_src` rather than an `OpFn` config bound.** `docs/wired-graph-codegen-decision.md` §3–§4 specifies both: the `func!` expansion coerces through a fn pointer so a *capturing* closure fails at the call site, and every closure-config op is bound by an `OpFn` trait with a blanket impl for `Fn` plus one for `QuotedFn`, so `map` and friends accept either form through one signature. | 🟢 | **The `OpFn` bound is not implementable, and the fn-pointer coercion is not general.** rustc propagates closure *signature* inference only from `Fn`/`FnMut`/`FnOnce` bounds; behind any other trait a closure literal loses parameter-type inference *and* higher-ranked lifetime inference. Measured on this catalog: ~370 errors across 41 targets, and the residue after reverting the fluent layer to `Fn` bounds is entirely **inside `nitro!` blocks** — `compiled()` emits closure literals into forwarders whose bounds come from the op, so the macro's whole inference-rooting mechanism depends on that bound being an `Fn` bound. The coercion fails separately: `fn(&_) -> _` names an arity, so it only covers `OpFn1`-shaped ops and would leave `join` and `fold` unquotable. **Instead**: ops keep their `Fn` bounds and never see a `QuotedFn`; the quotation is unwrapped at the fluent layer (`map(q.f)`) and the source recorded against the *node* via `Stream::with_src`, read back by `Runner::describe`. One method covers the whole catalog — built-in and user ops alike — instead of a quoted twin per op, and it puts the metadata where a traversal looks. Closedness moves to the generator (#726 step 3), which is where the requirement actually bites: it is the thing about to splice a body into another scope. §3's tier-2 explicit capture lists **are** implemented — `func!([fee] move |p| p - fee)` records each capture's *value* through `EmitLiteral` and emits `{ let fee = 2.5f64; move |p| p - fee }`, which is what makes a per-instrument parameter generatable at all. An *undeclared* capture is still not caught at the call site (the fn-pointer coercion §3 specifies has to name an arity, so it cannot cover `join`/`fold`); it surfaces as a pass-2 compile error with a breadcrumb. `crates/wingfoil/src/quote.rs`; `tests/quotation.rs`; #726. | ---