diff --git a/tests/hm_poly.rs b/tests/hm_poly.rs new file mode 100644 index 0000000..4b5dc8d --- /dev/null +++ b/tests/hm_poly.rs @@ -0,0 +1,191 @@ +use myc::ast::environment::Environment; +use myc::ast::types::Value; + +// ── Stage B: HM Let-Polymorphism ───────────────────────────────────────────── +// +// These tests verify that user-defined generic functions (e.g. `last`, `prev`) +// work correctly across multiple series element types without type conflicts. +// +// Before Stage B: TypeVars are unified once per call site. The optimizer +// hides failures by inlining, but diagnostics may report false conflicts and +// return types remain `Any` instead of the precise inferred type. +// +// After Stage B: `generalize` wraps function types in `Forall`; `instantiate` +// gives each call site fresh TypeVars. No conflicts, precise return types. +// ───────────────────────────────────────────────────────────────────────────── + +/// A generic accessor function must compile without any type-conflict diagnostics +/// when called with two series of different element types. +/// +/// This is the primary Stage B regression test. Before let-polymorphism, the +/// TypeVar from the first call may leak into the second, producing a spurious +/// type-conflict error. +#[test] +fn test_let_poly_no_conflict_diagnostic() { + let env = Environment::new(); + let source = r#" + (do + (def last (fn [s] (s 0))) + (def a (series 5)) + (push a 1.5) + (def b (series 5)) + (push b "hello") + (last a) + (last b) + ) + "#; + let result = env.compile(source); + assert!( + !result.diagnostics.has_errors(), + "Generic function used with two series types must not produce type errors: {}", + result.diagnostics.format_errors() + ); + let has_type_conflict = result + .diagnostics + .items + .iter() + .any(|d| d.message.contains("type") || d.message.contains("conflict") || d.message.contains("mismatch")); + assert!( + !has_type_conflict, + "No type-conflict diagnostics expected for a polymorphic function: {:?}", + result.diagnostics.items + ); +} + +/// Runtime correctness: `last` retrieves the correct value from Float and Text series. +#[test] +fn test_let_poly_two_series_types_runtime() { + let env = Environment::new(); + let source = r#" + (do + (def last (fn [s] (s 0))) + (def a (series 5)) + (push a 1.5) + (push a 2.5) + (def b (series 5)) + (push b "hello") + (push b "world") + [(last a) (last b)] + ) + "#; + let result = env.run_script(source); + match result { + Ok(Value::Tuple(elems)) if elems.len() == 2 => { + match (&elems[0], &elems[1]) { + (Value::Float(f), Value::Text(t)) + if (*f - 2.5).abs() < 1e-9 && t.as_ref() == "world" => {} + other => panic!("Expected [Float(2.5), Text(\"world\")], got {:?}", other), + } + } + other => panic!("Expected tuple result, got {:?}", other), + } +} + +/// A generic function works on record series as well as scalar series. +#[test] +fn test_let_poly_record_series() { + let env = Environment::new(); + let source = r#" + (do + (def last (fn [s] (s 0))) + (def prices (series 5)) + (push prices {:close 42.0 :vol 100}) + (def names (series 5)) + (push names {:name "Alice"}) + [ + ((.close prices) 0) + ((.name names) 0) + ] + ) + "#; + let result = env.run_script(source); + match result { + Ok(Value::Tuple(elems)) if elems.len() == 2 => { + match (&elems[0], &elems[1]) { + (Value::Float(f), Value::Text(t)) + if (*f - 42.0).abs() < 1e-9 && t.as_ref() == "Alice" => {} + other => panic!("Expected [Float(42.0), Text(\"Alice\")], got {:?}", other), + } + } + other => panic!("Expected tuple result, got {:?}", other), + } +} + +/// Value restriction: a series binding must NOT be generalized. +/// Pushing incompatible types into the same series must remain a type error. +#[test] +fn test_let_poly_value_restriction_series() { + let env = Environment::new(); + let source = r#" + (do + (def s (series 5)) + (push s 1.5) + (push s "hello") + (s 0) + ) + "#; + let result = env.compile(source); + assert!( + result.diagnostics.has_errors(), + "Pushing incompatible types into the same series must produce a type error" + ); +} + +/// Higher-order function: `apply-f` passes a generic function to different series. +/// Both calls must compile cleanly and return the correct values. +#[test] +fn test_let_poly_higher_order() { + let env = Environment::new(); + let source = r#" + (do + (def last (fn [s] (s 0))) + (def apply-f (fn [f s] (f s))) + (def a (series 5)) + (push a 1.5) + (push a 2.5) + (def b (series 5)) + (push b "hello") + (push b "world") + [(apply-f last a) (apply-f last b)] + ) + "#; + let result = env.run_script(source); + match result { + Ok(Value::Tuple(elems)) if elems.len() == 2 => { + match (&elems[0], &elems[1]) { + (Value::Float(f), Value::Text(t)) + if (*f - 2.5).abs() < 1e-9 && t.as_ref() == "world" => {} + other => panic!("Expected [Float(2.5), Text(\"world\")], got {:?}", other), + } + } + other => panic!("Expected tuple result, got {:?}", other), + } +} + +/// When a generic function returns `Any` (because its TypeVar is not resolved), +/// pushing the result into a new series leaves that series with an unresolved +/// element type. Field access on `Series(TypeVar)` then fails because the type +/// checker cannot find the record layout. +/// +/// After Stage B + call-site unification: the return type of `(get-first prices)` +/// is correctly inferred as `Record({:price Float})`, the pushed series gets +/// `Series(Record({:price Float}))`, and field access works. +#[test] +fn test_let_poly_generic_return_used_in_field_series() { + let env = Environment::new(); + let source = r#" + (do + (def get-first (fn [s] (s 0))) + (def prices (series 5)) + (push prices {:price 42.0}) + (def out (series 3)) + (push out (get-first prices)) + ((.price out) 0) + ) + "#; + let result = env.run_script(source); + match result { + Ok(Value::Float(v)) if (v - 42.0).abs() < 1e-9 => {} + other => panic!("Expected Float(42.0), got {:?}", other), + } +}