flowrs-engine/crates/flowrs-core/tests/tier_differential.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

788 lines
31 KiB
Rust

//! Run BOTH tiers on the same input and compare the answers.
//!
//! The tier-parity guards in `widgets/mod.rs` are static: one checks that codegen reads
//! the parameters the executor reads, another that it respects the same bounds. Both were
//! written after divergences that had already shipped, and both are necessarily blind to
//! the same thing — whether the two tiers, having read the same parameters and respected
//! the same bounds, then COMPUTE the same value.
//!
//! That was the dominant defect class in this crate's history, and every instance was
//! found by hand, one widget at a time: `list.sort` comparing lexicographically at T1 and
//! numerically at T0; `text.split` guarding an empty separator in one tier only;
//! `msg.get` ending in `unwrap_or_default` where `execute` returns nothing; `list.unique`
//! deduplicating by identity at T1 and by rendered text at T0. Each was a silent wrong
//! answer, and each was invisible to every test that existed at the time.
//!
//! This runs the interpreter, compiles and runs the generated Rust, and compares. Where
//! the DSP and audio pillars each have a hand-written parity test over a handful of
//! chosen cases, this enumerates the widget x parameter space for every family whose
//! ports have a Rust shape both tiers share — text, list, msg, logic, math and vec3.
//! Every output port is compared, not only the first.
//!
//! # Scope, and why it is these families
//!
//! Both tiers here speak the same Rust shapes: a `Text` port is a `String`, a `List` is a
//! `Vec<String>`, a `Record` is a `BTreeMap<String, String>`, a `Scalar` an `f64`, a
//! 3-vector a `[f32; 3]`. That
//! makes a value-level comparison possible without inventing a serialisation, and the
//! fixture table gives each type ONCE, in both renderings, so a case cannot drift between
//! the tiers and manufacture a false disagreement.
//!
//! Out of reach: ports with no shared shape (general tensors) are skipped and
//! COUNTED — the skip list is printed and a floor on the compared count is asserted,
//! because a harness that quietly shrinks to nothing is the same failure as a guard that
//! covers nothing. Pillars needing a dependency (image, data, vision) or an ambient
//! runtime (dsp, embedded) are out too; DSP and audio have their own numeric parity tests
//! doing this job over `Vec<f64>`, and the corpus spectrum, whose filter needs flowrs-dsp,
//! is compared whole at the end of this file, built with cargo.
//!
//! One rustc invocation for every case, because a compile per case makes the test
//! unusable — a single program prints one tagged line per case and the comparison happens
//! after.
use flowrs_core::graph::tier_check::{Fix, TierCase, classify, run};
use flowrs_core::graph::widget::builtin_registry;
use std::collections::{BTreeMap, BTreeSet};
/// One comparison: a widget, its parameters, and any port whose value must differ from
/// what its type's fixture would give.
struct Case {
id: &'static str,
params: &'static [(&'static str, &'static str)],
/// Per-port values, for cases the one-value-per-type table cannot express.
///
/// `logic.if` has two `Any` inputs; handing both the same text compares a correct
/// widget against one that picks the wrong branch and finds them equal. Anything not
/// named here still comes from `fixture`.
inputs: &'static [(&'static str, Fix)],
}
/// Every parameter a case sets must be one the widget declares, with a value among the
/// choices it offers.
///
/// A parameter the widget does not declare, or a value outside its choices, tests
/// nothing: both tiers fall back to the same default and agree. That is how
/// `logic.compare` was covered by two passing cases — `op = "gt"`, which is not one of
/// the six operators it offers — while the interpreter ignored the operator outright.
fn check_params(
id: &str,
declared: &[flowrs_core::graph::widget::ParameterDef],
params: &[(&str, &str)],
) {
for (k, v) in params {
let def = declared
.iter()
.find(|d| d.name == *k)
.unwrap_or_else(|| panic!("{id} does not declare a parameter {k:?}"));
assert!(
def.choices.is_empty() || def.choices.iter().any(|choice| choice == v),
"{id}: {k:?} = {v:?} is not one of {:?}",
def.choices
);
}
}
/// The guard above has to fire, or it is decoration: a case with a value outside the
/// declared choices must stop the suite rather than quietly prove nothing.
#[test]
#[should_panic(expected = "is not one of")]
fn a_parameter_value_outside_the_declared_choices_is_rejected() {
let reg = builtin_registry();
let w = reg.get("logic.compare").expect("logic.compare");
check_params("logic.compare", &w.parameters(), &[("op", "gt")]);
}
#[test]
#[should_panic(expected = "does not declare a parameter")]
fn a_parameter_the_widget_does_not_declare_is_rejected() {
let reg = builtin_registry();
let w = reg.get("logic.compare").expect("logic.compare");
check_params("logic.compare", &w.parameters(), &[("operator", ">")]);
}
fn cases() -> Vec<Case> {
let c = |id, params| Case {
id,
params,
inputs: &[],
};
// A case that pins particular inputs rather than the per-type fixture.
let ci = |id, params, inputs| Case { id, params, inputs };
const A: (&str, Fix) = ("a", Fix::Num(7.5));
const B: (&str, Fix) = ("b", Fix::Num(2.0));
vec![
// unit — a pure factor, an affine one (Celsius to Fahrenheit adds 32 after the
// factor), a factor printed in scientific notation, and the identity.
c("unit.convert", &[("from", "m/s"), ("to", "km/h")]),
c("unit.convert", &[("from", "Cel"), ("to", "[degF]")]),
c("unit.convert", &[("from", "uV"), ("to", "V")]),
c("unit.convert", &[("from", "m.s-1"), ("to", "m/s")]),
// text — an empty separator, an empty find, a needle that misses.
c("text.split", &[("separator", ",")]),
c("text.split", &[("separator", "")]),
c("text.trim", &[]),
c("text.case", &[("mode", "upper")]),
c("text.case", &[("mode", "lower")]),
c("text.replace", &[("find", ","), ("replace", "-")]),
c("text.replace", &[("find", ""), ("replace", "-")]),
c("text.contains", &[("needle", "b")]),
c("text.contains", &[("needle", "zz")]),
c("text.length", &[]),
c("text.join", &[("separator", ",")]),
c("text.join", &[("separator", "")]),
// list — an out-of-range index, a negative one, text where a number belongs.
c("list.sort", &[("order", "ascending")]),
c("list.sort", &[("order", "descending")]),
c("list.reverse", &[]),
c("list.unique", &[]),
c("list.length", &[]),
c("list.get", &[("index", "0")]),
c("list.get", &[("index", "-1")]),
c("list.get", &[("index", "99")]),
c("list.get", &[("index", "abc")]),
c("list.slice", &[("start", "1"), ("count", "2")]),
c("list.slice", &[("start", "-1"), ("count", "")]),
c("list.slice", &[("start", "99"), ("count", "1")]),
// msg — a key that is present and one that is not, which is where
// `unwrap_or_default` used to make the two tiers disagree.
c("msg.get", &[("key", "topic")]),
c("msg.get", &[("key", "absent")]),
c("msg.set", &[("key", "topic")]),
c("msg.set", &[("key", "brand new")]),
c("msg.remove", &[("key", "topic")]),
c("msg.remove", &[("key", "absent")]),
c("msg.keys", &[]),
c("msg.merge", &[]),
c("msg.new", &[("key", "payload")]),
// logic and math — plain arithmetic, included because agreement here is cheap to
// check and a regression would be as silent as any other.
c("logic.and", &[]),
c("logic.or", &[]),
c("logic.not", &[]),
// The operators the panel actually offers. `gt` and `eq` are not among them, so
// BOTH tiers fell back to `==` and the case proved nothing: the interpreter
// ignored the parameter outright, and codegen filters values it does not know.
ci("logic.compare", &[("op", ">")], &[A, B]),
ci("logic.compare", &[("op", "<=")], &[A, B]),
ci("logic.compare", &[("op", "==")], &[A, B]),
// A toggle whose trigger fired flips the state wired into it, from either value
// and whatever `initial` says (a wired state wins). It answered `true` at T0 and
// `!initial` at T1, which disagreed whenever `initial` was true.
ci(
"logic.toggle",
&[("initial", "true")],
&[("state", Fix::Bool(true))],
),
ci(
"logic.toggle",
&[("initial", "true")],
&[("state", Fix::Bool(false))],
),
ci(
"logic.toggle",
&[("initial", "false")],
&[("state", Fix::Bool(false))],
),
c("math.abs", &[]),
c("math.neg", &[]),
c("math.clamp", &[]),
// A value below, inside and above its bounds, and bounds that meet.
ci(
"math.clamp",
&[],
&[
("value", Fix::Num(-3.0)),
("min", Fix::Num(0.0)),
("max", Fix::Num(4.0)),
],
),
ci(
"math.clamp",
&[],
&[
("value", Fix::Num(9.5)),
("min", Fix::Num(0.0)),
("max", Fix::Num(4.0)),
],
),
ci(
"math.clamp",
&[],
&[
("value", Fix::Num(9.5)),
("min", Fix::Num(2.0)),
("max", Fix::Num(2.0)),
],
),
// A NaN reading between good bounds is NaN at both tiers (f64::clamp passes it).
ci(
"math.clamp",
&[],
&[
("value", Fix::Num(f64::NAN)),
("min", Fix::Num(0.0)),
("max", Fix::Num(4.0)),
],
),
// Bounds that hold no number: `f64::clamp` panics on them, so both tiers refuse, in
// one wording (the harness holds the program's returned error to T0's refusal).
ci(
"math.clamp",
&[],
&[
("value", Fix::Num(2.5)),
("min", Fix::Num(5.0)),
("max", Fix::Num(4.0)),
],
),
ci(
"math.clamp",
&[],
&[
("value", Fix::Num(2.5)),
("min", Fix::Num(f64::NAN)),
("max", Fix::Num(4.0)),
],
),
ci(
"math.clamp",
&[],
&[
("value", Fix::Num(2.5)),
("min", Fix::Num(0.0)),
("max", Fix::Num(f64::NAN)),
],
),
// Every binary operator, on operands that tell them apart: 9.5, 5.5, 15, 3.75,
// 1.5. The one fixture per type would have given a == b and made add and mul the
// only distinguishable pair.
ci("math.add", &[], &[A, B]),
ci("math.sub", &[], &[A, B]),
ci("math.mul", &[], &[A, B]),
ci("math.div", &[], &[A, B]),
ci("math.mod", &[], &[A, B]),
// Dividing by zero: whatever the tiers do here, they must do the same thing.
ci("math.div", &[], &[A, ("b", Fix::Num(0.0))]),
// Both branches distinct, and the condition taken both ways.
ci(
"logic.if",
&[],
&[
("condition", Fix::Bool(true)),
("true_val", Fix::Text("yes")),
("false_val", Fix::Text("no")),
],
),
ci(
"logic.if",
&[],
&[
("condition", Fix::Bool(false)),
("true_val", Fix::Text("yes")),
("false_val", Fix::Text("no")),
],
),
// Routing sends the value to one output and nothing to the other.
ci(
"logic.route",
&[("compare", "hello"), ("op", "eq"), ("compare_type", "str")],
&[("value", Fix::Text("hello"))],
),
ci(
"logic.route",
&[("compare", "other"), ("op", "eq"), ("compare_type", "str")],
&[("value", Fix::Text("hello"))],
),
// A stateful source: the interpreter previews the first sample the exported
// oscillator produces, so the frequency on the panel reaches both tiers.
ci(
"signal.sine",
&[("freq", "440.0"), ("sample_rate", "48000")],
&[],
),
ci(
"signal.sine",
&[("freq", "1000.0"), ("sample_rate", "8000")],
&[],
),
// The format parameter reaches the output — it used to be ignored at T0, which
// returned the value's Rust debug form instead.
ci(
"io.format",
&[("format", "{}")],
&[("value", Fix::Text("hello"))],
),
ci(
"io.format",
&[("format", "[{}] ok {{}}")],
&[("value", Fix::Text("hello"))],
),
// vec3 — every binop, with both operands wired so the arithmetic itself is what
// gets compared; the literal-operand path is covered by vecmath's own tests.
c("vec3.add", &[]),
c("vec3.subtract", &[]),
c("vec3.multiply", &[]),
c("vec3.divide", &[]),
c("vec3.invert", &[]),
c("vec3.mix", &[]),
c("vec3.combine", &[]),
c("vec3.separate", &[]),
c("vec3.constant", &[("value", "0.5, 1, -1")]),
]
}
impl Case {
fn owned(&self) -> TierCase {
TierCase {
id: self.id.to_string(),
params: self
.params
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
inputs: self
.inputs
.iter()
.map(|(k, f)| ((*k).to_string(), *f))
.collect(),
}
}
}
/// Every widget [`classify`] says CAN be compared, at its defaults, unless a hand-written
/// case already covers it. The harness lives in `graph::tier_check` so `graph_build` runs
/// the same comparison before it reports a graph as OK.
fn automatic_cases(covered: &[&str]) -> Vec<TierCase> {
let reg = builtin_registry();
let mut out: Vec<TierCase> = reg
.all()
.iter()
.filter(|w| !covered.contains(&w.id().as_str()) && classify(**w).is_ok())
.map(|w| TierCase::at_defaults(w.id()))
.collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
#[test]
fn both_tiers_compute_the_same_answer() {
let reg = builtin_registry();
let hand = cases();
for c in &hand {
let w = reg
.get(c.id)
.unwrap_or_else(|| panic!("{} is not in the registry", c.id));
check_params(c.id, &w.parameters(), c.params);
}
let named: Vec<&str> = hand.iter().map(|c| c.id).collect();
let mut all: Vec<TierCase> = hand.iter().map(Case::owned).collect();
let automatic = automatic_cases(&named);
eprintln!(
"{} hand-written cases, {} widgets added automatically",
all.len(),
automatic.len()
);
all.extend(automatic);
let dir = std::env::temp_dir().join("flowrs_tier_differential");
let report = run(&all, &dir).unwrap_or_else(|e| panic!("{e}"));
eprintln!(
"compared {} output ports, skipped {}: {:?}",
report.compared,
report.skipped.len(),
report.skipped
);
// A hand-written case that was skipped (an input with no fixture, an output left
// unbound) proved nothing while reading as covered: `logic.toggle` sat outside this
// comparison that way, with tiers that disagreed.
let skipped_by_hand: Vec<&(String, String)> = report
.skipped
.iter()
.filter(|(id, _)| named.contains(&id.as_str()))
.collect();
assert!(
skipped_by_hand.is_empty(),
"hand-written cases were skipped, not compared: {skipped_by_hand:?}"
);
// A harness that quietly covers nothing is the failure this file reacts to.
assert!(
report.compared >= 60,
"only {} output ports were compared",
report.compared
);
assert!(
report.disagreements.is_empty(),
"{} of {} output ports disagree between the interpreter and the compiled program:\n {}",
report.disagreements.len(),
report.compared,
report.disagreements.join("\n ")
);
}
/// Families with no widget that can be compared, and why. A family in this table that
/// gains a comparable widget fails the test below, and so does a family that has none and
/// is missing here: the list of what is NOT checked is kept as honest as what is.
const UNCOMPARED_FAMILIES: &[(&str, &str)] = &[
(
"mavlink",
"reads and commands a vehicle on a MAVLink link, which the editor cannot reach \
(tests/mavlink_nodes.rs runs the generated program against the vehicle twin)",
),
(
"ai",
"model calls through the LLM runtime, and image inputs with no shared fixture",
),
(
"capture",
"reads a capture file from the file store; T1 reads it with std::fs and flowrs-capture",
),
(
"cloud",
"every widget calls the Particle cloud API over the network",
),
(
"data",
"DataFrame ports: T1 is polars, which a one-rustc comparison cannot link",
),
(
"decode",
"Capture ports have no shared T0/T1 fixture; capture.rs compiles every decoder against flowrs-capture and compares each port",
),
(
"embedded",
"compared as byte streams over a whole graph instead (tier_check::embedded: T0 against \
the Host program; flowrs-pil: T0 against the Embassy and RTIC apps on the F4 and \
RP2040 twins), since what they do is to the wires around the board",
),
(
"gen",
"image and tensor ports, and model runtimes the generated code links",
),
(
"image",
"image ports: T1 uses the image crate and the shared frames module",
),
(
"instrument",
"SCPI and Red Pitaya calls over the network to real hardware",
),
(
"imu",
"a raw IMU burst (Vec<u8>) has no shared fixture; imu.rs compiles the generated code \
and compares its bits with T0 over 2,000 chained bursts, and the split check compares \
it across the typed link",
),
("microphone", "captures from the host audio device"),
("ml", "tensor and model ports: T1 links tract/ONNX"),
("neural", "tensor and model ports: T1 links tract/ONNX"),
(
"record",
"run and record ports, read from and written to files",
),
(
"stats",
"DataFrame ports: T1 is polars (stats has its own numeric tests in stats.rs)",
),
("twin", "drives the Renode twin runtime"),
(
"live",
"reads a stream a live source published (serial, RTT, a probe or a twin)",
),
(
"video",
"video ports: T1 decodes and encodes through the frames crate",
),
("vision", "image ports: T1 uses imageproc"),
];
/// Every widget family is either compared by `both_tiers_compute_the_same_answer` or
/// listed in [`UNCOMPARED_FAMILIES`] with its reason; the per-widget reasons are printed.
#[test]
fn every_widget_family_is_compared_or_explained() {
let reg = builtin_registry();
let mut compared: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut skipped: BTreeMap<String, Vec<String>> = BTreeMap::new();
for w in reg.all() {
let id = w.id();
let family = id.split('.').next().unwrap_or(&id).to_string();
match classify(w) {
Ok(()) => compared.entry(family).or_default().push(id),
Err(s) => skipped
.entry(family)
.or_default()
.push(format!("{id}: {}", s.reason())),
}
}
let families: BTreeSet<&String> = compared.keys().chain(skipped.keys()).collect();
let explained: BTreeMap<&str, &str> = UNCOMPARED_FAMILIES.iter().copied().collect();
let mut problems = Vec::new();
for f in &families {
let n = compared.get(*f).map_or(0, Vec::len);
let m = skipped.get(*f).map_or(0, Vec::len);
eprintln!("{f}: {n} compared, {m} not comparable");
for s in skipped.get(*f).into_iter().flatten() {
eprintln!(" {s}");
}
match (n, explained.get(f.as_str())) {
(0, None) => problems.push(format!("{f}: no comparable widget and no reason listed")),
(k, Some(_)) if k > 0 => problems.push(format!(
"{f}: listed as uncompared but {k} widget(s) are now compared; remove it"
)),
_ => {}
}
}
for f in explained.keys() {
if !families.iter().any(|x| x.as_str() == *f) {
problems.push(format!("{f}: listed but no such family"));
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// The corpus spectrum (`dsp_filter_spectrum`: an oscillator, a Butterworth low-pass, an
/// FFT) computes the interpreter's bits in its compiled program, every port of every node, in
/// a debug and a release build.
///
/// The corpus claims `tiers` for this graph, and its `tiers` stage can only compare it per
/// widget: the filter's program calls flowrs-dsp, which one rustc call cannot link, so the
/// stage compares the oscillator and names the other two as not compared. This builds the
/// whole program with cargo instead, flowrs-dsp a path dependency, offline. A port is held to
/// T0's `{:?}`, which for an f64 is its shortest round-trip text: equal text is equal bits.
/// Before work/tierfix the program's FFT was rustfft's and its two ports differed from T0 on
/// 244 and 239 of their 256 bins.
#[test]
fn the_corpus_spectrum_program_computes_t0s_bits_in_debug_and_release() {
use flowrs_core::graph::tier_check::render;
let e = flowrs_core::graph::corpus::get("dsp_filter_spectrum").expect("the corpus has it");
assert!(e.proof_line().contains("tiers"), "{}", e.proof_line());
let g = e.parse().expect("it parses").graph;
let mut ex =
flowrs_core::graph::GraphExecutor::builtin(flowrs_core::graph::runtime::Runtime::new());
let steps = ex.run(&g).expect("T0 runs it");
let mut probes = Vec::new();
let mut want = BTreeMap::new();
for s in &steps {
for (port, v) in &s.outputs {
probes.push((s.node_id, port.clone()));
want.insert(format!("{}.{port}", s.node_id.0), render(Some(v)));
}
}
for p in ["0.signal", "1.output", "2.magnitude", "2.phase"] {
assert!(
want.contains_key(p),
"T0 computed no {p}: {:?}",
want.keys()
);
}
let program = flowrs_core::codegen::rust::primitives_to_rust_probed(&g, &probes)
.expect("codegen takes it");
assert!(program.contains("mod fft_math {"), "{program}");
assert!(
!program.contains("rustfft::")
&& !program
.lines()
.any(|l| l.starts_with("// Cargo.toml needs:") && l.contains("rustfft")),
"{program}"
);
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let dir = std::env::temp_dir().join(format!("flowrs_spectrum_tiers_{}", std::process::id()));
std::fs::create_dir_all(dir.join("src")).unwrap();
let dsp = root.join("crates/flowrs-dsp").canonicalize().unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"spectrum_tiers\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
[dependencies]\nflowrs-dsp = {{ path = {:?}, default-features = false }}\n[workspace]\n",
dsp.display().to_string()
),
)
.unwrap();
// The workspace's lock, so flowrs-dsp's dependencies resolve offline as they did for it.
std::fs::copy(root.join("Cargo.lock"), dir.join("Cargo.lock")).unwrap();
std::fs::write(dir.join("src/main.rs"), &program).unwrap();
for release in [false, true] {
let mut cargo = std::process::Command::new("cargo");
cargo
.args(["build", "--quiet", "--offline", "--target-dir"])
.arg(dir.join("target"))
.current_dir(&dir);
if release {
cargo.arg("--release");
}
let build = cargo.output().expect("cargo runs");
assert!(
build.status.success(),
"the program does not build (release {release}):\n{}",
String::from_utf8_lossy(&build.stderr)
);
let bin = dir
.join("target")
.join(if release { "release" } else { "debug" })
.join("spectrum_tiers");
let run = std::process::Command::new(&bin).output().expect("it runs");
assert!(run.status.success(), "release {release}: {run:?}");
let stderr = String::from_utf8_lossy(&run.stderr);
let got: BTreeMap<&str, &str> = stderr
.lines()
.filter_map(|l| l.strip_prefix('\u{1f}')?.split_once('\t'))
.collect();
for (port, t0) in &want {
let t1 = got.get(port.as_str()).unwrap_or_else(|| {
panic!("release {release}: the program printed no {port}:\n{stderr}")
});
assert_eq!(t1, t0, "release {release}: {port} is not T0's");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A clamp whose bounds hold no number (`min` above `max`, or a NaN bound) is refused at both
/// tiers, in one wording, and never panics.
///
/// `f64::clamp` panics on such bounds, and both tiers called it: a person who dragged `min`
/// above `max` crashed the editor's tab, and the program panicked on the same graph. Bounds
/// known before the run (number literals wired in) are refused by the checker on the node,
/// and codegen emits no program. Bounds computed at run time (here `min` = 3 + 2) cannot be
/// known before it: the interpreter lists the node as refused, and the program returns the
/// same refusal from `main` (exit status 1, `Error: …` on stderr) where it used to panic
/// (status 101). `run_graphs` holds that error's text to T0's refusal.
#[test]
fn a_clamp_with_bounds_that_hold_no_number_is_refused_alike_and_never_panics() {
use flowrs_core::graph::diagnostics::{Severity, check};
use flowrs_core::graph::tier_check::{GraphCase, run_graphs};
let parse = |text: &str| {
flowrs_core::graph::dsl::parse_graph(text)
.unwrap_or_else(|e| panic!("{e:?}"))
.graph
};
let clamp_of = |g: &flowrs_core::graph::Graph| {
g.nodes
.values()
.find(|n| n.type_id == "math.clamp")
.unwrap()
.id
};
// Known before the run: the checker says so on the node, codegen refuses, T0 refuses.
for (lo, hi, said) in [
("5", "4", "min 5.0 is not at most max 4.0"),
("NaN", "4", "min NaN is not at most max 4.0"),
("0", "NaN", "min 0.0 is not at most max NaN"),
] {
let g = parse(&format!(
"let n0 = FloatLiteral {{ value: 2.5 }};\n\
let n1 = FloatLiteral {{ value: {lo} }};\n\
let n2 = FloatLiteral {{ value: {hi} }};\n\
let n3 = math.clamp(value: n0.value, min: n1.value, max: n2.value);\n\
let n4 = io.print(value: n3.clamped);\n"
));
let clamp = clamp_of(&g);
let errors: Vec<String> = check(&g)
.into_iter()
.filter(|d| d.severity == Severity::Error && d.node == clamp)
.map(|d| d.message)
.collect();
assert!(
errors.iter().any(|m| m.contains(said)),
"{lo}..{hi}: the checker said {errors:?}"
);
let refused = g.to_rust().expect_err("codegen emits no program");
assert!(refused.contains(said), "{lo}..{hi}: {refused}");
let mut ex =
flowrs_core::graph::GraphExecutor::builtin(flowrs_core::graph::runtime::Runtime::new());
let steps = ex.run(&g).expect("the run completes");
assert!(
steps
.iter()
.all(|s| s.node_id != clamp || s.outputs.is_empty())
);
assert!(
ex.unexecuted()
.iter()
.any(|u| u.node == clamp && u.reason.contains(said)),
"{lo}..{hi}: {:?}",
ex.unexecuted()
);
}
// Good literal bounds raise nothing.
let good = parse(
"let n0 = FloatLiteral { value: 5 };\nlet n1 = FloatLiteral { value: 0 };\n\
let n2 = FloatLiteral { value: 4 };\n\
let n3 = math.clamp(value: n0.value, min: n1.value, max: n2.value);\n",
);
assert!(
check(&good).iter().all(|d| d.severity != Severity::Error),
"{:?}",
check(&good)
);
// Known only at run time: refused alike, and the program returns its error.
let g = parse(
"let n0 = FloatLiteral { value: 2.5 };\n\
let n1 = FloatLiteral { value: 3 };\n\
let n2 = FloatLiteral { value: 2 };\n\
let n3 = math.add(a: n1.value, b: n2.value);\n\
let n4 = FloatLiteral { value: 4 };\n\
let n5 = math.clamp(value: n0.value, min: n3.sum, max: n4.value);\n\
let n6 = io.print(value: n5.clamped);\n",
);
let clamp = clamp_of(&g);
assert!(
check(&g).iter().all(|d| d.severity != Severity::Error),
"a computed bound is not the checker's to know: {:?}",
check(&g)
);
let dir = std::env::temp_dir().join(format!("flowrs_clamp_bounds_{}", std::process::id()));
let report = run_graphs(
&[GraphCase {
name: "clamp min 3 + 2 over max 4".into(),
graph: g.clone(),
probes: vec![(clamp, "clamped".into())],
files: Vec::new(),
console: true,
}],
&dir,
)
.expect("both tiers run");
assert!(
report.disagreements.is_empty(),
"{:#?}",
report.disagreements
);
assert_eq!(report.faulted.len(), 1, "both tiers refuse: {report:?}");
let program = g.to_rust().expect("codegen takes a computed bound");
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("clamp.rs");
let bin = dir.join("clamp");
std::fs::write(&src, &program).unwrap();
let build = std::process::Command::new("rustc")
.args(["--edition", "2021", "-o"])
.arg(&bin)
.arg(&src)
.output()
.expect("rustc runs");
assert!(
build.status.success(),
"{}",
String::from_utf8_lossy(&build.stderr)
);
let run = std::process::Command::new(&bin).output().unwrap();
let stderr = String::from_utf8_lossy(&run.stderr);
assert_eq!(
run.status.code(),
Some(1),
"an error from main, not a panic (101): {stderr}"
);
assert!(!stderr.contains("panicked"), "{stderr}");
assert!(
stderr.contains(
"`math.clamp` refused: min 5.0 is not at most max 4.0: a clamp needs min <= max, and neither NaN"
),
"{stderr}"
);
assert!(run.stdout.is_empty(), "nothing printed past the refusal");
let _ = std::fs::remove_dir_all(&dir);
}