Refactor: Simplify series creation and infer element type

The `series` constructor now only accepts the `lookback` limit. The
element type is inferred by the type checker and implicitly added as a
schema argument during compilation. This simplifies the API and
centralizes type inference.

This change also includes:
- Updates to example scripts (`HMA.myc`, `sma.myc`, `soa_series.myc`) to
  reflect the new `series` signature.
- Modifications to the `TypeChecker` to handle the inferred schema
  injection.
- An addition of a regression test for type inference through nested
  closures with `series`.
- Removal of `#[allow(dead_code)]` from several `TypeChecker` methods as
  they are now used.
- Update to `rtl/prelude.myc` macro `cache`.
- Update to `rtl/series/mod.rs` to reflect new signature.
- Update to `ast/types.rs` to allow `series` to be indexed with an
  integer to retrieve its element type.
This commit is contained in:
2026-03-28 13:47:15 +01:00
parent e595e747d4
commit d2cba2d55d
10 changed files with 284 additions and 19 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
(def make-sma (def make-sma
(fn [lookback] (fn [lookback]
(do (do
(def s (series lookback :float)) (def s (series lookback))
(def running-sum 0.0) (def running-sum 0.0)
(def count 0) (def count 0)
(fn [x] (fn [x]
@@ -22,7 +22,7 @@
(do (do
;; Sicherstellen, dass lookback mindestens 1 ist ;; Sicherstellen, dass lookback mindestens 1 ist
(def n (if (< lookback 1) 1 lookback)) (def n (if (< lookback 1) 1 lookback))
(def s (series n :float)) (def s (series n))
(def price-sum 0.0) (def price-sum 0.0)
(def weighted-sum 0.0) (def weighted-sum 0.0)
(def count 0) (def count 0)
+3 -3
View File
@@ -11,11 +11,11 @@
(pipe [(pipe [(.close src)] (SMA 5))] (printx "sma5=")) (pipe [(pipe [(.close src)] (SMA 5))] (printx "sma5="))
(pipe [(pipe [(.close src)] (SMA 100))] (printx "sma100=")) (pipe [(pipe [(.close src)] (SMA 100))] (printx "sma100="))
(macro cache [lookback type src] `(do (def data (series ~lookback ~type)) (pipe [~src] (fn [s] (do (push data s)))) data)) (macro cache [lookback src] `(do (def data (series ~lookback)) (pipe [~src] (fn [s] (do (push data s)))) data))
; (def bars (series 20 :float)) ; (def bars (series 20))
; (pipe [src] (fn [s] (do (push bars (.close s))))) ; (pipe [src] (fn [s] (do (push bars (.close s)))))
(cache 20 :float src) (cache 20 src)
) )
+1 -1
View File
@@ -1,7 +1,7 @@
;; Benchmark: 1.1us ;; Benchmark: 1.1us
;; Benchmark-Repeat: 1877 ;; Benchmark-Repeat: 1877
(do (do
(def my_ticks (series 100 {:price :float :volume :int :msg :text})) (def my_ticks (series 100))
(push my_ticks {:price 10.5 :volume 100 :msg "A"}) (push my_ticks {:price 10.5 :volume 100 :msg "A"})
(push my_ticks {:price 11.2 :volume 200 :msg "B"}) (push my_ticks {:price 11.2 :volume 200 :msg "B"})
+1 -1
View File
@@ -2,7 +2,7 @@
(def SMA (def SMA
(fn [length] (fn [length]
(do (do
(def history (series length :float)) (def history (series length))
(def sum 0.0) (def sum 0.0)
(fn [val] (fn [val]
+222 -7
View File
@@ -3,7 +3,7 @@ use crate::ast::nodes::{
Node, NodeKind, TypedNode, TypedPhase, VirtualId, Node, NodeKind, TypedNode, TypedPhase, VirtualId,
}; };
use crate::ast::diagnostics::Diagnostics; use crate::ast::diagnostics::Diagnostics;
use crate::ast::types::{Keyword, RecordLayout, Signature, StaticType}; use crate::ast::types::{Keyword, NodeIdentity, RecordLayout, Signature, StaticType, Value};
use std::cell::Cell; use std::cell::Cell;
use std::collections::HashMap; use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
@@ -149,7 +149,6 @@ impl TypeChecker {
} }
/// Creates a fresh, unique type variable. /// Creates a fresh, unique type variable.
#[allow(dead_code)]
fn fresh_var(&self) -> StaticType { fn fresh_var(&self) -> StaticType {
let id = self.var_counter.get(); let id = self.var_counter.get();
self.var_counter.set(id + 1); self.var_counter.set(id + 1);
@@ -158,7 +157,6 @@ impl TypeChecker {
/// Recursively applies the substitution map to a type, replacing all /// Recursively applies the substitution map to a type, replacing all
/// resolved `TypeVar`s with their concrete types. /// resolved `TypeVar`s with their concrete types.
#[allow(dead_code)]
fn apply_subst(ty: StaticType, subst: &HashMap<u32, StaticType>) -> StaticType { fn apply_subst(ty: StaticType, subst: &HashMap<u32, StaticType>) -> StaticType {
match ty { match ty {
StaticType::TypeVar(n) => { StaticType::TypeVar(n) => {
@@ -191,7 +189,6 @@ impl TypeChecker {
/// Returns true if `TypeVar(var_id)` appears anywhere in `ty` under the /// Returns true if `TypeVar(var_id)` appears anywhere in `ty` under the
/// current substitution. Used to prevent infinite types (occurs check). /// current substitution. Used to prevent infinite types (occurs check).
#[allow(dead_code)]
fn occurs(var_id: u32, ty: &StaticType, subst: &HashMap<u32, StaticType>) -> bool { fn occurs(var_id: u32, ty: &StaticType, subst: &HashMap<u32, StaticType>) -> bool {
match ty { match ty {
StaticType::TypeVar(n) => { StaticType::TypeVar(n) => {
@@ -220,7 +217,6 @@ impl TypeChecker {
/// Unifies two types under the current substitution. /// Unifies two types under the current substitution.
/// On success, the substitution is extended so that `ty1` and `ty2` become equal. /// On success, the substitution is extended so that `ty1` and `ty2` become equal.
/// On failure (type mismatch or occurs check), a diagnostic error is emitted. /// On failure (type mismatch or occurs check), a diagnostic error is emitted.
#[allow(dead_code)]
fn unify(&self, ty1: StaticType, ty2: StaticType, diag: &mut Diagnostics) { fn unify(&self, ty1: StaticType, ty2: StaticType, diag: &mut Diagnostics) {
let mut subst = self.subst.borrow_mut(); let mut subst = self.subst.borrow_mut();
let ty1 = Self::apply_subst(ty1, &subst); let ty1 = Self::apply_subst(ty1, &subst);
@@ -256,13 +252,195 @@ impl TypeChecker {
} }
} }
/// Returns true if `node` is an identifier with the given symbol name.
fn is_identifier_named(name: &str, node: &TypedNode) -> bool {
matches!(&node.kind, NodeKind::Identifier { symbol, .. } if symbol.name.as_ref() == name)
}
/// Converts a resolved series element type to the schema `Value` passed to the
/// `series` runtime: a type keyword (`:float`, `:int`, …) for scalar types, or a
/// schema record (`{:price :float …}`) for record types.
/// Returns `None` if the type is still unresolved (`TypeVar`, `Any`, …).
fn series_element_to_schema_value(ty: &StaticType) -> Option<Value> {
match ty {
StaticType::Float => Some(Value::Keyword(Keyword::intern("float"))),
StaticType::Int | StaticType::DateTime => Some(Value::Keyword(Keyword::intern("int"))),
StaticType::Bool => Some(Value::Keyword(Keyword::intern("bool"))),
StaticType::Text => Some(Value::Keyword(Keyword::intern("text"))),
StaticType::Record(layout) => {
// Build schema record: { :field :type_keyword ... }
let schema_fields: Vec<(Keyword, StaticType)> = layout
.fields
.iter()
.map(|(k, _)| (*k, StaticType::Keyword))
.collect();
let schema_layout = RecordLayout::get_or_create(schema_fields);
let schema_values: Option<Vec<Value>> = layout
.fields
.iter()
.map(|(_, field_ty)| Self::series_element_to_schema_value(field_ty))
.collect();
schema_values.map(|vals| Value::Record(schema_layout, std::rc::Rc::new(vals)))
}
_ => None,
}
}
/// Walks the typed AST, applies the HM substitution to every type annotation,
/// and elaborates `(series n)` calls by injecting the resolved schema as a
/// second argument so the runtime can allocate the correct storage.
fn finalize_node(node: TypedNode, subst: &HashMap<u32, StaticType>) -> TypedNode {
let new_ty = Self::apply_subst(node.ty, subst);
let new_kind = Self::finalize_kind(node.kind, subst, &new_ty, &node.identity);
Node { kind: new_kind, ty: new_ty, identity: node.identity, comments: node.comments }
}
fn finalize_kind(
kind: NodeKind<TypedPhase>,
subst: &HashMap<u32, StaticType>,
node_ty: &StaticType,
identity: &Rc<NodeIdentity>,
) -> NodeKind<TypedPhase> {
match kind {
// Leaf nodes — nothing to recurse into
NodeKind::Nop => NodeKind::Nop,
NodeKind::Constant(v) => NodeKind::Constant(v),
NodeKind::FieldAccessor(k) => NodeKind::FieldAccessor(k),
NodeKind::Error => NodeKind::Error,
NodeKind::Identifier { symbol, binding } => NodeKind::Identifier { symbol, binding },
NodeKind::Extension(ext) => NodeKind::Extension(ext),
// Call: detect `(series n)` and inject the resolved schema arg
NodeKind::Call { callee, args } => {
let callee_fin = Rc::new(Self::finalize_node((*callee).clone(), subst));
if Self::is_identifier_named("series", &callee_fin) {
if let StaticType::Series(inner) = node_ty {
if let NodeKind::Tuple { elements } = &args.kind {
if elements.len() == 1 {
if let Some(schema_val) =
Self::series_element_to_schema_value(inner)
{
let orig_arg = Rc::new(Self::finalize_node(
(*elements[0]).clone(),
subst,
));
let schema_ty = schema_val.static_type();
let schema_node = Rc::new(Node {
kind: NodeKind::Constant(schema_val),
ty: schema_ty,
identity: identity.clone(),
comments: Rc::from([]),
});
let new_elems = vec![orig_arg, schema_node];
let elem_types: Vec<StaticType> =
new_elems.iter().map(|e| e.ty.clone()).collect();
let new_args = Node {
kind: NodeKind::Tuple { elements: new_elems },
ty: StaticType::Tuple(elem_types),
identity: args.identity.clone(),
comments: args.comments.clone(),
};
return NodeKind::Call {
callee: callee_fin,
args: Rc::new(new_args),
};
}
}
}
}
}
let args_fin = Rc::new(Self::finalize_node((*args).clone(), subst));
NodeKind::Call { callee: callee_fin, args: args_fin }
}
NodeKind::If { cond, then_br, else_br } => NodeKind::If {
cond: Rc::new(Self::finalize_node((*cond).clone(), subst)),
then_br: Rc::new(Self::finalize_node((*then_br).clone(), subst)),
else_br: else_br.map(|e| Rc::new(Self::finalize_node((*e).clone(), subst))),
},
NodeKind::Def { pattern, value, info } => NodeKind::Def {
pattern: Rc::new(Self::finalize_node((*pattern).clone(), subst)),
value: Rc::new(Self::finalize_node((*value).clone(), subst)),
info,
},
NodeKind::Assign { target, value, info } => NodeKind::Assign {
target: Rc::new(Self::finalize_node((*target).clone(), subst)),
value: Rc::new(Self::finalize_node((*value).clone(), subst)),
info,
},
NodeKind::Lambda { params, body, info } => NodeKind::Lambda {
params: Rc::new(Self::finalize_node((*params).clone(), subst)),
body: Rc::new(Self::finalize_node((*body).clone(), subst)),
info,
},
NodeKind::Again { args } => NodeKind::Again {
args: Rc::new(Self::finalize_node((*args).clone(), subst)),
},
NodeKind::Block { exprs } => NodeKind::Block {
exprs: exprs
.into_iter()
.map(|e| Rc::new(Self::finalize_node((*e).clone(), subst)))
.collect(),
},
NodeKind::Tuple { elements } => NodeKind::Tuple {
elements: elements
.into_iter()
.map(|e| Rc::new(Self::finalize_node((*e).clone(), subst)))
.collect(),
},
NodeKind::Record { fields, layout } => NodeKind::Record {
fields: fields
.into_iter()
.map(|(k, v)| {
(
Rc::new(Self::finalize_node((*k).clone(), subst)),
Rc::new(Self::finalize_node((*v).clone(), subst)),
)
})
.collect(),
layout,
},
NodeKind::GetField { rec, field } => NodeKind::GetField {
rec: Rc::new(Self::finalize_node((*rec).clone(), subst)),
field,
},
NodeKind::Expansion { original_call, expanded } => NodeKind::Expansion {
original_call,
expanded: Rc::new(Self::finalize_node((*expanded).clone(), subst)),
},
NodeKind::MacroDecl { name, params, body } => NodeKind::MacroDecl {
name,
params: Rc::new(Self::finalize_node((*params).clone(), subst)),
body: Rc::new(Self::finalize_node((*body).clone(), subst)),
},
NodeKind::Template(inner) => {
NodeKind::Template(Rc::new(Self::finalize_node((*inner).clone(), subst)))
}
NodeKind::Placeholder(inner) => {
NodeKind::Placeholder(Rc::new(Self::finalize_node((*inner).clone(), subst)))
}
NodeKind::Splice(inner) => {
NodeKind::Splice(Rc::new(Self::finalize_node((*inner).clone(), subst)))
}
}
}
/// Applies the final HM substitution to all type annotations in the tree and
/// elaborates `(series n)` calls with their inferred schema arguments.
/// Must be called after `check()` or `check_node_as_bound()` completes.
pub fn finalize(&self, node: TypedNode) -> TypedNode {
let subst = self.subst.borrow().clone();
Self::finalize_node(node, &subst)
}
pub fn check( pub fn check(
&self, &self,
node: &Node<BoundPhase>, node: &Node<BoundPhase>,
arg_types: &[StaticType], arg_types: &[StaticType],
diag: &mut Diagnostics, diag: &mut Diagnostics,
) -> TypedNode { ) -> TypedNode {
self.check_node_as_bound(node, arg_types, diag) let typed = self.check_node_as_bound(node, arg_types, diag);
self.finalize(typed)
} }
/// Allows re-checking a node from any phase as if it were a bound node. /// Allows re-checking a node from any phase as if it were a bound node.
@@ -912,7 +1090,7 @@ impl TypeChecker {
self.check_node(args, ctx, diag) self.check_node(args, ctx, diag)
}; };
let ret_ty = match callee_typed.ty.resolve_call(&args_typed.ty) { let mut ret_ty = match callee_typed.ty.resolve_call(&args_typed.ty) {
Some(ty) => ty, Some(ty) => ty,
None => { None => {
diag.push_error( diag.push_error(
@@ -926,6 +1104,43 @@ impl TypeChecker {
} }
}; };
// HM: (series n) returns Series(Any) — replace with a fresh TypeVar so
// subsequent push calls can unify the element type.
if Self::is_identifier_named("series", &callee_typed) {
if let StaticType::Series(inner) = &ret_ty {
if **inner == StaticType::Any {
ret_ty = StaticType::Series(Box::new(self.fresh_var()));
}
}
}
// HM: (push series val) — unify the series element TypeVar with the value
// type and propagate the resolved type back to the binding in the context.
if Self::is_identifier_named("push", &callee_typed) {
if let NodeKind::Tuple { elements } = &args_typed.kind {
if elements.len() >= 2 {
let series_arg = &elements[0];
let value_arg = &elements[1];
if let StaticType::Series(inner) = &series_arg.ty {
let inner_ty = (**inner).clone();
let val_ty = value_arg.ty.clone();
self.unify(inner_ty, val_ty, diag);
if let NodeKind::Identifier {
binding: IdentifierBinding::Reference(addr),
..
} = &series_arg.kind
{
let resolved = Self::apply_subst(
series_arg.ty.clone(),
&self.subst.borrow(),
);
ctx.set_type(*addr, resolved);
}
}
}
}
}
( (
NodeKind::Call { NodeKind::Call {
callee: Rc::new(callee_typed), callee: Rc::new(callee_typed),
+1
View File
@@ -819,6 +819,7 @@ impl Environment {
// us re-run type inference on the TypedNode with concrete `arg_types`: existing // us re-run type inference on the TypedNode with concrete `arg_types`: existing
// `ty` metadata is discarded and all types are inferred fresh from the call site. // `ty` metadata is discarded and all types are inferred fresh from the call site.
let retyped_ast = checker.check_node_as_bound(func_template.ty.original.as_ref(), arg_types, &mut diag); let retyped_ast = checker.check_node_as_bound(func_template.ty.original.as_ref(), arg_types, &mut diag);
let retyped_ast = checker.finalize(retyped_ast);
if diag.has_errors() { if diag.has_errors() {
return Err(diag.format_errors()); return Err(diag.format_errors());
+3 -2
View File
@@ -20,9 +20,10 @@
) )
;; Creates a stateful cache (Series) from a stateless stream. ;; Creates a stateful cache (Series) from a stateless stream.
(macro cache [lookback type src] ;; The element type is inferred from the stream's item type.
(macro cache [lookback src]
`(do `(do
(def data (series ~lookback ~type)) (def data (series ~lookback))
(pipe [~src] (fn [s] (do (push data s)))) (pipe [~src] (fn [s] (do (push data s))))
data data
) )
+6 -3
View File
@@ -11,12 +11,15 @@ use crate::ast::types::{Purity, RecordLayout, Signature, StaticType, Value};
// ============================================================================ // ============================================================================
pub fn register(env: &Environment) { pub fn register(env: &Environment) {
// (series lookback_limit template_or_type_keyword) -> Series // (series lookback_limit) -> Series
// The element type is inferred by the HM type checker from subsequent push calls.
// After type checking, the compiler elaborates (series n) into (series n schema)
// so the runtime always receives the explicit schema argument.
env.register_native_fn( env.register_native_fn(
"series", "series",
StaticType::Function(Box::new(Signature { StaticType::Function(Box::new(Signature {
params: StaticType::Tuple(vec![StaticType::Int, StaticType::Any]), params: StaticType::Tuple(vec![StaticType::Int]),
ret: StaticType::Any, ret: StaticType::Series(Box::new(StaticType::Any)),
})), })),
Purity::Impure, Purity::Impure,
|args: &[Value]| { |args: &[Value]| {
+15
View File
@@ -596,6 +596,21 @@ impl StaticType {
.map(|sig| sig.ret.clone()), .map(|sig| sig.ret.clone()),
StaticType::PolymorphicFn { resolve_return, .. } => resolve_return(args_ty), StaticType::PolymorphicFn { resolve_return, .. } => resolve_return(args_ty),
StaticType::TypeVar(_) => Some(StaticType::Any), StaticType::TypeVar(_) => Some(StaticType::Any),
// Lookback indexing: (my-series 0) → element type
StaticType::Series(inner) => {
let is_int = matches!(
args_ty,
StaticType::Int | StaticType::Any | StaticType::TypeVar(_)
) || matches!(args_ty, StaticType::Tuple(elems)
if elems.len() == 1
&& matches!(&elems[0], StaticType::Int | StaticType::Any | StaticType::TypeVar(_))
);
if is_int {
Some(*inner.clone())
} else {
None
}
}
_ => None, _ => None,
} }
} }
+30
View File
@@ -10,6 +10,36 @@ fn test_series_api_with_limit() {
assert!(result.is_ok(), "Series creation with limit should work: {:?}", result.err()); assert!(result.is_ok(), "Series creation with limit should work: {:?}", result.err());
} }
/// Regression test for HM type inference through nested closures.
///
/// `series` is created in an outer closure, `push` happens in an inner closure.
/// The pushed value type must propagate back through the TypeVar chain so that
/// the compiler can inject the `:float` schema — no explicit schema given.
#[test]
fn test_series_infer_type_from_nested_closure() {
let env = Environment::new();
let source = r#"
(do
(def make-acc
(fn [n]
(do
(def s (series n))
(def total 0.0)
(fn [x]
(do
(assign total (+ total x))
(push s x)
(s 0))))))
(def acc (make-acc 5))
(acc 1.5))
"#;
let result = env.run_script(source);
match result {
Ok(Value::Float(v)) if (v - 1.5).abs() < 1e-9 => {}
other => panic!("Expected Float(1.5), got {:?}", other),
}
}
#[test] #[test]
fn test_record_structural_equality_order() { fn test_record_structural_equality_order() {
let env = Environment::new(); let env = Environment::new();