Refactor: Replace Object with StreamStorage for streams
The `Object` trait is too generic and has been causing confusion. This commit introduces `StreamStorage` as a dedicated trait for reactive stream types. This change involves: - Renaming `Object` to `StreamStorage` in relevant places. - Updating the `Value::Object` enum variant to `Value::Stream`. - Modifying how streams are handled in the compiler, VM, and tests to use the new `StreamStorage` trait. - Adjusting example scripts and tests to reflect the change in type representation. - The `StreamNode` struct now explicitly holds its `element_type`.
This commit is contained in:
@@ -20,5 +20,5 @@
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
(assert-eq :StreamNode (type-of my_indicator))
|
(assert-eq :stream (type-of my_indicator))
|
||||||
)
|
)
|
||||||
@@ -44,5 +44,5 @@
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
(assert-eq :StreamNode (type-of my_indicator))
|
(assert-eq :stream (type-of my_indicator))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,5 +19,5 @@
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
(assert-eq :StreamNode (type-of indicator))
|
(assert-eq :stream (type-of indicator))
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-15
@@ -1,6 +1,5 @@
|
|||||||
use crate::ast::nodes::{AnalyzedNode, ExecNode};
|
use crate::ast::nodes::{AnalyzedNode, ExecNode};
|
||||||
use crate::ast::types::{Object, Value};
|
use crate::ast::types::Value;
|
||||||
use std::any::Any;
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -45,16 +44,3 @@ impl Closure {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Object for Closure {
|
|
||||||
fn type_name(&self) -> &'static str {
|
|
||||||
"closure"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any(&self) -> &dyn Any {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_rc_any(self: Rc<Self>) -> Rc<dyn Any> {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ impl Specializer {
|
|||||||
// Only replace the callee if the compiled value is actually a function/object.
|
// Only replace the callee if the compiled value is actually a function/object.
|
||||||
// If it's a scalar (like 30 from folding), we DON'T fold here.
|
// If it's a scalar (like 30 from folding), we DON'T fold here.
|
||||||
// We keep the Call but update the callee to the specialized version if it's an object.
|
// We keep the Call but update the callee to the specialized version if it's an object.
|
||||||
if let Value::Object(_) | Value::Function(_) = &compiled_val {
|
if let Value::Stream(_) | Value::Function(_) = &compiled_val {
|
||||||
let specialized_callee = self.make_constant_node(
|
let specialized_callee = self.make_constant_node(
|
||||||
compiled_val,
|
compiled_val,
|
||||||
StaticType::Function(Box::new(Signature {
|
StaticType::Function(Box::new(Signature {
|
||||||
|
|||||||
+143
-63
@@ -1,7 +1,10 @@
|
|||||||
|
use crate::ast::compiler::call_hooks::RtlCompilerHook;
|
||||||
|
use crate::ast::nodes::{Node, NodeKind, TypedNode, TypedPhase};
|
||||||
use crate::ast::rtl::series::{RingBuffer, ScalarValue, SeriesMember};
|
use crate::ast::rtl::series::{RingBuffer, ScalarValue, SeriesMember};
|
||||||
use crate::ast::types::{Object, PipeFn, Value};
|
use crate::ast::types::{NativeFunction, NodeIdentity, PipeFn, SourceLocation, StreamStorage, Value};
|
||||||
use crate::ast::vm::VM;
|
use crate::ast::vm::{GlobalStore, VM};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
/// A Signal is the "packet" flowing through the reactive pipeline.
|
/// A Signal is the "packet" flowing through the reactive pipeline.
|
||||||
@@ -52,10 +55,13 @@ impl<T: ObservableStream> ObservableStream for std::cell::RefCell<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A generic wrapper to pass ANY ObservableStream (Root, Pipe, etc.) as an Object to the VM.
|
/// A generic wrapper to pass ANY ObservableStream (Root, Pipe, etc.) as a Stream to the VM.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct StreamNode {
|
pub struct StreamNode {
|
||||||
pub inner: Rc<dyn ObservableStream>,
|
pub inner: Rc<dyn ObservableStream>,
|
||||||
|
/// The `StaticType` of the elements emitted by this stream.
|
||||||
|
/// Set at construction time and used by `Value::static_type()`.
|
||||||
|
pub element_type: StaticType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for StreamNode {
|
impl std::fmt::Debug for StreamNode {
|
||||||
@@ -64,9 +70,9 @@ impl std::fmt::Debug for StreamNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Object for StreamNode {
|
impl StreamStorage for StreamNode {
|
||||||
fn type_name(&self) -> &'static str {
|
fn stream_type_name(&self) -> &'static str {
|
||||||
"StreamNode"
|
"stream"
|
||||||
}
|
}
|
||||||
fn as_any(&self) -> &dyn std::any::Any {
|
fn as_any(&self) -> &dyn std::any::Any {
|
||||||
self
|
self
|
||||||
@@ -74,6 +80,9 @@ impl Object for StreamNode {
|
|||||||
fn into_rc_any(self: std::rc::Rc<Self>) -> std::rc::Rc<dyn std::any::Any> {
|
fn into_rc_any(self: std::rc::Rc<Self>) -> std::rc::Rc<dyn std::any::Any> {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
fn element_type(&self) -> StaticType {
|
||||||
|
self.element_type.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The RootStream is the "Clock" and data source of the entire pipeline.
|
/// The RootStream is the "Clock" and data source of the entire pipeline.
|
||||||
@@ -145,13 +154,23 @@ pub struct PipeStream {
|
|||||||
pub executor: Option<Box<PipeFn>>,
|
pub executor: Option<Box<PipeFn>>,
|
||||||
/// Observers of THIS pipe.
|
/// Observers of THIS pipe.
|
||||||
observers: RefCell<Vec<Rc<RefCell<dyn Observer>>>>,
|
observers: RefCell<Vec<Rc<RefCell<dyn Observer>>>>,
|
||||||
|
/// Output element type, injected by PipeHook::finalize. `Any` when unknown.
|
||||||
|
pub element_type: StaticType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PipeStream {
|
impl PipeStream {
|
||||||
pub fn new(
|
/// Creates a PipeStream with unknown output element type (`StaticType::Any`).
|
||||||
|
pub fn new(name: String, input_count: usize, executor: Option<Box<PipeFn>>) -> Self {
|
||||||
|
Self::new_typed(name, input_count, executor, StaticType::Any)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a PipeStream with a known output element type.
|
||||||
|
/// Called by `build_pipeline_node` when the type is available from `PipeHook::finalize`.
|
||||||
|
pub fn new_typed(
|
||||||
name: String,
|
name: String,
|
||||||
input_count: usize,
|
input_count: usize,
|
||||||
executor: Option<Box<PipeFn>>,
|
executor: Option<Box<PipeFn>>,
|
||||||
|
element_type: StaticType,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name,
|
name,
|
||||||
@@ -161,6 +180,7 @@ impl PipeStream {
|
|||||||
current_signal: RefCell::new(None),
|
current_signal: RefCell::new(None),
|
||||||
executor,
|
executor,
|
||||||
observers: RefCell::new(Vec::new()),
|
observers: RefCell::new(Vec::new()),
|
||||||
|
element_type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,12 +288,13 @@ impl Observer for RecordPusher {
|
|||||||
pub fn build_pipeline_node(
|
pub fn build_pipeline_node(
|
||||||
inputs: Vec<Rc<dyn ObservableStream>>,
|
inputs: Vec<Rc<dyn ObservableStream>>,
|
||||||
executor: Box<PipeFn>,
|
executor: Box<PipeFn>,
|
||||||
_out_type: &StaticType,
|
out_type: &StaticType,
|
||||||
) -> Rc<StreamNode> {
|
) -> Rc<StreamNode> {
|
||||||
let pipe = Rc::new(RefCell::new(PipeStream::new(
|
let pipe = Rc::new(RefCell::new(PipeStream::new_typed(
|
||||||
"pipe".to_string(),
|
"pipe".to_string(),
|
||||||
inputs.len(),
|
inputs.len(),
|
||||||
Some(executor),
|
Some(executor),
|
||||||
|
out_type.clone(),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// Connect inputs to the pipe
|
// Connect inputs to the pipe
|
||||||
@@ -285,7 +306,7 @@ pub fn build_pipeline_node(
|
|||||||
input.add_observer(adapter);
|
input.add_observer(adapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
Rc::new(StreamNode { inner: pipe })
|
Rc::new(StreamNode { inner: pipe, element_type: out_type.clone() })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_map_stream(input: Rc<dyn ObservableStream>, field: Keyword) -> StreamNode {
|
pub fn build_map_stream(input: Rc<dyn ObservableStream>, field: Keyword) -> StreamNode {
|
||||||
@@ -313,6 +334,7 @@ pub fn build_map_stream(input: Rc<dyn ObservableStream>, field: Keyword) -> Stre
|
|||||||
|
|
||||||
StreamNode {
|
StreamNode {
|
||||||
inner: pipe,
|
inner: pipe,
|
||||||
|
element_type: StaticType::Any,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +345,98 @@ pub fn build_map_stream(input: Rc<dyn ObservableStream>, field: Keyword) -> Stre
|
|||||||
use crate::ast::environment::Environment;
|
use crate::ast::environment::Environment;
|
||||||
use crate::ast::types::{Keyword, Purity, RecordLayout, Signature, StaticType};
|
use crate::ast::types::{Keyword, Purity, RecordLayout, Signature, StaticType};
|
||||||
|
|
||||||
|
/// Extracts `ObservableStream` references from a runtime `Value`.
|
||||||
|
/// Accepts a single `StreamNode` or a `Tuple` of `StreamNode`s.
|
||||||
|
fn extract_obs_streams(val: &Value) -> Vec<Rc<dyn ObservableStream>> {
|
||||||
|
match val {
|
||||||
|
Value::Tuple(elements) => elements
|
||||||
|
.iter()
|
||||||
|
.map(|v| {
|
||||||
|
if let Value::Stream(s) = v
|
||||||
|
&& let Some(sn) = s.as_any().downcast_ref::<StreamNode>()
|
||||||
|
{
|
||||||
|
sn.inner.clone()
|
||||||
|
} else {
|
||||||
|
panic!("pipe: each input must be a StreamNode");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Value::Stream(s) => {
|
||||||
|
if let Some(sn) = s.as_any().downcast_ref::<StreamNode>() {
|
||||||
|
vec![sn.inner.clone()]
|
||||||
|
} else {
|
||||||
|
panic!("pipe: input must be a StreamNode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => panic!("pipe: first argument must be a stream or tuple of streams"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `PipeFn` executor from a runtime `Value::Closure` or `Value::Function`.
|
||||||
|
fn build_pipe_executor(val: &Value, globals: GlobalStore) -> Box<PipeFn> {
|
||||||
|
match val {
|
||||||
|
Value::Closure(rc) => {
|
||||||
|
let my_closure = rc.clone();
|
||||||
|
let mut pipe_vm = VM::new(globals);
|
||||||
|
Box::new(move |call_args: &[Value]| -> Value {
|
||||||
|
pipe_vm
|
||||||
|
.run_with_args(my_closure.clone(), call_args)
|
||||||
|
.unwrap_or_else(|e| panic!("Pipeline lambda execution failed: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Value::Function(f) => {
|
||||||
|
let my_func = f.clone();
|
||||||
|
Box::new(move |call_args: &[Value]| -> Value { (my_func.func)(call_args) })
|
||||||
|
}
|
||||||
|
_ => panic!("pipe: second argument must be a function or closure"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compiler hook for `(pipe inputs lambda)`.
|
||||||
|
///
|
||||||
|
/// - **finalize:** Replaces the `pipe` identifier with a pre-configured factory closure
|
||||||
|
/// that captures both the resolved output element type and the global store. This mirrors
|
||||||
|
/// `SeriesHook::finalize` and makes the element type available to `build_pipeline_node`
|
||||||
|
/// at runtime without a runtime type lookup.
|
||||||
|
pub struct PipeHook {
|
||||||
|
globals: GlobalStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RtlCompilerHook for PipeHook {
|
||||||
|
fn finalize(
|
||||||
|
&self,
|
||||||
|
_callee: Rc<TypedNode>,
|
||||||
|
args: Rc<TypedNode>,
|
||||||
|
node_ty: &StaticType,
|
||||||
|
_subst: &HashMap<u32, StaticType>,
|
||||||
|
) -> Option<NodeKind<TypedPhase>> {
|
||||||
|
let StaticType::Stream(inner) = node_ty else { return None };
|
||||||
|
// Only finalize when the element type is concrete — not unresolved Any or TypeVar.
|
||||||
|
if matches!(inner.as_ref(), StaticType::Any | StaticType::TypeVar(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let element_type = *inner.clone();
|
||||||
|
let globals = self.globals.clone();
|
||||||
|
|
||||||
|
let factory: Value = Value::Function(Rc::new(NativeFunction {
|
||||||
|
func: Rc::new(move |call_args: &[Value]| {
|
||||||
|
let obs = extract_obs_streams(&call_args[0]);
|
||||||
|
let exec = build_pipe_executor(&call_args[1], globals.clone());
|
||||||
|
Value::Stream(build_pipeline_node(obs, exec, &element_type))
|
||||||
|
}),
|
||||||
|
purity: Purity::Impure,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let factory_node = Rc::new(Node {
|
||||||
|
kind: NodeKind::Constant(factory),
|
||||||
|
ty: StaticType::Any,
|
||||||
|
identity: NodeIdentity::new(SourceLocation { line: 0, col: 0 }),
|
||||||
|
comments: Rc::from([]),
|
||||||
|
});
|
||||||
|
Some(NodeKind::Call { callee: factory_node, args })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves the return type of a `pipe` call from its argument types.
|
/// Resolves the return type of a `pipe` call from its argument types.
|
||||||
/// Argument layout: `Tuple([inputs, lambda])` where inputs is a Tuple of streams
|
/// Argument layout: `Tuple([inputs, lambda])` where inputs is a Tuple of streams
|
||||||
/// or a single StreamNode, and lambda is a `Function<args -> U>`.
|
/// or a single StreamNode, and lambda is a `Function<args -> U>`.
|
||||||
@@ -412,11 +526,8 @@ pub fn register(env: &Environment) {
|
|||||||
|
|
||||||
// 1. Create the RootStream
|
// 1. Create the RootStream
|
||||||
let root_stream = Rc::new(RootStream::new());
|
let root_stream = Rc::new(RootStream::new());
|
||||||
let stream_node = StreamNode {
|
|
||||||
inner: root_stream.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Setup the Layout for OHLC records
|
// 2. Setup the Layout for OHLC records (before StreamNode so element_type is available)
|
||||||
let layout = RecordLayout::get_or_create(vec![
|
let layout = RecordLayout::get_or_create(vec![
|
||||||
(Keyword::intern("open"), StaticType::Float),
|
(Keyword::intern("open"), StaticType::Float),
|
||||||
(Keyword::intern("high"), StaticType::Float),
|
(Keyword::intern("high"), StaticType::Float),
|
||||||
@@ -424,6 +535,11 @@ pub fn register(env: &Environment) {
|
|||||||
(Keyword::intern("close"), StaticType::Float),
|
(Keyword::intern("close"), StaticType::Float),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let stream_node = StreamNode {
|
||||||
|
inner: root_stream.clone(),
|
||||||
|
element_type: StaticType::Record(layout.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
// 3. Create the stateful generator closure
|
// 3. Create the stateful generator closure
|
||||||
let mut current_tick = 0;
|
let mut current_tick = 0;
|
||||||
let mut last_close = 100.0;
|
let mut last_close = 100.0;
|
||||||
@@ -465,7 +581,7 @@ pub fn register(env: &Environment) {
|
|||||||
generators.borrow_mut().push(Box::new(generator));
|
generators.borrow_mut().push(Box::new(generator));
|
||||||
|
|
||||||
// 5. Return the stream reference to the script
|
// 5. Return the stream reference to the script
|
||||||
Value::Object(Rc::new(stream_node))
|
Value::Stream(Rc::new(stream_node))
|
||||||
},
|
},
|
||||||
).doc("Creates a stream of random OHLC (Open-High-Low-Close) candles.")
|
).doc("Creates a stream of random OHLC (Open-High-Low-Close) candles.")
|
||||||
.description("Args: (seed: int, limit: int). Uses a random walk model. Connect via (pipe ...).")
|
.description("Args: (seed: int, limit: int). Uses a random walk model. Connect via (pipe ...).")
|
||||||
@@ -500,6 +616,7 @@ pub fn register(env: &Environment) {
|
|||||||
let root_stream = Rc::new(RootStream::new());
|
let root_stream = Rc::new(RootStream::new());
|
||||||
let stream_node = StreamNode {
|
let stream_node = StreamNode {
|
||||||
inner: root_stream.clone(),
|
inner: root_stream.clone(),
|
||||||
|
element_type: StaticType::Bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Setup isolated VM
|
// 2. Setup isolated VM
|
||||||
@@ -528,7 +645,7 @@ pub fn register(env: &Environment) {
|
|||||||
ticker_generators.borrow_mut().push(Box::new(generator));
|
ticker_generators.borrow_mut().push(Box::new(generator));
|
||||||
|
|
||||||
// 5. Return stream
|
// 5. Return stream
|
||||||
Value::Object(Rc::new(stream_node))
|
Value::Stream(Rc::new(stream_node))
|
||||||
},
|
},
|
||||||
).doc("Creates a stream driven by a boolean closure. Ticks as long as closure returns true.")
|
).doc("Creates a stream driven by a boolean closure. Ticks as long as closure returns true.")
|
||||||
.examples(&["(def ticker (create-ticker (fn [] (< (now) end-time))))"]);
|
.examples(&["(def ticker (create-ticker (fn [] (< (now) end-time))))"]);
|
||||||
@@ -537,7 +654,9 @@ pub fn register(env: &Environment) {
|
|||||||
// inputs: a Tuple of StreamNodes or a single StreamNode
|
// inputs: a Tuple of StreamNodes or a single StreamNode
|
||||||
// lambda: a Closure or Function
|
// lambda: a Closure or Function
|
||||||
// Same RTL-only bootstrap capture as create-ticker above.
|
// Same RTL-only bootstrap capture as create-ticker above.
|
||||||
let globals = env.global_store();
|
// `fn_globals` is moved into the native fn fallback; `hook_globals` is captured by PipeHook.
|
||||||
|
let fn_globals = env.global_store();
|
||||||
|
let hook_globals = fn_globals.clone();
|
||||||
env.register_native_fn(
|
env.register_native_fn(
|
||||||
"pipe",
|
"pipe",
|
||||||
StaticType::PolymorphicFn {
|
StaticType::PolymorphicFn {
|
||||||
@@ -546,54 +665,15 @@ pub fn register(env: &Environment) {
|
|||||||
},
|
},
|
||||||
Purity::Impure,
|
Purity::Impure,
|
||||||
move |args: &[Value]| {
|
move |args: &[Value]| {
|
||||||
|
// This fallback only runs when PipeHook::finalize did not replace the callee
|
||||||
|
// (e.g. the output element type could not be determined at compile time).
|
||||||
assert!(args.len() == 2, "pipe expects exactly 2 arguments (inputs, lambda)");
|
assert!(args.len() == 2, "pipe expects exactly 2 arguments (inputs, lambda)");
|
||||||
|
let obs = extract_obs_streams(&args[0]);
|
||||||
// Extract observable streams from args[0]
|
let exec = build_pipe_executor(&args[1], fn_globals.clone());
|
||||||
let obs_streams: Vec<Rc<dyn ObservableStream>> = match &args[0] {
|
Value::Stream(build_pipeline_node(obs, exec, &StaticType::Any))
|
||||||
Value::Tuple(elements) => elements
|
|
||||||
.iter()
|
|
||||||
.map(|v| {
|
|
||||||
if let Value::Object(obj) = v
|
|
||||||
&& let Some(sn) = obj.as_any().downcast_ref::<StreamNode>()
|
|
||||||
{
|
|
||||||
sn.inner.clone()
|
|
||||||
} else {
|
|
||||||
panic!("pipe: each input must be a StreamNode");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
Value::Object(obj) => {
|
|
||||||
if let Some(sn) = obj.as_any().downcast_ref::<StreamNode>() {
|
|
||||||
vec![sn.inner.clone()]
|
|
||||||
} else {
|
|
||||||
panic!("pipe: input must be a StreamNode");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => panic!("pipe: first argument must be a stream or tuple of streams"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the executor closure from args[1]
|
|
||||||
let mut pipe_vm = VM::new(globals.clone());
|
|
||||||
let executor: Box<PipeFn> = match &args[1] {
|
|
||||||
Value::Closure(rc) => {
|
|
||||||
let my_closure = rc.clone();
|
|
||||||
Box::new(move |call_args: &[Value]| -> Value {
|
|
||||||
pipe_vm
|
|
||||||
.run_with_args(my_closure.clone(), call_args)
|
|
||||||
.unwrap_or_else(|e| panic!("Pipeline lambda execution failed: {}", e))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Value::Function(f) => {
|
|
||||||
let my_func = f.clone();
|
|
||||||
Box::new(move |call_args: &[Value]| -> Value { (my_func.func)(call_args) })
|
|
||||||
}
|
|
||||||
_ => panic!("pipe: second argument must be a function or closure"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let node = build_pipeline_node(obs_streams, executor, &StaticType::Any);
|
|
||||||
Value::Object(node)
|
|
||||||
},
|
},
|
||||||
).doc("Connects a lambda to one or more streams, producing a transformed output stream.")
|
).with_compiler_hook(Rc::new(PipeHook { globals: hook_globals }))
|
||||||
|
.doc("Connects a lambda to one or more streams, producing a transformed output stream.")
|
||||||
.description("Returns void from lambda to act as a filter (value is dropped). Supports tuple inputs for barrier synchronization.")
|
.description("Returns void from lambda to act as a filter (value is dropped). Supports tuple inputs for barrier synchronization.")
|
||||||
.examples(&[
|
.examples(&[
|
||||||
"(pipe ohlc (fn [bar] (.close bar)))",
|
"(pipe ohlc (fn [bar] (.close bar)))",
|
||||||
|
|||||||
+16
-15
@@ -192,17 +192,6 @@ impl RecordLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interface for custom RTL objects (Streams and custom extensions).
|
|
||||||
/// For series data, use `Value::Series` with the `SeriesStorage` trait instead.
|
|
||||||
pub trait Object: fmt::Debug {
|
|
||||||
fn type_name(&self) -> &'static str;
|
|
||||||
fn as_any(&self) -> &dyn Any;
|
|
||||||
|
|
||||||
/// Converts `Rc<Self>` into `Rc<dyn Any>`, enabling zero-copy downcasting to a concrete
|
|
||||||
/// `Rc<T>` via `Rc::downcast::<T>()`. Requires `arbitrary_self_types` (stable since 1.81).
|
|
||||||
fn into_rc_any(self: Rc<Self>) -> Rc<dyn Any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read interface for all series types: indexed lookback access and type metadata.
|
/// Read interface for all series types: indexed lookback access and type metadata.
|
||||||
pub trait SeriesStorage: fmt::Debug {
|
pub trait SeriesStorage: fmt::Debug {
|
||||||
fn series_type_name(&self) -> &'static str;
|
fn series_type_name(&self) -> &'static str;
|
||||||
@@ -241,6 +230,18 @@ pub trait PushableStorage: SeriesStorage {
|
|||||||
fn push_value(&self, val: Value);
|
fn push_value(&self, val: Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read interface for reactive stream objects (StreamNode and derived streams).
|
||||||
|
pub trait StreamStorage: fmt::Debug {
|
||||||
|
fn stream_type_name(&self) -> &'static str;
|
||||||
|
fn as_any(&self) -> &dyn Any;
|
||||||
|
|
||||||
|
/// Converts `Rc<Self>` into `Rc<dyn Any>` for zero-copy concrete downcast.
|
||||||
|
fn into_rc_any(self: Rc<Self>) -> Rc<dyn Any>;
|
||||||
|
|
||||||
|
/// Returns the `StaticType` of the elements emitted by this stream.
|
||||||
|
fn element_type(&self) -> StaticType;
|
||||||
|
}
|
||||||
|
|
||||||
/// A shared sequence of values, used by both Tuples and Records.
|
/// A shared sequence of values, used by both Tuples and Records.
|
||||||
pub type ValueList = Rc<Vec<Value>>;
|
pub type ValueList = Rc<Vec<Value>>;
|
||||||
|
|
||||||
@@ -287,7 +288,7 @@ pub enum Value {
|
|||||||
Closure(Rc<Closure>), // Compiled function with captured upvalues
|
Closure(Rc<Closure>), // Compiled function with captured upvalues
|
||||||
Quote(Rc<SyntaxNode>), // Quoted AST node (from 'expr or macro expansion)
|
Quote(Rc<SyntaxNode>), // Quoted AST node (from 'expr or macro expansion)
|
||||||
Series(Rc<dyn SeriesStorage>), // Time series: ScalarSeries, RecordSeries, SeriesView, etc.
|
Series(Rc<dyn SeriesStorage>), // Time series: ScalarSeries, RecordSeries, SeriesView, etc.
|
||||||
Object(Rc<dyn Object>), // RTL extensions: Streams and custom types
|
Stream(Rc<dyn StreamStorage>), // Reactive stream: StreamNode and derived streams
|
||||||
Cell(Rc<RefCell<Value>>), // Boxed value for captures
|
Cell(Rc<RefCell<Value>>), // Boxed value for captures
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@ impl PartialEq for Value {
|
|||||||
(Value::Closure(a), Value::Closure(b)) => Rc::ptr_eq(a, b),
|
(Value::Closure(a), Value::Closure(b)) => Rc::ptr_eq(a, b),
|
||||||
(Value::Quote(a), Value::Quote(b)) => Rc::ptr_eq(a, b),
|
(Value::Quote(a), Value::Quote(b)) => Rc::ptr_eq(a, b),
|
||||||
(Value::Series(a), Value::Series(b)) => Rc::ptr_eq(a, b),
|
(Value::Series(a), Value::Series(b)) => Rc::ptr_eq(a, b),
|
||||||
(Value::Object(a), Value::Object(b)) => Rc::ptr_eq(a, b),
|
(Value::Stream(a), Value::Stream(b)) => Rc::ptr_eq(a, b),
|
||||||
(Value::Cell(a), Value::Cell(b)) => Rc::ptr_eq(a, b),
|
(Value::Cell(a), Value::Cell(b)) => Rc::ptr_eq(a, b),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -747,7 +748,7 @@ impl Value {
|
|||||||
Value::Closure(_) => StaticType::Object("closure"),
|
Value::Closure(_) => StaticType::Object("closure"),
|
||||||
Value::Quote(_) => StaticType::Object("ast-node"),
|
Value::Quote(_) => StaticType::Object("ast-node"),
|
||||||
Value::Series(s) => StaticType::Series(Box::new(s.inner_static_type())),
|
Value::Series(s) => StaticType::Series(Box::new(s.inner_static_type())),
|
||||||
Value::Object(o) => StaticType::Object(o.type_name()),
|
Value::Stream(s) => StaticType::Stream(Box::new(s.element_type())),
|
||||||
Value::Cell(c) => c.borrow().static_type(),
|
Value::Cell(c) => c.borrow().static_type(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -793,7 +794,7 @@ impl fmt::Display for Value {
|
|||||||
Value::Closure(_) => write!(f, "<closure>"),
|
Value::Closure(_) => write!(f, "<closure>"),
|
||||||
Value::Quote(_) => write!(f, "<ast-node>"),
|
Value::Quote(_) => write!(f, "<ast-node>"),
|
||||||
Value::Series(s) => write!(f, "<series:{}>", s.series_type_name()),
|
Value::Series(s) => write!(f, "<series:{}>", s.series_type_name()),
|
||||||
Value::Object(o) => write!(f, "<{:?}>", o),
|
Value::Stream(s) => write!(f, "<stream:{}>", s.stream_type_name()),
|
||||||
Value::Cell(c) => write!(f, "{}", c.borrow()),
|
Value::Cell(c) => write!(f, "{}", c.borrow()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-24
@@ -209,11 +209,8 @@ impl VM {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Value::Object(obj) => {
|
Value::Stream(_) => {
|
||||||
return Err(format!(
|
return Err("Tail call target is not callable: stream".to_string());
|
||||||
"Tail call target is not callable: {}",
|
|
||||||
obj.type_name()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(format!("Tail call target is not callable: {}", other));
|
return Err(format!("Tail call target is not callable: {}", other));
|
||||||
@@ -411,15 +408,12 @@ impl VM {
|
|||||||
s.series_type_name()
|
s.series_type_name()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Value::Object(obj) => {
|
Value::Stream(s) => {
|
||||||
if let Some(sn) = obj.as_any().downcast_ref::<StreamNode>() {
|
if let Some(sn) = s.as_any().downcast_ref::<StreamNode>() {
|
||||||
let mapped = build_map_stream(sn.inner.clone(), *field);
|
let mapped = build_map_stream(sn.inner.clone(), *field);
|
||||||
return Ok(Value::Object(Rc::new(mapped)));
|
return Ok(Value::Stream(Rc::new(mapped)));
|
||||||
}
|
}
|
||||||
Err(format!(
|
Err("Attempt to access field on non-record stream".to_string())
|
||||||
"Attempt to access field on non-record object: {}",
|
|
||||||
obj.type_name()
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
_ => Err(format!(
|
_ => Err(format!(
|
||||||
"Attempt to access field on non-record: {}",
|
"Attempt to access field on non-record: {}",
|
||||||
@@ -483,7 +477,7 @@ impl VM {
|
|||||||
self.stack.truncate(base);
|
self.stack.truncate(base);
|
||||||
|
|
||||||
match func_val {
|
match func_val {
|
||||||
Value::Closure(_) | Value::Object(_) | Value::Series(_) => {
|
Value::Closure(_) | Value::Stream(_) | Value::Series(_) => {
|
||||||
self.tail_call = Some((func_val, arg_vals));
|
self.tail_call = Some((func_val, arg_vals));
|
||||||
return Ok(Value::Void);
|
return Ok(Value::Void);
|
||||||
}
|
}
|
||||||
@@ -535,15 +529,15 @@ impl VM {
|
|||||||
k.name(),
|
k.name(),
|
||||||
s.series_type_name()
|
s.series_type_name()
|
||||||
));
|
));
|
||||||
} else if let Value::Object(obj) = rec {
|
} else if let Value::Stream(s) = rec {
|
||||||
if let Some(sn) = obj.as_any().downcast_ref::<StreamNode>() {
|
if let Some(sn) = s.as_any().downcast_ref::<StreamNode>() {
|
||||||
let mapped = build_map_stream(sn.inner.clone(), k);
|
let mapped = build_map_stream(sn.inner.clone(), k);
|
||||||
return Ok(Value::Object(Rc::new(mapped)));
|
return Ok(Value::Stream(Rc::new(mapped)));
|
||||||
}
|
}
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Field accessor .{} expects a record, RecordSeries or Stream, got {}",
|
"Field accessor .{} expects a record, RecordSeries or Stream, got {}",
|
||||||
k.name(),
|
k.name(),
|
||||||
obj.type_name()
|
s.stream_type_name()
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -616,15 +610,15 @@ impl VM {
|
|||||||
s.series_type_name()
|
s.series_type_name()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
} else if let Value::Object(obj) = rec {
|
} else if let Value::Stream(s) = rec {
|
||||||
if let Some(sn) = obj.as_any().downcast_ref::<StreamNode>() {
|
if let Some(sn) = s.as_any().downcast_ref::<StreamNode>() {
|
||||||
let mapped = build_map_stream(sn.inner.clone(), *k);
|
let mapped = build_map_stream(sn.inner.clone(), *k);
|
||||||
Ok(Value::Object(Rc::new(mapped)))
|
Ok(Value::Stream(Rc::new(mapped)))
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"Field accessor .{} expects a record, RecordSeries or Stream, got {}",
|
"Field accessor .{} expects a record, RecordSeries or Stream, got {}",
|
||||||
k.name(),
|
k.name(),
|
||||||
obj.type_name()
|
s.stream_type_name()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -713,9 +707,9 @@ impl VM {
|
|||||||
self.stack.truncate(base);
|
self.stack.truncate(base);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
Value::Object(obj) => {
|
Value::Stream(_) => {
|
||||||
self.stack.truncate(base);
|
self.stack.truncate(base);
|
||||||
return Err(format!("Object is not callable: {}", obj.type_name()));
|
return Err("Stream is not callable".to_string());
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.stack.truncate(base);
|
self.stack.truncate(base);
|
||||||
@@ -734,7 +728,7 @@ impl VM {
|
|||||||
Value::Closure(_) => "Closure",
|
Value::Closure(_) => "Closure",
|
||||||
Value::Quote(_) => "Quote",
|
Value::Quote(_) => "Quote",
|
||||||
Value::Series(_) => "Series",
|
Value::Series(_) => "Series",
|
||||||
Value::Object(_) => "Object",
|
Value::Stream(_) => "Stream",
|
||||||
Value::Cell(_) => "Cell",
|
Value::Cell(_) => "Cell",
|
||||||
};
|
};
|
||||||
return Err(format!("Attempt to call non-function: {} (Variant: {})", current_func, variant_name));
|
return Err(format!("Attempt to call non-function: {} (Variant: {})", current_func, variant_name));
|
||||||
|
|||||||
+53
-3
@@ -43,6 +43,56 @@ fn test_pipe_infers_lambda_params_from_tuple_inputs() {
|
|||||||
assert!(res.is_ok(), "Script failed: {:?}", res.err());
|
assert!(res.is_ok(), "Script failed: {:?}", res.err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies that PipeHook::finalize replaces the `pipe` identifier with a
|
||||||
|
/// pre-configured factory Constant — mirroring the SeriesHook pattern.
|
||||||
|
/// FAILS before PipeHook is implemented (callee is still `Identifier: pipe`).
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_callee_is_factory_after_finalize() {
|
||||||
|
let env = Environment::new();
|
||||||
|
let dump = env.dump_ast("(do
|
||||||
|
(def ohlc (create-random-ohlc 42 5))
|
||||||
|
(pipe ohlc (fn [bar] (.close bar)))
|
||||||
|
)").expect("dump_ast failed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!dump.contains("Identifier: pipe"),
|
||||||
|
"PipeHook::finalize should replace `pipe` with a pre-configured factory Constant.\nDump:\n{dump}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Field accessor on unknown field in pipe lambda must be a compile-time error.
|
||||||
|
/// The lambda param is inferred as the concrete Record type (not Any),
|
||||||
|
/// so the compiler can validate field names.
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_field_typo_is_compile_error() {
|
||||||
|
let env = Environment::new();
|
||||||
|
let valid = "(do (def ohlc (create-random-ohlc 42 5)) (pipe ohlc (fn [bar] (.close bar))))";
|
||||||
|
assert!(env.run_script(valid).is_ok(), "Valid pipe should compile");
|
||||||
|
|
||||||
|
let typo = "(do (def ohlc (create-random-ohlc 42 5)) (pipe ohlc (fn [bar] (.nonexistent bar))))";
|
||||||
|
assert!(
|
||||||
|
env.run_script(typo).is_err(),
|
||||||
|
"Field typo in pipe lambda must be a compile error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chained pipe (output feeds another pipe) must preserve concrete Stream types.
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_chain_preserves_concrete_types() {
|
||||||
|
let env = Environment::new();
|
||||||
|
let source = "(do
|
||||||
|
(def ohlc (create-random-ohlc 42 5))
|
||||||
|
(def closes (pipe ohlc (fn [bar] (.close bar))))
|
||||||
|
(pipe closes (fn [c] (* c 2.0)))
|
||||||
|
)";
|
||||||
|
let dump = env.dump_ast(source).expect("dump_ast failed");
|
||||||
|
assert!(
|
||||||
|
dump.contains("Stream(Float)"),
|
||||||
|
"Both pipe stages should yield Stream(Float).\nDump:\n{dump}"
|
||||||
|
);
|
||||||
|
assert!(env.run_script(source).is_ok(), "Chained pipe failed at runtime");
|
||||||
|
}
|
||||||
|
|
||||||
/// Verifies that pipe with Optional return (filter pattern) still produces Stream(Float).
|
/// Verifies that pipe with Optional return (filter pattern) still produces Stream(Float).
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pipeline_optional_type() {
|
fn test_pipeline_optional_type() {
|
||||||
@@ -69,9 +119,9 @@ fn test_pipeline_optional_type() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let val = res.unwrap();
|
let val = res.unwrap();
|
||||||
if let Value::Object(obj) = val {
|
if let Value::Stream(s) = val {
|
||||||
assert_eq!(obj.type_name(), "StreamNode");
|
assert_eq!(s.stream_type_name(), "stream");
|
||||||
} else {
|
} else {
|
||||||
panic!("Expected an Object(StreamNode)");
|
panic!("Expected a Stream(StreamNode)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user