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]>
315 lines
13 KiB
Rust
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);
|
|
}
|