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]>
788 lines
31 KiB
Rust
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);
|
|
}
|