From b01b408359ed75596a76a9f706809ebe10078f35 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Tue, 7 Jul 2026 00:12:29 -0500 Subject: [PATCH 1/2] EIR VM: hard-error instead of silent () on unbound symbols, min/max/assoc misuse, and non-exhaustive match The default backend silently produced unit in several places the legacy interpreter errors loudly. Now, matching the interp's wording: - Unbound symbols lower to a Built::UnboundSym trap that raises "unbound symbol ''" (with span) instead of a string literal. - Binary [min 1 2] / [max 5 3] error "min requires a vector"; empty vectors error "min: empty vector"; vector min/max now also compare floats. assoc on a non-map errors "assoc requires a map" (#20, #21). - A match with no matching arm raises "no match arm matched value: " via Built::MatchFail carrying the scrutinee. Wasm/native compile the new builtins to their existing unit fallback, preserving behavior. Convergences surfaced by the loud path: - First-class binary operators wrap in an arity-2 closure, so [fold xs 0 +] now agrees with the interp (pinned divergence retired); and-or-value.oo flips to expect-fail: wasm. - First-class arity-N ADT constructors wrap in a real closure. - Const.* physics constants resolve in lowering (physics.oo used the silent field-on-string path). New VmErrorKind variants (UnboundSymbol, NoMatch, BuiltinType) carry stable class() names for trace/verify. Coverage added in backend_parity.rs (silent_unit_traps_error_loudly). Co-Authored-By: Claude Fable 5 --- .../tests/conformance/and-or-value.oo | 10 +- crates/loon-lang/src/eir/lower.rs | 136 ++++++++++++++---- crates/loon-lang/src/eir/mod.rs | 8 ++ crates/loon-lang/src/eir/vm.rs | 112 +++++++++++---- crates/loon-lang/tests/backend_parity.rs | 74 +++++++++- 5 files changed, 278 insertions(+), 62 deletions(-) diff --git a/crates/loon-cli/tests/conformance/and-or-value.oo b/crates/loon-cli/tests/conformance/and-or-value.oo index 3a04deb..36954e8 100644 --- a/crates/loon-cli/tests/conformance/and-or-value.oo +++ b/crates/loon-cli/tests/conformance/and-or-value.oo @@ -1,9 +1,9 @@ ; A bare `and`/`or` in VALUE position is not desugared. The legacy -; interpreter resolves it to its eager variadic env builtin (prints false -; here); the EIR VM and wasm have no callable builtin VALUES (pre-existing -; limit), so the oracle fails with "value is not callable". -; expect-fail: legacy — the eager builtin value works there, so it prints -; false and exits 0 while the VM oracle errors. +; interpreter resolves it to its eager variadic env builtin, and the EIR VM +; now wraps first-class binary operators in an arity-2 closure — both print +; false. The wasm codegen has no first-class operator values yet, so it +; rejects at compile time with "unbound symbol 'and'". +; expect-fail: wasm — no first-class operator values in the wasm codegen. [fn main [] [let f and] [println [f true false]]] diff --git a/crates/loon-lang/src/eir/lower.rs b/crates/loon-lang/src/eir/lower.rs index 99d65ee..873e356 100644 --- a/crates/loon-lang/src/eir/lower.rs +++ b/crates/loon-lang/src/eir/lower.rs @@ -571,6 +571,12 @@ impl<'a> Lower<'a> { self.emit(Op::Close(r, fid, Vec::new(), expr.span)); return r; } + // Physics constants (same set the interpreter registers). + if let Some(v) = Self::physics_const_value(&path) { + let r = self.reg(); + self.emit(Op::Lit(r, Lit::Float(v), expr.span)); + return r; + } } // Field access let obj = self.lower_expr(inner); @@ -588,6 +594,19 @@ impl<'a> Lower<'a> { } } + /// Namespaced physics constants, mirroring the interpreter's + /// `Const.*` registrations in `interp/builtins.rs`. + fn physics_const_value(path: &str) -> Option { + match path { + "Const.c" => Some(299_792_458.0), + "Const.G" => Some(6.674_30e-11), + "Const.h" => Some(6.626_070_15e-34), + "Const.k-B" => Some(1.380_649e-23), + "Const.e-charge" => Some(1.602_176_634e-19), + _ => None, + } + } + fn lower_symbol(&mut self, name: &str, span: Span) -> Reg { // Check local scope first if let Some(r) = self.lookup(name) { @@ -606,6 +625,31 @@ impl<'a> Lower<'a> { self.emit(Op::Adt(r, tag, Vec::new(), span)); return r; } + // First-class use of an arity-N constructor (e.g. `[map Some xs]`) + // — wrap it as a closure, like builtins below. + let saved_func = self.cur_func; + let saved_block = self.cur_block; + let saved_next_reg = self.next_reg; + let saved_scopes = std::mem::take(&mut self.scopes); + + let func_id = self.begin_func(None, span); + self.scopes = vec![HashMap::new()]; + let params: Vec = (0..arity).map(|i| Reg(i as u32)).collect(); + self.next_reg = arity as u32; + self.module.funcs[func_id.0 as usize].params = vec![Ty::Any; arity as usize]; + + let result = self.reg(); + self.emit(Op::Adt(result, tag, params, span)); + self.seal(End::Ret(result)); + + self.cur_func = saved_func; + self.cur_block = saved_block; + self.next_reg = saved_next_reg; + self.scopes = saved_scopes; + + let r = self.reg(); + self.emit(Op::Close(r, func_id, Vec::new(), span)); + return r; } // Check if it's a builtin — wrap as a closure for first-class use if let Some(built) = self.resolve_builtin(name) { @@ -637,10 +681,43 @@ impl<'a> Lower<'a> { return r; } - // Fallback: emit as a string literal (will be resolved by the VM) - let r = self.reg(); + // First-class use of a binary operator (e.g. `[fold xs 0 +]`) — + // wrap it as an arity-2 closure, mirroring the interpreter's + // variadic operator dispatch for the binary case. + if let Some(binop) = binop_for(name) { + let saved_func = self.cur_func; + let saved_block = self.cur_block; + let saved_next_reg = self.next_reg; + let saved_scopes = std::mem::take(&mut self.scopes); + + let func_id = self.begin_func(None, span); + self.scopes = vec![HashMap::new()]; + self.next_reg = 2; + self.module.funcs[func_id.0 as usize].params = vec![Ty::Any, Ty::Any]; + + let result = self.reg(); + self.emit(Op::Bin(result, binop, Reg(0), Reg(1), span)); + self.seal(End::Ret(result)); + + self.cur_func = saved_func; + self.cur_block = saved_block; + self.next_reg = saved_next_reg; + self.scopes = saved_scopes; + + let r = self.reg(); + self.emit(Op::Close(r, func_id, Vec::new(), span)); + return r; + } + + // Unbound: nothing in scope, no function, ctor, or builtin by this + // name. Emit a runtime trap carrying the name so evaluating the + // symbol errors loudly ("unbound symbol ''") instead of + // silently producing a string value the program never asked for. + let name_reg = self.reg(); let sid = self.intern(name); - self.emit(Op::Lit(r, Lit::Str(sid), span)); + self.emit(Op::Lit(name_reg, Lit::Str(sid), span)); + let r = self.reg(); + self.emit(Op::Builtin(r, Built::UnboundSym, vec![name_reg], span)); r } @@ -1312,11 +1389,14 @@ impl<'a> Lower<'a> { self.seal(End::Jmp(merge_block, vec![val])); } - // Default block + // Default block: no arm matched. On the VM this raises a hard + // "no match arm matched value" error (MatchFail carries the + // scrutinee for the diagnostic); wasm/native compile MatchFail to + // unit via their builtin fallback, preserving prior behavior there. self.switch_to(default_block); - let unit = self.reg(); - self.emit(Op::Lit(unit, Lit::Unit, span)); - self.seal(End::Jmp(merge_block, vec![unit])); + let fail = self.reg(); + self.emit(Op::Builtin(fail, Built::MatchFail, vec![scrutinee], span)); + self.seal(End::Jmp(merge_block, vec![fail])); // Merge block self.switch_to(merge_block); @@ -2030,25 +2110,10 @@ impl<'a> Lower<'a> { return r; } - // Check for binary operators + // Check for binary operators ("str" is handled as Built::Str + // (variadic), not BinOp) if arg_exprs.len() == 2 { - if let Some(binop) = match name.as_str() { - "+" => Some(BinOp::Add), - "-" => Some(BinOp::Sub), - "*" => Some(BinOp::Mul), - "/" => Some(BinOp::Div), - "%" => Some(BinOp::Rem), - "=" => Some(BinOp::Eq), - "!=" => Some(BinOp::Ne), - "<" => Some(BinOp::Lt), - ">" => Some(BinOp::Gt), - "<=" => Some(BinOp::Le), - ">=" => Some(BinOp::Ge), - "and" => Some(BinOp::And), - "or" => Some(BinOp::Or), - // "str" is handled as Built::Str (variadic), not BinOp - _ => None, - } { + if let Some(binop) = binop_for(name.as_str()) { let a = self.lower_expr(&arg_exprs[0]); let b = self.lower_expr(&arg_exprs[1]); let r = self.reg(); @@ -2466,6 +2531,27 @@ pub(crate) fn is_special_form(name: &str) -> bool { ) } +/// Binary operator tag for a symbol, shared by call-position lowering and +/// first-class operator wrappers in `lower_symbol`. +fn binop_for(name: &str) -> Option { + match name { + "+" => Some(BinOp::Add), + "-" => Some(BinOp::Sub), + "*" => Some(BinOp::Mul), + "/" => Some(BinOp::Div), + "%" => Some(BinOp::Rem), + "=" => Some(BinOp::Eq), + "!=" => Some(BinOp::Ne), + "<" => Some(BinOp::Lt), + ">" => Some(BinOp::Gt), + "<=" => Some(BinOp::Le), + ">=" => Some(BinOp::Ge), + "and" => Some(BinOp::And), + "or" => Some(BinOp::Or), + _ => None, + } +} + pub(crate) fn is_operator(name: &str) -> bool { matches!( name, diff --git a/crates/loon-lang/src/eir/mod.rs b/crates/loon-lang/src/eir/mod.rs index 15b35d4..dbcb28a 100644 --- a/crates/loon-lang/src/eir/mod.rs +++ b/crates/loon-lang/src/eir/mod.rs @@ -373,4 +373,12 @@ pub enum Built { SomeP, /// `none?` — true for None/unit (complement of `some?`). NoneP, + /// Non-exhaustive `match` fell through every arm. Takes the scrutinee; + /// the VM raises a "no match arm matched value" error. Wasm/native fall + /// back to unit (their builtin fallback), preserving prior behavior there. + MatchFail, + /// A symbol that resolved to nothing at lowering time. Takes the interned + /// name as a string; the VM raises "unbound symbol ''" the moment + /// the symbol is evaluated, instead of silently producing a string value. + UnboundSym, } diff --git a/crates/loon-lang/src/eir/vm.rs b/crates/loon-lang/src/eir/vm.rs index dab719d..81f415e 100644 --- a/crates/loon-lang/src/eir/vm.rs +++ b/crates/loon-lang/src/eir/vm.rs @@ -1148,9 +1148,15 @@ impl Vm { } } - Op::Builtin(dst, built, args, _) => { + Op::Builtin(dst, built, args, span) => { let vals = self.read_regs(args); - let result = self.exec_builtin(*built, &vals)?; + let result = self.exec_builtin(*built, &vals).map_err(|e| { + if e.span.is_none() { + e.with_span(*span) + } else { + e + } + })?; self.w(*dst, result); } @@ -1472,7 +1478,11 @@ impl Vm { map.insert(key, val); // O(log₃₂ n) with structural sharing Ok(self.alloc(Obj::Map(map))) } - _ => Ok(Val::UNIT), + // Same wording as the interpreter — never a silent `()` + // (issue #21: assoc on a vector used to return unit). + _ => Err(VmError::new(VmErrorKind::BuiltinType( + "assoc requires a map".to_string(), + ))), } } Built::Range => { @@ -1601,33 +1611,45 @@ impl Vm { _ => Ok(Val::int(0)), } } - Built::Min => { + Built::Min | Built::Max => { + // Vector-only, like the interpreter: binary `[min 1 2]` and + // an empty vector are hard errors, never a silent `()`. + let name = if built == Built::Min { "min" } else { "max" }; let coll = args.first().copied().unwrap_or(Val::UNIT); - match self.get_obj(coll) { - Some(Obj::Vec(items)) => { - let min = items - .iter() - .filter(|v| v.is_int()) - .map(|v| v.as_int()) - .min(); - Ok(min.map(Val::int).unwrap_or(Val::UNIT)) + let Some(Obj::Vec(items)) = self.get_obj(coll) else { + return Err(VmError::new(VmErrorKind::BuiltinType(format!( + "{name} requires a vector" + )))); + }; + if items.is_empty() { + return Err(VmError::new(VmErrorKind::BuiltinType(format!( + "{name}: empty vector" + )))); + } + // Numeric comparison over ints and floats (mixed allowed); + // non-numeric elements compare equal, mirroring Sort above. + let key = |v: Val| -> f64 { + if v.is_int() { + v.as_int() as f64 + } else if v.is_float() { + v.as_float() + } else { + f64::NAN } - _ => Ok(Val::UNIT), - } - } - Built::Max => { - let coll = args.first().copied().unwrap_or(Val::UNIT); - match self.get_obj(coll) { - Some(Obj::Vec(items)) => { - let max = items - .iter() - .filter(|v| v.is_int()) - .map(|v| v.as_int()) - .max(); - Ok(max.map(Val::int).unwrap_or(Val::UNIT)) + }; + let mut best = *items.front().unwrap(); + for &item in items.iter().skip(1) { + let ord = key(item).partial_cmp(&key(best)); + let better = if built == Built::Min { + ord == Some(std::cmp::Ordering::Less) + } else { + ord == Some(std::cmp::Ordering::Greater) + }; + if better { + best = item; } - _ => Ok(Val::UNIT), } + Ok(best) } Built::Cons => { let val = args.first().copied().unwrap_or(Val::UNIT); @@ -1761,6 +1783,19 @@ impl Vm { let v = args.first().copied().unwrap_or(Val::UNIT); Ok(Val::bool(v.is_none() || v.is_unit())) } + Built::MatchFail => { + let scrutinee = args.first().copied().unwrap_or(Val::UNIT); + let rendered = self.val_to_string_inner(scrutinee, true); + Err(VmError::new(VmErrorKind::NoMatch(rendered))) + } + Built::UnboundSym => { + let name = args + .first() + .and_then(|v| self.get_str(*v)) + .unwrap_or_default() + .to_string(); + Err(VmError::new(VmErrorKind::UnboundSymbol(name))) + } Built::Keys => { let m = args.first().copied().unwrap_or(Val::UNIT); match self.get_obj(m).cloned() { @@ -3049,6 +3084,19 @@ pub enum VmErrorKind { /// here would let programs believe they got a valid quotient. The `&str` /// names the operation ("division" or "modulo") for the diagnostic. DivideByZero(&'static str), + /// Evaluated a symbol that resolved to nothing at lowering time. + /// Silently treating it as a string value would let programs run with + /// misspelled or missing names. Wording matches the interpreter's + /// "unbound symbol ''" so both backends fail the same way. + UnboundSymbol(String), + /// A `match` fell through every arm. Silently returning `()` would hide + /// non-exhaustive matches. The `String` is the rendered scrutinee. + /// Wording matches the interpreter's "no match arm matched value: ". + NoMatch(String), + /// A builtin was given an argument type/shape it does not support (e.g. + /// binary `[min 1 2]`, `assoc` on a vector). The `String` is the full + /// message, matching the interpreter's wording ("min requires a vector"). + BuiltinType(String), } /// Structured detail for a replay divergence, so `loon verify` can classify @@ -3078,6 +3126,9 @@ impl VmErrorKind { VmErrorKind::ReplayDivergence(_) => "replay-divergence", VmErrorKind::UnhandledEffect(_) => "unhandled-effect", VmErrorKind::DivideByZero(_) => "divide-by-zero", + VmErrorKind::UnboundSymbol(_) => "unbound-symbol", + VmErrorKind::NoMatch(_) => "no-match", + VmErrorKind::BuiltinType(_) => "builtin-type-error", } } } @@ -3124,6 +3175,15 @@ impl std::fmt::Display for VmError { // "modulo by zero" so both backends fail the same way. write!(f, "{kind} by zero") } + VmErrorKind::UnboundSymbol(name) => { + // Same wording as the interpreter, so both backends fail + // the same way. + write!(f, "unbound symbol '{name}'") + } + VmErrorKind::NoMatch(scrutinee) => { + write!(f, "no match arm matched value: {scrutinee}") + } + VmErrorKind::BuiltinType(msg) => write!(f, "{msg}"), } } } diff --git a/crates/loon-lang/tests/backend_parity.rs b/crates/loon-lang/tests/backend_parity.rs index bf87139..b15a42c 100644 --- a/crates/loon-lang/tests/backend_parity.rs +++ b/crates/loon-lang/tests/backend_parity.rs @@ -399,9 +399,9 @@ fn int_divide_by_zero_raises_on_both_backends() { /// discards the continuation like the EIR VM, so it lives in CORPUS as /// `effect-abort-discards-continuation` and is enforced for agreement.) /// -/// - fold-builtin-arg: a binary builtin (`+`) passed as a HOF function. The EIR -/// VM wraps a builtin used as a value in an arity-1 closure, so binary use via -/// fold misfires (0); the interp dispatches variadically (10). interp correct. +/// (The fold-builtin-arg divergence has been RETIRED: the EIR VM now wraps a +/// first-class binary operator in an arity-2 closure, so `[fold xs 0 +]` +/// agrees with the interp's variadic dispatch. Asserted below as CONVERGED.) #[test] fn known_divergences_are_pinned() { // CONVERGED (2026-07-01, phase-2): an uncaught Fail raised in a handler @@ -422,16 +422,20 @@ fn known_divergences_are_pinned() { "interp uncaught clause-Fail errors" ); + // CONVERGED (2026-07-07): a binary operator (`+`) passed as a HOF + // function. The EIR VM used to wrap it in an arity-1 closure (misfiring + // to 0); lower_symbol now wraps first-class operators in an arity-2 + // closure, matching the interp's variadic dispatch for the binary case. let fold_builtin = "[fn main [] [println [fold #[1 2 3 4] 0 +]]]"; assert_eq!( eir_output(fold_builtin).as_deref(), - Ok("0"), - "EIR builtin-as-fold-fn (gap)" + Ok("10"), + "EIR operator-as-fold-fn" ); assert_eq!( interp_output(fold_builtin).as_deref(), Ok("10"), - "interp builtin-as-fold-fn" + "interp operator-as-fold-fn" ); // nested-handlers: two effects handled by two stacked handlers. The EIR VM @@ -521,3 +525,61 @@ fn known_divergences_are_pinned() { "interp try-retry captures (broken)" ); } + +/// The EIR VM used to silently produce `()` in several places the interp +/// errors loudly (unbound symbols, binary min/max, assoc on a vector, +/// non-exhaustive match). All are now hard errors on BOTH backends, with the +/// EIR message matching the interp's wording. +#[test] +fn silent_unit_traps_error_loudly() { + // (program, expected substring in the EIR error) + let cases: &[(&str, &str)] = &[ + ( + "[fn main [] [println [reduce f 0 #[1 2 3]]]]", + "unbound symbol 'f'", + ), + ( + "[fn main [] [println [sqrt 4]]]", + "unbound symbol 'sqrt'", // interp has sqrt; EIR names the gap + ), + ("[fn main [] [println [min 1 2]]]", "min requires a vector"), + ("[fn main [] [println [max 5 3]]]", "max requires a vector"), + ("[fn main [] [println [min #[]]]]", "min: empty vector"), + ( + "[fn main [] [println [assoc #[1 2 3] 0 9]]]", + "assoc requires a map", + ), + ( + r#"[fn main [] [println [match 5 1 "one" 2 "two"]]]"#, + "no match arm matched value: 5", + ), + ]; + for (src, want) in cases { + let eir = eir_output(src); + let msg = eir.expect_err(&format!("EIR must error: {src}")); + assert!( + msg.contains(want), + "EIR error for {src:?} was {msg:?}, expected to contain {want:?}" + ); + // The interp errors on all of these too — except sqrt, which it + // implements (the EIR error names the unported builtin instead). + if !src.contains("sqrt") { + assert!(interp_output(src).is_err(), "interp must also error: {src}"); + } + } + + // Loud-but-correct counterparts: the supported forms still work and agree. + let ok_cases: &[(&str, &str)] = &[ + ("[fn main [] [println [min #[3 1 2]]]]", "1"), + ("[fn main [] [println [max #[3 1 2]]]]", "3"), + ( + r#"[fn main [] [println [get [assoc {:a 1} :b 2] :b]]]"#, + "2", + ), + (r#"[fn main [] [println [match 2 1 "one" 2 "two"]]]"#, "two"), + ]; + for (src, want) in ok_cases { + assert_eq!(eir_output(src).as_deref(), Ok(*want), "EIR: {src}"); + assert_eq!(interp_output(src).as_deref(), Ok(*want), "interp: {src}"); + } +} From f156882bc367c144c9d2fd6ba26de2072083b3c5 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Tue, 7 Jul 2026 00:34:41 -0500 Subject: [PATCH 2/2] min/max: hard-error on non-numeric elements instead of NaN silent-skip Review follow-up: the NaN-keyed comparator silently ignored non-numeric elements (and pinned `best` to a leading non-numeric one). Non-numeric elements now raise "min: non-numeric element", consistent with the loud-over-silent policy of this branch. Note: the interp diverges here (its generic value_cmp orders strings); pinned in the parity test. Co-Authored-By: Claude Fable 5 --- crates/loon-lang/src/eir/vm.rs | 23 ++++++++++++++--------- crates/loon-lang/tests/backend_parity.rs | 12 ++++++++++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/crates/loon-lang/src/eir/vm.rs b/crates/loon-lang/src/eir/vm.rs index 81f415e..2534e17 100644 --- a/crates/loon-lang/src/eir/vm.rs +++ b/crates/loon-lang/src/eir/vm.rs @@ -1626,27 +1626,32 @@ impl Vm { "{name}: empty vector" )))); } - // Numeric comparison over ints and floats (mixed allowed); - // non-numeric elements compare equal, mirroring Sort above. - let key = |v: Val| -> f64 { + // Numeric comparison over ints and floats (mixed allowed). + // Anything else is a hard error — a NaN-style silent skip + // would return the wrong element for mixed vectors. + let key = |v: Val| -> Result { if v.is_int() { - v.as_int() as f64 + Ok(v.as_int() as f64) } else if v.is_float() { - v.as_float() + Ok(v.as_float()) } else { - f64::NAN + Err(VmError::new(VmErrorKind::BuiltinType(format!( + "{name}: non-numeric element" + )))) } }; let mut best = *items.front().unwrap(); + let mut best_key = key(best)?; for &item in items.iter().skip(1) { - let ord = key(item).partial_cmp(&key(best)); + let item_key = key(item)?; let better = if built == Built::Min { - ord == Some(std::cmp::Ordering::Less) + item_key < best_key } else { - ord == Some(std::cmp::Ordering::Greater) + item_key > best_key }; if better { best = item; + best_key = item_key; } } Ok(best) diff --git a/crates/loon-lang/tests/backend_parity.rs b/crates/loon-lang/tests/backend_parity.rs index b15a42c..5d802c4 100644 --- a/crates/loon-lang/tests/backend_parity.rs +++ b/crates/loon-lang/tests/backend_parity.rs @@ -545,6 +545,13 @@ fn silent_unit_traps_error_loudly() { ("[fn main [] [println [min 1 2]]]", "min requires a vector"), ("[fn main [] [println [max 5 3]]]", "max requires a vector"), ("[fn main [] [println [min #[]]]]", "min: empty vector"), + ( + // Non-numeric elements error rather than being silently skipped + // (a NaN-keyed skip would return the wrong element). The interp + // diverges here: its generic value_cmp orders strings. + r#"[fn main [] [println [min #["b" "a"]]]]"#, + "min: non-numeric element", + ), ( "[fn main [] [println [assoc #[1 2 3] 0 9]]]", "assoc requires a map", @@ -562,8 +569,9 @@ fn silent_unit_traps_error_loudly() { "EIR error for {src:?} was {msg:?}, expected to contain {want:?}" ); // The interp errors on all of these too — except sqrt, which it - // implements (the EIR error names the unported builtin instead). - if !src.contains("sqrt") { + // implements (the EIR error names the unported builtin instead), + // and string min, which its generic value_cmp orders. + if !src.contains("sqrt") && !src.contains(r#"#["b" "a"]"#) { assert!(interp_output(src).is_err(), "interp must also error: {src}"); } }