flowrs-engine/crates/flowrs-core/tests/held_wires.rs
flowRS 2b6f30d348 flowRS engine, cut from flowRS f907e0756a7c
Built alone from an empty CARGO_HOME, its tests passed, and flowrsc checked, ran and exported a
document from the conformance corpus; the export built alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 08:54:33 -05:00

315 lines
13 KiB
Rust

//! Held wires: a loop of wires runs when one wire in it carries the previous step.
//!
//! The interpreter advances on `step`, never on `run`; the first step reads the source
//! type's zero; a replay of a stepped log reproduces it; and the generated program, which
//! loops over the same steps, prints what the interpreter printed at every one of them.
use flowrs_core::graph::runtime::{Runtime, replay};
use flowrs_core::graph::tier_check::run_steps;
use flowrs_core::graph::{GraphExecutor, WidgetRegistry};
use flowrs_core::{Graph, NodeId, Value, WireId, WireMode};
fn lit(g: &mut Graph, ty: &str, v: &str) -> NodeId {
let n = g.add_node(ty, 0.0, 0.0);
g.get_node_mut(n)
.unwrap()
.properties
.insert("value".into(), v.into());
n
}
fn executor() -> GraphExecutor {
GraphExecutor::with_runtime(WidgetRegistry::with_builtins(), Runtime::new())
}
fn output(ex: &mut GraphExecutor, g: &Graph, node: NodeId, port: &str, advance: bool) -> Value {
let steps = if advance { ex.step(g) } else { ex.run(g) }.unwrap();
steps
.into_iter()
.find(|s| s.node_id == node)
.and_then(|s| s.outputs.get(port).cloned())
.unwrap_or_else(|| panic!("node {node:?} produced no `{port}`"))
}
/// y[n] = y[n-1] + 1 with y[0] = 0, printed every step. Returns (graph, add, the held wire).
fn counter() -> (Graph, NodeId, WireId) {
let mut g = Graph::new();
let one = lit(&mut g, "FloatLiteral", "1");
let add = g.add_node("math.add", 0.0, 0.0);
let print = g.add_node("PrintLn", 0.0, 0.0);
g.add_wire(one, "value", add, "b", WireMode::Copy).unwrap();
let back = g.add_wire(add, "sum", add, "a", WireMode::Copy).unwrap();
g.set_feedback(back, true);
g.add_wire(add, "sum", print, "input", WireMode::Copy)
.unwrap();
(g, add, back)
}
#[test]
fn an_instant_loop_is_refused_and_a_held_one_counts_steps() {
let (mut g, add, back) = counter();
g.set_feedback(back, false);
let refused = executor()
.run(&g)
.err()
.expect("an instant loop has no order");
assert!(refused.contains("cycle"), "{refused}");
assert!(g.to_rust().is_err(), "nor a program");
g.set_feedback(back, true);
let mut ex = executor();
for n in 1..=4 {
let sum = output(&mut ex, &g, add, "sum", true).as_f64();
assert_eq!(sum, f64::from(n), "step {n}");
}
}
/// Evaluating the same step again shows the same values. The editor evaluates on every
/// redraw; a run that advanced would move the loop each time something repainted.
#[test]
fn run_reads_the_held_step_and_only_step_advances_it() {
let (g, add, _) = counter();
let mut ex = executor();
assert_eq!(output(&mut ex, &g, add, "sum", false).as_f64(), 1.0);
assert_eq!(output(&mut ex, &g, add, "sum", false).as_f64(), 1.0);
assert_eq!(output(&mut ex, &g, add, "sum", true).as_f64(), 1.0);
assert_eq!(output(&mut ex, &g, add, "sum", false).as_f64(), 2.0);
// Clearing the held store is the first step again.
ex.runtime().clear("held");
assert_eq!(output(&mut ex, &g, add, "sum", false).as_f64(), 1.0);
}
/// y[n] = 0.5 y[n-1] + 0.5, y[0] = 0. Closed form: y[n] = 1 - 0.5^n.
#[test]
fn a_held_first_order_plant_matches_its_closed_form() {
let mut g = Graph::new();
let half = lit(&mut g, "FloatLiteral", "0.5");
let gain_u = g.add_node("math.mul", 0.0, 0.0);
let gain_y = g.add_node("math.mul", 0.0, 0.0);
let sum = g.add_node("math.add", 0.0, 0.0);
let one = lit(&mut g, "FloatLiteral", "1");
g.add_wire(half, "value", gain_u, "a", WireMode::Copy)
.unwrap();
g.add_wire(one, "value", gain_u, "b", WireMode::Copy)
.unwrap();
g.add_wire(half, "value", gain_y, "b", WireMode::Copy)
.unwrap();
let back = g.add_wire(sum, "sum", gain_y, "a", WireMode::Copy).unwrap();
g.set_feedback(back, true);
g.add_wire(gain_u, "product", sum, "a", WireMode::Copy)
.unwrap();
g.add_wire(gain_y, "product", sum, "b", WireMode::Copy)
.unwrap();
let mut ex = executor();
for n in 1..=6 {
let y = output(&mut ex, &g, sum, "sum", true).as_f64();
let expect = 1.0 - 0.5_f64.powi(n);
assert!((y - expect).abs() < 1e-12, "step {n}: {y} vs {expect}");
}
}
/// An integer loop starts from the integer zero, not 0.0: the type of the first step is
/// the type of every later one.
#[test]
fn an_integer_loop_starts_from_the_integer_zero() {
let mut g = Graph::new();
let three = lit(&mut g, "IntLiteral", "3");
let add = g.add_node("Add", 0.0, 0.0);
g.add_wire(three, "value", add, "b", WireMode::Copy)
.unwrap();
let back = g.add_wire(add, "result", add, "a", WireMode::Copy).unwrap();
g.set_feedback(back, true);
let mut ex = executor();
for n in 1..=3 {
match output(&mut ex, &g, add, "result", true) {
Value::Int(v) => assert_eq!(v, 3 * n, "step {n}"),
other => panic!("step {n}: expected an Int, got {other:?}"),
}
}
}
/// A held wire carrying text has no zero both tiers agree on: the interpreter and the
/// code generator refuse it, and say why.
#[test]
fn a_held_wire_without_a_zero_is_refused_by_both_tiers() {
let mut g = Graph::new();
let s = lit(&mut g, "StringLiteral", "hi");
let p = g.add_node("PrintLn", 0.0, 0.0);
let w = g.add_wire(s, "value", p, "input", WireMode::Copy).unwrap();
g.set_feedback(w, true);
let e = executor().run(&g).err().expect("refused");
assert!(e.contains("no zero"), "{e}");
let e = g.to_rust().expect_err("refused");
assert!(e.contains("no zero"), "{e}");
}
/// Stepping is logged, so a replay of the log reproduces every step. Without the held
/// values in the log the replay's second run would read zero and diverge.
#[test]
fn a_replay_of_a_stepped_log_reproduces_it() {
let (g, _, _) = counter();
let rt = Runtime::new();
let mut ex = GraphExecutor::with_runtime(WidgetRegistry::with_builtins(), rt.clone());
for _ in 0..4 {
ex.step(&g).unwrap();
}
let log = rt.log();
replay(&log, &g, WidgetRegistry::with_builtins())
.unwrap_or_else(|problems| panic!("{problems:#?}"));
}
/// The generated program runs the same steps and prints what the interpreter printed at
/// each one. Before it looped, a held wire emitted one pass starting from 0.0, which
/// agrees with step one and with nothing after it.
#[test]
fn the_compiled_program_matches_the_interpreter_at_every_step() {
let scratch = std::env::temp_dir().join(format!("flowrs_held_steps_{}", std::process::id()));
let (counter, _, _) = counter();
let r = run_steps(&counter, 6, &scratch.join("counter")).unwrap();
assert_eq!(r.compared, 6, "one line per step");
assert!(r.agrees(), "{:#?}", r.disagreements);
// An integer loop through a palette primitive whose ports say i32 while the literal
// binds i64: the held variable must take its width from what is assigned to it.
let mut ints = Graph::new();
let three = lit(&mut ints, "IntLiteral", "3");
let add = ints.add_node("Add", 0.0, 0.0);
let print = ints.add_node("PrintLn", 0.0, 0.0);
ints.add_wire(three, "value", add, "b", WireMode::Copy)
.unwrap();
let back = ints
.add_wire(add, "result", add, "a", WireMode::Copy)
.unwrap();
ints.set_feedback(back, true);
ints.add_wire(add, "result", print, "input", WireMode::Copy)
.unwrap();
let r = run_steps(&ints, 5, &scratch.join("ints")).unwrap();
assert_eq!(r.compared, 5);
assert!(r.agrees(), "{:#?}", r.disagreements);
// The plant, printed: a value that changes every step by a different amount.
let mut g = Graph::new();
let half = lit(&mut g, "FloatLiteral", "0.5");
let gain_y = g.add_node("math.mul", 0.0, 0.0);
let sum = g.add_node("math.add", 0.0, 0.0);
let print = g.add_node("PrintLn", 0.0, 0.0);
g.add_wire(half, "value", gain_y, "b", WireMode::Copy)
.unwrap();
g.add_wire(half, "value", sum, "a", WireMode::Copy).unwrap();
g.add_wire(gain_y, "product", sum, "b", WireMode::Copy)
.unwrap();
let back = g.add_wire(sum, "sum", gain_y, "a", WireMode::Copy).unwrap();
g.set_feedback(back, true);
g.add_wire(sum, "sum", print, "input", WireMode::Copy)
.unwrap();
let r = run_steps(&g, 8, &scratch.join("plant")).unwrap();
assert_eq!(r.compared, 8);
assert!(r.agrees(), "{:#?}", r.disagreements);
}
/// A program that holds a wire says so, and runs the number of steps it was asked for.
#[test]
fn the_program_names_its_held_wire_and_its_step_count() {
let (g, _, back) = counter();
let src = g.to_rust_steps(3).unwrap();
let var = format!("held_{}", back.0);
assert!(src.contains(&format!("let mut {var} = 0.0;")), "{src}");
assert!(src.contains("for _step in 0..3"), "{src}");
assert!(src.contains(&format!("{var} = ")), "{src}");
// A graph without a held wire is emitted exactly as before.
let mut plain = Graph::new();
let one = lit(&mut plain, "FloatLiteral", "1");
let print = plain.add_node("PrintLn", 0.0, 0.0);
plain
.add_wire(one, "value", print, "input", WireMode::Copy)
.unwrap();
assert_eq!(plain.to_rust_steps(9).unwrap(), plain.to_rust().unwrap());
assert!(!plain.to_rust().unwrap().contains("_step"));
}
/// A toggle on a timer, its `state` held back into `state`, printed: one per step.
/// Returns (graph, toggle).
fn toggle_on_a_timer(initial: &str, trigger: bool, looped: bool) -> (Graph, NodeId) {
let mut g = Graph::new();
let toggle = g.add_node("logic.toggle", 0.0, 0.0);
g.get_node_mut(toggle)
.unwrap()
.properties
.insert("initial".into(), initial.into());
if trigger {
let timer = g.add_node("embedded.timer.interval", 0.0, 0.0);
g.add_wire(timer, "tick", toggle, "trigger", WireMode::Copy)
.unwrap();
}
if looped {
let back = g
.add_wire(toggle, "state", toggle, "state", WireMode::Copy)
.unwrap();
g.set_feedback(back, true);
}
let print = g.add_node("PrintLn", 0.0, 0.0);
g.add_wire(toggle, "state", print, "input", WireMode::Copy)
.unwrap();
(g, toggle)
}
/// `logic.toggle` keeps its state on a held wire and flips it once per trigger, at both
/// tiers. It used to store nothing: T0 answered `true` on every step and the program
/// `!initial` on every pass, so it never toggled twice and, with `initial = true`, the
/// two tiers disagreed.
#[test]
fn a_held_toggle_flips_once_per_step_at_both_tiers() {
let scratch = std::env::temp_dir().join(format!("flowrs_held_toggle_{}", std::process::id()));
let (g, toggle) = toggle_on_a_timer("false", true, true);
let mut ex = executor();
let seen: Vec<bool> = (0..6)
.map(|_| match output(&mut ex, &g, toggle, "state", true) {
Value::Bool(b) => b,
other => panic!("a toggle answers a bool, not {other:?}"),
})
.collect();
assert_eq!(seen, [true, false, true, false, true, false]);
let r = run_steps(&g, 6, &scratch.join("looped")).unwrap();
assert_eq!(r.compared, 6, "one line per step");
assert!(r.agrees(), "{:#?}", r.disagreements);
let src = g.to_rust_steps(6).unwrap();
assert!(
src.contains("!held_"),
"the program flips the held state:\n{src}"
);
// Unlooped, it is one flip from `initial`: the case where the tiers used to disagree.
for (initial, want) in [("true", "false"), ("false", "true")] {
let (g, _) = toggle_on_a_timer(initial, true, false);
let r = run_steps(&g, 1, &scratch.join(format!("once_{initial}"))).unwrap();
assert_eq!(r.compared, 1);
assert!(r.agrees(), "initial {initial}: {:#?}", r.disagreements);
let mut ex = executor();
ex.step(&g).unwrap();
assert_eq!(ex.console().trim(), want, "initial {initial}");
}
// With no trigger it holds, looped or not, at both tiers.
let (g, _) = toggle_on_a_timer("true", false, false);
let r = run_steps(&g, 1, &scratch.join("held_initial")).unwrap();
assert!(r.agrees(), "{:#?}", r.disagreements);
let mut ex = executor();
ex.step(&g).unwrap();
assert_eq!(ex.console().trim(), "true");
let (g, toggle) = toggle_on_a_timer("false", false, true);
let mut ex = executor();
for step in 1..=3 {
assert!(
matches!(
output(&mut ex, &g, toggle, "state", true),
Value::Bool(false)
),
"step {step}: an untriggered toggle holds"
);
}
let r = run_steps(&g, 3, &scratch.join("held_loop")).unwrap();
assert_eq!(r.compared, 3);
assert!(r.agrees(), "{:#?}", r.disagreements);
let _ = std::fs::remove_dir_all(&scratch);
}