//! 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`, a `Record` is a `BTreeMap`, 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`, 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 { 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 { let reg = builtin_registry(); let mut out: Vec = 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 = 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) 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> = BTreeMap::new(); let mut skipped: BTreeMap> = 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 = 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); }