From 159d6d8e282ce2289bd4d533bf2aea94f5a02ef5 Mon Sep 17 00:00:00 2001 From: Linus Shoravi Date: Mon, 1 Jun 2026 13:46:42 +0200 Subject: [PATCH 1/2] fix: guard eta-reduction against mismatched arg counts The eta-reduction pass simplified `Lambda(a, b): App(f, a)` to `f`, silently dropping `b`. The `zip` iterator stopped at the shorter list without verifying both sides had the same length. Add an `args.len() == app_args.len()` guard so eta-reduction only fires when the lambda forwards all of its arguments. --- src/cps/reduce.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cps/reduce.rs b/src/cps/reduce.rs index 099a822e..bd0f8b36 100644 --- a/src/cps/reduce.rs +++ b/src/cps/reduce.rs @@ -169,6 +169,7 @@ impl Cps { && args.continuation.is_none() && let Cps::App(k, app_args) = &body && *k != Value::from(val) + && args.args.len() == app_args.len() && args .args .iter() From 49eeaf1dfd62aa0d2183b10a6b164c89fd303a7f Mon Sep 17 00:00:00 2001 From: Linus Shoravi Date: Mon, 1 Jun 2026 19:06:14 +0200 Subject: [PATCH 2/2] test: add CPS-level regression tests for eta-reduction guard Two unit tests that construct CPS directly: - Lambda(a, b): App(f, a) must NOT be eta-reduced (drops b) - Lambda(a, b): App(f, a, b) SHOULD be eta-reduced to f The first test fails without the args-length guard, confirming the fix prevents silent argument dropping. --- src/cps/reduce.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/cps/reduce.rs b/src/cps/reduce.rs index bd0f8b36..c9d69131 100644 --- a/src/cps/reduce.rs +++ b/src/cps/reduce.rs @@ -245,3 +245,71 @@ fn substitute( body.substitute(&substitutions, uses_cache); body } + +#[cfg(test)] +mod tests { + use super::*; + use crate::env::Local; + + #[test] + fn eta_reduction_preserves_lambdas_that_drop_args() { + // Lambda(a, b): App(f, a) must NOT be reduced to f, + // because that would change the arity and silently drop b. + let a = Local::gensym(); + let b = Local::gensym(); + let f = Local::gensym(); + let lambda_val = Local::gensym(); + + let cps = Cps::Lambda { + args: LambdaArgs::new(vec![a, b], false, None), + body: Box::new(Cps::App(Value::from(f), vec![Value::from(a)])), + val: lambda_val, + cexp: Box::new(Cps::Halt(Value::from(lambda_val))), + span: None, + }; + + let reduced = cps.reduce(); + + // The lambda must survive — it should NOT be eta-reduced. + match &reduced { + Cps::Lambda { args, body, .. } => { + assert_eq!(args.args.len(), 2, "lambda should keep both args"); + assert!( + matches!(body.as_ref(), Cps::App(_, app_args) if app_args.len() == 1), + "body should still forward only one arg" + ); + } + other => panic!("expected Lambda, got {other:?}"), + } + } + + #[test] + fn eta_reduction_still_works_for_exact_forwarding() { + // Lambda(a, b): App(f, a, b) SHOULD be reduced to f. + let a = Local::gensym(); + let b = Local::gensym(); + let f = Local::gensym(); + let lambda_val = Local::gensym(); + + let cps = Cps::Lambda { + args: LambdaArgs::new(vec![a, b], false, None), + body: Box::new(Cps::App( + Value::from(f), + vec![Value::from(a), Value::from(b)], + )), + val: lambda_val, + cexp: Box::new(Cps::Halt(Value::from(lambda_val))), + span: None, + }; + + let reduced = cps.reduce(); + + // The lambda should be eta-reduced: Halt(f) + match &reduced { + Cps::Halt(val) => { + assert_eq!(*val, Value::from(f), "should eta-reduce to f"); + } + other => panic!("expected Halt(f) after eta-reduction, got {other:?}"), + } + } +}