Add call hooks for series and push

Introduces `CallHooks` to enable extensions to the type inference and
AST finalization process. This allows for more dynamic and configurable
behavior without hardcoding symbol names in the compiler core.

Specifically, this commit adds:

- **`series_post_call`**: A hook for the `(series n)` function. It
  ensures that the return type is a fresh `TypeVar` when the element
  type is `Any`, allowing for proper type inference from subsequent
  `push` calls.
- **`series_finalize`**: A hook for `(series n)` that rewrites the AST
  node. It replaces the generic `series` call with a pre-configured
  factory closure that allocates the correct series storage type (e.g.,
  `ScalarSeries<f64>` for floats, `RecordSeries` for records). This
  avoids runtime dispatch and relies on type information captured during
  compilation.
- **`push_post_call`**: A hook for the `(push series val)` function. It
  unifies the series element `TypeVar` with the pushed value's type. It
  also handles numeric and record field promotion and updates the
  `TypeVar` binding if a promotion occurs.
This commit is contained in:
2026-03-28 21:13:26 +01:00
parent 6cab8f649b
commit 8efd12add6
7 changed files with 408 additions and 205 deletions
+164 -45
View File
@@ -1,10 +1,164 @@
pub mod data;
pub use data::*;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use crate::ast::compiler::call_hooks::{FinalizeHook, PostCallHook, TypeInferenceAccess, TypeSlotAccess};
use crate::ast::diagnostics::Diagnostics;
use crate::ast::environment::Environment;
use crate::ast::types::{Purity, RecordLayout, Signature, StaticType, Value};
use crate::ast::nodes::{IdentifierBinding, Node, NodeKind, TypedNode, TypedPhase};
use crate::ast::types::{NativeFunction, NodeIdentity, Purity, Signature, SourceLocation, StaticType, Value};
// ============================================================================
// 4. Type-Inference Hooks (registered via RTL bootstrap, no name coupling)
// ============================================================================
/// Post-call hook for `(series n)`.
/// Replaces the `Series(Any)` return type with a fresh TypeVar so that
/// subsequent `push` calls can unify the element type (HM inference).
fn series_post_call(
_args: &TypedNode,
ret_ty: StaticType,
inference: &dyn TypeInferenceAccess,
_ctx: &mut dyn TypeSlotAccess,
_diag: &mut Diagnostics,
) -> StaticType {
if let StaticType::Series(inner) = &ret_ty
&& **inner == StaticType::Any
{
return StaticType::Series(Box::new(inference.fresh_var()));
}
ret_ty
}
/// Finalization hook for `(series n)`.
/// Replaces the callee with a pre-configured factory closure that directly
/// allocates the correct storage type (e.g. `ScalarSeries::<f64>` for Float,
/// `RecordSeries` with a captured `Arc<RecordLayout>` for record types).
/// No schema argument is needed — the type is captured in the closure.
fn series_finalize(
_callee: Rc<TypedNode>,
args: Rc<TypedNode>,
node_ty: &StaticType,
_subst: &HashMap<u32, StaticType>,
) -> Option<NodeKind<TypedPhase>> {
let StaticType::Series(inner) = node_ty else { return None };
let NodeKind::Tuple { elements } = &args.kind else { return None };
if elements.len() != 1 {
return None;
}
// Build a factory Value::Function whose closure already knows the exact
// storage type — no keyword encoding, no runtime dispatch.
let factory_value: Value = match inner.as_ref() {
StaticType::Float => Value::Function(Rc::new(NativeFunction {
func: Rc::new(|args: &[Value]| {
let n = args[0].as_int().unwrap_or(0) as usize;
Value::Series(Rc::new(ScalarSeries::<f64>::new("FloatSeries", n)))
}),
purity: Purity::Impure,
})),
StaticType::Int | StaticType::DateTime => Value::Function(Rc::new(NativeFunction {
func: Rc::new(|args: &[Value]| {
let n = args[0].as_int().unwrap_or(0) as usize;
Value::Series(Rc::new(ScalarSeries::<i64>::new("IntSeries", n)))
}),
purity: Purity::Impure,
})),
StaticType::Bool => Value::Function(Rc::new(NativeFunction {
func: Rc::new(|args: &[Value]| {
let n = args[0].as_int().unwrap_or(0) as usize;
Value::Series(Rc::new(ScalarSeries::<bool>::new("BoolSeries", n)))
}),
purity: Purity::Impure,
})),
StaticType::Text => Value::Function(Rc::new(NativeFunction {
func: Rc::new(|args: &[Value]| {
let n = args[0].as_int().unwrap_or(0) as usize;
Value::Series(Rc::new(ValueSeries::new(n)))
}),
purity: Purity::Impure,
})),
StaticType::Record(layout) => {
let layout = Arc::clone(layout);
Value::Function(Rc::new(NativeFunction {
func: Rc::new(move |args: &[Value]| {
let n = args[0].as_int().unwrap_or(0) as usize;
Value::Series(Rc::new(RecordSeries::new(Arc::clone(&layout), n)))
}),
purity: Purity::Impure,
}))
}
_ => return None,
};
let factory_node = Rc::new(Node {
kind: NodeKind::Constant(factory_value),
ty: StaticType::Any,
identity: NodeIdentity::new(SourceLocation { line: 0, col: 0 }),
comments: Rc::from([]),
});
Some(NodeKind::Call { callee: factory_node, args: Rc::clone(&args) })
}
/// Post-call hook for `(push series val)`.
/// Unifies the series element TypeVar with the pushed value type, applying
/// Int→Float numeric promotion and record field promotion when needed.
///
/// We intentionally do NOT bake the resolved type into ctx after normal
/// unification. Keeping `Series(TypeVar(n))` in ctx allows a later push
/// to recover the TypeVar ID and rebind it (e.g. Int → Float promotion).
/// All identifier lookups call `apply_subst`, so the resolved type is
/// always correct regardless of what ctx stores.
fn push_post_call(
args: &TypedNode,
ret_ty: StaticType,
inference: &dyn TypeInferenceAccess,
ctx: &mut dyn TypeSlotAccess,
diag: &mut Diagnostics,
) -> StaticType {
let NodeKind::Tuple { elements } = &args.kind else { return ret_ty };
if elements.len() < 2 {
return ret_ty;
}
let series_arg = &elements[0];
let value_arg = &elements[1];
let StaticType::Series(inner) = &series_arg.ty else { return ret_ty };
let inner_ty = (**inner).clone();
let val_ty = value_arg.ty.clone();
if let NodeKind::Identifier {
binding: IdentifierBinding::Reference(addr),
..
} = &series_arg.kind
{
// Recover the raw inner type from ctx (before apply_subst) so we can
// find the TypeVar ID even after the first push resolved it.
let raw_inner = match ctx.get_slot_type(*addr) {
StaticType::Series(ri) => *ri,
_ => inner_ty.clone(),
};
if let Some(promoted) = inference
.try_numeric_widen(&inner_ty, &val_ty)
.or_else(|| inference.try_record_promote(&inner_ty, &val_ty))
{
// Rebind the TypeVar to the promoted type so that finalize
// injects the correct schema (e.g. :float instead of :int).
if let StaticType::TypeVar(n) = raw_inner {
inference.bind_typevar(n, promoted.clone());
}
ctx.set_slot_type(*addr, StaticType::Series(Box::new(promoted)));
} else {
inference.unify(inner_ty, val_ty, diag);
}
} else {
inference.unify(inner_ty, val_ty, diag);
}
ret_ty
}
// ============================================================================
// 5. Script Integration (RTL Registration)
@@ -23,51 +177,15 @@ pub fn register(env: &Environment) {
})),
Purity::Impure,
|args: &[Value]| {
// The FinalizeHook replaces this call with a pre-configured factory
// for resolved element types. This fallback only runs when the element
// type could not be determined at compile time (e.g. push inside a
// nested closure the type checker could not reach).
let lookback_limit = args[0].as_int().unwrap_or(0) as usize;
// The type checker injects the schema as a second argument via HM inference.
// If schema injection was not possible (e.g. the first push is inside a nested
// closure that the type checker could not reach), fall back to a ValueSeries
// that stores unboxed Values. The HM type checker still guarantees type safety
// at compile time; this path is only a runtime fallback for storage allocation.
if args.len() < 2 {
return Value::Series(Rc::new(ValueSeries::new(lookback_limit)));
}
let arg = &args[1];
match arg {
Value::Keyword(k) => {
match k.name().to_lowercase().as_str() {
"float" => Value::Series(Rc::new(ScalarSeries::<f64>::new("FloatSeries", lookback_limit))),
"int" | "datetime" => Value::Series(Rc::new(ScalarSeries::<i64>::new("IntSeries", lookback_limit))),
"bool" => Value::Series(Rc::new(ScalarSeries::<bool>::new("BoolSeries", lookback_limit))),
"text" => Value::Series(Rc::new(ValueSeries::new(lookback_limit))),
_ => panic!("Unknown or unsupported series type keyword: :{}", k.name()),
}
}
Value::Record(schema_layout, schema_values) => {
// Interpret the record as a schema: { :field :type_keyword }
let mut fields = Vec::with_capacity(schema_layout.fields.len());
for (i, (name, _)) in schema_layout.fields.iter().enumerate() {
let type_keyword = match &schema_values[i] {
Value::Keyword(tk) => tk.name().to_lowercase(),
_ => panic!("Field :{} in series schema must be a type keyword (e.g., :float, :int)", name.name()),
};
let ty = match type_keyword.as_str() {
"float" => StaticType::Float,
"int" => StaticType::Int,
"bool" => StaticType::Bool,
"datetime" => StaticType::DateTime,
"text" => StaticType::Text,
_ => panic!("Unsupported type keyword :{} for series field :{}", type_keyword, name.name()),
};
fields.push((*name, ty));
}
let final_layout = RecordLayout::get_or_create(fields);
Value::Series(Rc::new(RecordSeries::new(final_layout, lookback_limit)))
}
_ => panic!("series: second arg must be a type keyword (:float, :int…) or a schema record ({{:field :type}})"),
}
Value::Series(Rc::new(ValueSeries::new(lookback_limit)))
},
).doc("Creates a new series (ring buffer) with the given lookback limit.")
).with_call_hooks(Some(series_post_call as PostCallHook), Some(series_finalize as FinalizeHook))
.doc("Creates a new series (ring buffer) with the given lookback limit.")
.description("Takes a single lookback limit (int). The element type is inferred at compile time from subsequent push calls via HM type inference. The compiler injects a second schema argument automatically; the runtime also accepts an explicit schema for cases where inference is not possible.")
.examples(&[
"(series 100)",
@@ -94,7 +212,8 @@ pub fn register(env: &Environment) {
}
panic!("push expects a series as the first argument")
},
).doc("Pushes a new value into a series. After push, index 0 returns this value.")
).with_call_hooks(Some(push_post_call as PostCallHook), None)
.doc("Pushes a new value into a series. After push, index 0 returns this value.")
.examples(&[
"(push my-ticks {:price 10.5 :volume 100})",
"(push prices 42.0)",