Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/wingfoil/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
12 changes: 7 additions & 5 deletions crates/wingfoil/src/fluent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ impl<T> Stream<T> {
/// 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
Expand All @@ -701,16 +701,18 @@ impl<T> Stream<T> {
"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<String> {
self.inner.borrow().node_src(self.handle.index())
}

Expand Down
25 changes: 15 additions & 10 deletions crates/wingfoil/src/interp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// `(file, line)` of the same quotation, for pointing a reader at the
/// wiring that produced this node.
loc: Option<(&'static str, u32)>,
Expand Down Expand Up @@ -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<String>,
/// `(file, line)` of that quotation.
pub loc: Option<(&'static str, u32)>,
/// The op's `#[op(build = …)]` method name (`"map"`) — what an emitter has
Expand Down Expand Up @@ -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)
Expand All @@ -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<String> {
self.nodes.get(idx).and_then(|n| n.src.clone())
}

/// A type-free description of every node, in wiring order — the
Expand All @@ -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(),
Expand Down Expand Up @@ -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<NodeInfo> {
self.nodes
Expand All @@ -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(),
Expand Down
122 changes: 107 additions & 15 deletions crates/wingfoil/src/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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<F> {
/// The real closure — handed to the op exactly as an unquoted one would
/// be, so nothing about execution changes.
Expand All @@ -76,6 +90,41 @@ pub struct QuotedFn<F> {
/// `(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<Capture>,
}

impl<F> QuotedFn<F> {
/// 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<String> = self
.captures
.iter()
.map(|c| format!("let {} = {};", c.name, c.value))
.collect();
format!("{{ {} {} }}", lets.join(" "), self.src)
}
}

impl<F> fmt::Debug for QuotedFn<F> {
Expand Down Expand Up @@ -103,28 +152,71 @@ impl<F> fmt::Debug for QuotedFn<F> {
/// 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(),
}
};
}
109 changes: 108 additions & 1 deletion crates/wingfoil/tests/codegen_emission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
Expand Down Expand Up @@ -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<f64> {
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<Stream<f64>> = 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<f64> {
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");
}
Loading