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:
+2
-2
@@ -5,7 +5,7 @@
|
||||
(def make-sma
|
||||
(fn [lookback]
|
||||
(do
|
||||
(def s (series lookback :float))
|
||||
(def s (series lookback))
|
||||
(def running-sum 0.0)
|
||||
(def count 0)
|
||||
(fn [x]
|
||||
@@ -22,7 +22,7 @@
|
||||
(do
|
||||
;; Sicherstellen, dass lookback mindestens 1 ist
|
||||
(def n (if (< lookback 1) 1 lookback))
|
||||
(def s (series n :float))
|
||||
(def s (series n))
|
||||
(def price-sum 0.0)
|
||||
(def weighted-sum 0.0)
|
||||
(def count 0)
|
||||
|
||||
+3
-3
@@ -11,11 +11,11 @@
|
||||
(pipe [(pipe [(.close src)] (SMA 5))] (printx "sma5="))
|
||||
(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)))))
|
||||
|
||||
(cache 20 :float src)
|
||||
(cache 20 src)
|
||||
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
;; Benchmark: 1.1us
|
||||
;; Benchmark-Repeat: 1877
|
||||
(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 11.2 :volume 200 :msg "B"})
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
(def SMA
|
||||
(fn [length]
|
||||
(do
|
||||
(def history (series length :float))
|
||||
(def history (series length))
|
||||
(def sum 0.0)
|
||||
|
||||
(fn [val]
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::ast::nodes::{
|
||||
Node, NodeKind, TypedNode, TypedPhase, VirtualId,
|
||||
};
|
||||
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::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
@@ -149,7 +149,6 @@ impl TypeChecker {
|
||||
}
|
||||
|
||||
/// Creates a fresh, unique type variable.
|
||||
#[allow(dead_code)]
|
||||
fn fresh_var(&self) -> StaticType {
|
||||
let id = self.var_counter.get();
|
||||
self.var_counter.set(id + 1);
|
||||
@@ -158,7 +157,6 @@ impl TypeChecker {
|
||||
|
||||
/// Recursively applies the substitution map to a type, replacing all
|
||||
/// resolved `TypeVar`s with their concrete types.
|
||||
#[allow(dead_code)]
|
||||
fn apply_subst(ty: StaticType, subst: &HashMap<u32, StaticType>) -> StaticType {
|
||||
match ty {
|
||||
StaticType::TypeVar(n) => {
|
||||
@@ -191,7 +189,6 @@ impl TypeChecker {
|
||||
|
||||
/// Returns true if `TypeVar(var_id)` appears anywhere in `ty` under the
|
||||
/// current substitution. Used to prevent infinite types (occurs check).
|
||||
#[allow(dead_code)]
|
||||
fn occurs(var_id: u32, ty: &StaticType, subst: &HashMap<u32, StaticType>) -> bool {
|
||||
match ty {
|
||||
StaticType::TypeVar(n) => {
|
||||
@@ -220,7 +217,6 @@ impl TypeChecker {
|
||||
/// Unifies two types under the current substitution.
|
||||
/// 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.
|
||||
#[allow(dead_code)]
|
||||
fn unify(&self, ty1: StaticType, ty2: StaticType, diag: &mut Diagnostics) {
|
||||
let mut subst = self.subst.borrow_mut();
|
||||
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(
|
||||
&self,
|
||||
node: &Node<BoundPhase>,
|
||||
arg_types: &[StaticType],
|
||||
diag: &mut Diagnostics,
|
||||
) -> 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.
|
||||
@@ -912,7 +1090,7 @@ impl TypeChecker {
|
||||
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,
|
||||
None => {
|
||||
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 {
|
||||
callee: Rc::new(callee_typed),
|
||||
|
||||
@@ -819,6 +819,7 @@ impl Environment {
|
||||
// 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.
|
||||
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() {
|
||||
return Err(diag.format_errors());
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
)
|
||||
|
||||
;; 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
|
||||
(def data (series ~lookback ~type))
|
||||
(def data (series ~lookback))
|
||||
(pipe [~src] (fn [s] (do (push data s))))
|
||||
data
|
||||
)
|
||||
|
||||
@@ -11,12 +11,15 @@ use crate::ast::types::{Purity, RecordLayout, Signature, StaticType, Value};
|
||||
// ============================================================================
|
||||
|
||||
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(
|
||||
"series",
|
||||
StaticType::Function(Box::new(Signature {
|
||||
params: StaticType::Tuple(vec![StaticType::Int, StaticType::Any]),
|
||||
ret: StaticType::Any,
|
||||
params: StaticType::Tuple(vec![StaticType::Int]),
|
||||
ret: StaticType::Series(Box::new(StaticType::Any)),
|
||||
})),
|
||||
Purity::Impure,
|
||||
|args: &[Value]| {
|
||||
|
||||
@@ -596,6 +596,21 @@ impl StaticType {
|
||||
.map(|sig| sig.ret.clone()),
|
||||
StaticType::PolymorphicFn { resolve_return, .. } => resolve_return(args_ty),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,36 @@ fn test_series_api_with_limit() {
|
||||
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]
|
||||
fn test_record_structural_equality_order() {
|
||||
let env = Environment::new();
|
||||
|
||||
Reference in New Issue
Block a user