feat: Specialize pipeline types for performance

Introduces type specialization for reactive pipeline nodes and their
data buffers. This eliminates the overhead of generic `Value` enums and
`SharedValueSeries` when dealing with known scalar or record types.

The architecture shifts buffer instantiation from the VM to the runtime
(RTL), leveraging type information from the AST. A new `out_type` field
is added to `BoundKind::Pipe` to store the static type of the pipeline's
output, determined by the type checker.

This enables the creation of specialized `RingBuffer<T>` and
`SharedRecordSeries` (for Struct-of-Arrays layout) when the output type
is known, significantly improving memory usage and processing speed for
time-series data, especially in financial analysis.
This commit is contained in:
Michael Schimmel
2026-03-02 22:03:24 +01:00
parent a4af142719
commit f7cb6655af
14 changed files with 295 additions and 58 deletions
@@ -0,0 +1,73 @@
# Typ-Spezialisierung der Reaktiven Pipeline
Dieses Dokument beschreibt die Architektur und Motivation hinter der typspezifischen Initialisierung von Pipeline-Knoten (`PipeStream`) und deren Datenpuffern (`SharedSeries`).
## 1. Motivation
In der Myc Script Engine ist die reaktive Pipeline (Pipes und Streams) das zentrale Konstrukt für die Verarbeitung von Zeitreihendaten (z.B. Finanzdaten, Indikatoren). Eine Pipe konsumiert Daten aus einem oder mehreren Streams, führt ein Lambda aus und speichert das Ergebnis in einem Ringpuffer, der wiederum als Stream für nachfolgende Pipes oder als Serie für Lookbacks (`series[0]`) dient.
### Das Problem der generischen Puffer
Ohne Typinformationen muss die VM zur Laufzeit einen generischen Puffer (`RingBuffer<Value>`) und eine `SharedValueSeries` instanziieren. Dies hat mehrere Nachteile:
* **Speicher-Overhead:** Jedes Element ist ein `Value`-Enum, das mehr Platz verbraucht als ein nativer Typ wie `f64`.
* **Performance-Overhead:** Bei jedem Zugriff (Lookback) oder Push muss das `Value`-Enum verarbeitet werden.
* **Kein Struct-of-Arrays (SoA):** Der wichtigste Anwendungsfall in der Finanzanalyse sind Indikatoren, die mehrere Werte produzieren (z.B. MACD mit `macd`, `signal` und `hist`). In einer generischen Architektur entstünde hier ein Puffer aus Record-Objekten (Array of Structs). Für die Performance und Vektorisierung ist jedoch ein "Struct of Arrays" (SoA) zwingend erforderlich, bei dem jedes Record-Feld seinen eigenen, flachen `RingBuffer<f64>` erhält.
### Die Rolle des TypeCheckers und der VM
Der `TypeChecker` analysiert bereits erfolgreich den Rückgabetyp der Lambdas innerhalb einer Pipe und kennt somit den exakten Typ der resultierenden Serie (z.B. `StaticType::Float` oder `StaticType::Record`).
Die Virtual Machine (VM) hingegen sollte so "dumm", universell und schnell wie möglich bleiben. Sie sollte keine komplexen Typbäume (`StaticType`) auswerten oder Puffer-Strategien verwalten müssen.
## 2. Design-Ziele
1. **Dumb VM:** Die VM soll keine Logik zur Auswahl oder Instanziierung von typisierten Ringpuffern enthalten.
2. **First-Class Record Support:** Wenn eine Pipe Records produziert (wie beim MACD), muss das System automatisch eine `SharedRecordSeries` aufbauen, die intern für jedes Record-Feld einen eigenen, typisierten `RingBuffer` (SoA) verwendet.
3. **Zero-Cost Abstractions für Skalare:** Skalare Werte (`f64`, `i64`, `bool`) sollen in flachen, nativen Arrays (`RingBuffer<T>`) landen.
4. **Klare Trennung von Zuständigkeiten:** Das RTL-Modul für Streams (`src/ast/rtl/streams.rs`) besitzt das Domänenwissen über Puffer und Observer, nicht der Compiler und nicht die VM.
## 3. Architektur und Umsetzung
Um diese Ziele zu erreichen, wird die Puffer-Initialisierung vollständig aus der VM in die Laufzeitumgebung (RTL) verschoben.
### Schritt 1: Transport der Typinformation im AST
Der AST-Knoten für Pipes (`BoundKind::Pipe`) wird um ein Feld für den resultierenden Typ erweitert.
```rust
BoundKind::Pipe {
inputs: Vec<BoundNode<T>>,
lambda: Box<BoundNode<T>>,
out_type: StaticType, // Wird vom TypeChecker injiziert
}
```
Damit steht die Typinformation direkt an der Stelle zur Verfügung, wo der Knoten evaluiert wird, ohne dass die VM Typ-Metadaten parsen muss.
### Schritt 2: Factory in der RTL (`streams.rs`)
Die VM sammelt zur Laufzeit nur noch die evaluierten Input-Streams und erzeugt die ausführbare VM-Closure für das Lambda. Anschließend delegiert sie die Konstruktion der Pipeline an eine zentrale Factory-Funktion in `streams.rs`:
```rust
pub fn build_pipeline_node(
inputs: Vec<Rc<dyn ObservableStream>>,
executor: Box<dyn FnMut(Vec<Value>) -> Value>,
out_type: &StaticType
) -> Result<Rc<PipelineNode>, String>
```
### Schritt 3: Typ-Spezialisierung und SoA-Aufbau
Innerhalb von `build_pipeline_node` wird per Pattern-Matching auf den `out_type` die optimale Puffer-Architektur aufgebaut:
1. **`StaticType::Record(layout)`:**
* Die Funktion durchläuft das Layout des Records.
* Für jedes Feld wird ein passender, typisierter Ringpuffer (z.B. `RingBuffer<f64>`) und ein entsprechender `SeriesPusher` erzeugt.
* Alle Puffer werden in einer `SharedRecordSeries` gebündelt. Diese verhält sich nach außen wie eine Liste von Records, speichert die Daten intern aber extrem effizient als Spalten (SoA).
2. **`StaticType::Float` / `Int` / `Bool`:**
* Es wird direkt ein `RingBuffer<T>` mit einer `SharedSeries<T>` und einem `SeriesPusher<T>` instanziiert.
3. **Fallback (`StaticType::Any`):**
* Nur wenn der Typ zur Compile-Zeit nicht ermittelbar ist oder es sich um unstrukturierte Daten handelt, fällt das System auf den generischen `RingBuffer<Value>` und die `SharedValueSeries` zurück.
### Schritt 4: Der Typsichere `SeriesPusher`
Da die Signale aus dem `PipeStream` generische `Value`-Objekte sind (da die VM-Closure `Value` zurückgibt), muss der Wert vor dem Einfügen in den flachen Puffer konvertiert (downcasted) werden.
Dies geschieht durch eine funktionale Injektion im `SeriesPusher`:
```rust
pub struct SeriesPusher<T: ScalarValue> {
pub buffer: Rc<RefCell<RingBuffer<T>>>,
pub extractor: fn(Value) -> Option<T>,
}
```
Der Extractor (z.B. `|v| if let Value::Float(f) = v { Some(f) } else { None }`) wird bei der Instanziierung in der Factory mitgegeben. Dadurch bleibt der Pusher völlig generisch, weiß aber exakt, wie er "seinen" nativen Typ aus dem VM-Rückgabewert extrahieren muss.
+2 -2
View File
@@ -1,5 +1,5 @@
;; Benchmark: 1.9us ;; Benchmark: 1.6us
;; Benchmark-Repeat: 1072 ;; Benchmark-Repeat: 1233
;; Output: [150 130 "Insufficient funds" 130] ;; Output: [150 130 "Insufficient funds" 130]
; --------------------------------------------------------- ; ---------------------------------------------------------
+23
View File
@@ -0,0 +1,23 @@
;; Benchmark: 1.5us
;; Benchmark-Repeat: 1367
;; Test Record SoA specialization in Pipelines
;; Dank der neuen Spezialisierung wird hierfür im Hintergrund
;; eine SharedRecordSeries mit SoA-Layout (Float-Puffer für mid und range) erstellt.
(do
(def ohlc_stream (create-random-ohlc 42 10))
;; Pipe die einen Record erzeugt
(def indicator
(pipe [ohlc_stream]
(fn [ohlc]
{
:mid (/ (+ (.high ohlc) (.low ohlc)) 2.0)
:range (- (.high ohlc) (.low ohlc))
}
)
)
)
indicator
)
+2 -1
View File
@@ -237,7 +237,7 @@ impl<'a> Analyzer<'a> {
Purity::Impure, Purity::Impure,
) )
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe { inputs, lambda, out_type } => {
let mut analyzed_inputs = Vec::with_capacity(inputs.len()); let mut analyzed_inputs = Vec::with_capacity(inputs.len());
for input in inputs { for input in inputs {
analyzed_inputs.push(self.visit(Rc::new(input.clone()))); analyzed_inputs.push(self.visit(Rc::new(input.clone())));
@@ -247,6 +247,7 @@ impl<'a> Analyzer<'a> {
BoundKind::Pipe { BoundKind::Pipe {
inputs: analyzed_inputs, inputs: analyzed_inputs,
lambda: a_lambda, lambda: a_lambda,
out_type: out_type.clone(),
}, },
Purity::Impure, Purity::Impure,
) )
+1
View File
@@ -287,6 +287,7 @@ impl Binder {
BoundKind::Pipe { BoundKind::Pipe {
inputs: bound_inputs, inputs: bound_inputs,
lambda: bound_lambda, lambda: bound_lambda,
out_type: crate::ast::types::StaticType::Any,
}, },
)) ))
} }
+1
View File
@@ -138,6 +138,7 @@ pub enum BoundKind<T = ()> {
Pipe { Pipe {
inputs: Vec<BoundNode<T>>, inputs: Vec<BoundNode<T>>,
lambda: Box<BoundNode<T>>, lambda: Box<BoundNode<T>>,
out_type: crate::ast::types::StaticType,
}, },
Block { Block {
exprs: Vec<BoundNode<T>>, exprs: Vec<BoundNode<T>>,
+2 -1
View File
@@ -89,7 +89,7 @@ impl CapturePass {
args: Box::new(Self::transform(*args, capture_map)), args: Box::new(Self::transform(*args, capture_map)),
}; };
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe { inputs, lambda, out_type } => {
let mut t_inputs = Vec::with_capacity(inputs.len()); let mut t_inputs = Vec::with_capacity(inputs.len());
for input in inputs { for input in inputs {
t_inputs.push(Self::transform(input, capture_map)); t_inputs.push(Self::transform(input, capture_map));
@@ -97,6 +97,7 @@ impl CapturePass {
node.kind = BoundKind::Pipe { node.kind = BoundKind::Pipe {
inputs: t_inputs, inputs: t_inputs,
lambda: Box::new(Self::transform(*lambda, capture_map)), lambda: Box::new(Self::transform(*lambda, capture_map)),
out_type: out_type.clone(),
}; };
} }
BoundKind::Block { exprs } => { BoundKind::Block { exprs } => {
+1 -1
View File
@@ -203,7 +203,7 @@ impl Dumper {
self.visit(args); self.visit(args);
self.indent -= 1; self.indent -= 1;
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe { inputs, lambda, .. } => {
self.log("Pipe", node); self.log("Pipe", node);
self.indent += 1; self.indent += 1;
for input in inputs { for input in inputs {
+2 -1
View File
@@ -386,7 +386,7 @@ impl Optimizer {
) )
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe { inputs, lambda, out_type } => {
let mut o_inputs = Vec::with_capacity(inputs.len()); let mut o_inputs = Vec::with_capacity(inputs.len());
for input in inputs { for input in inputs {
o_inputs.push(self.visit_node(input, sub, path)); o_inputs.push(self.visit_node(input, sub, path));
@@ -396,6 +396,7 @@ impl Optimizer {
BoundKind::Pipe { BoundKind::Pipe {
inputs: o_inputs, inputs: o_inputs,
lambda: o_lambda, lambda: o_lambda,
out_type: out_type.clone(),
}, },
node.ty.clone(), node.ty.clone(),
) )
+1 -1
View File
@@ -152,7 +152,7 @@ impl UsageInfo {
BoundKind::Again { args } => { BoundKind::Again { args } => {
self.collect(args); self.collect(args);
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe { inputs, lambda, .. } => {
for input in inputs { for input in inputs {
self.collect(input); self.collect(input);
} }
+2 -1
View File
@@ -48,7 +48,7 @@ impl TCO {
args: Box::new(Self::transform(Rc::new((**args).clone()), false)), args: Box::new(Self::transform(Rc::new((**args).clone()), false)),
} }
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe { inputs, lambda, out_type } => {
let mut t_inputs = Vec::with_capacity(inputs.len()); let mut t_inputs = Vec::with_capacity(inputs.len());
for input in inputs { for input in inputs {
t_inputs.push(Self::transform(Rc::new(input.clone()), false)); t_inputs.push(Self::transform(Rc::new(input.clone()), false));
@@ -56,6 +56,7 @@ impl TCO {
BoundKind::Pipe { BoundKind::Pipe {
inputs: t_inputs, inputs: t_inputs,
lambda: Box::new(Self::transform(Rc::new((**lambda).clone()), false)), lambda: Box::new(Self::transform(Rc::new((**lambda).clone()), false)),
out_type: out_type.clone(),
} }
} }
+16 -3
View File
@@ -387,12 +387,24 @@ impl TypeChecker {
) )
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe { inputs, lambda, .. } => {
let mut typed_inputs = Vec::with_capacity(inputs.len()); let mut typed_inputs = Vec::with_capacity(inputs.len());
let mut arg_types = Vec::with_capacity(inputs.len());
for input in inputs { for input in inputs {
typed_inputs.push(self.check_node(input, ctx)?); let typed_input = self.check_node(input, ctx)?;
// Unwrap Series(T) to T for the lambda argument
let arg_ty = if let StaticType::Series(inner) = &typed_input.ty {
*inner.clone()
} else {
StaticType::Any
};
arg_types.push(arg_ty);
typed_inputs.push(typed_input);
} }
let typed_lambda = self.check_node(*lambda, ctx)?;
// Specialized check for the lambda using the input types!
let typed_lambda = self.check(*lambda, &arg_types)?;
let ret_ty = if let StaticType::Function(sig) = &typed_lambda.ty { let ret_ty = if let StaticType::Function(sig) = &typed_lambda.ty {
// If the lambda returns an Optional(T), the pipeline filters Void and stores T! // If the lambda returns an Optional(T), the pipeline filters Void and stores T!
if let StaticType::Optional(inner) = &sig.ret { if let StaticType::Optional(inner) = &sig.ret {
@@ -407,6 +419,7 @@ impl TypeChecker {
BoundKind::Pipe { BoundKind::Pipe {
inputs: typed_inputs, inputs: typed_inputs,
lambda: Box::new(typed_lambda), lambda: Box::new(typed_lambda),
out_type: ret_ty.clone(),
}, },
StaticType::Series(Box::new(ret_ty)), StaticType::Series(Box::new(ret_ty)),
) )
+145 -3
View File
@@ -240,11 +240,14 @@ impl Observer for PipeStream {
pub struct SeriesPusher<T: crate::ast::rtl::series::ScalarValue> { pub struct SeriesPusher<T: crate::ast::rtl::series::ScalarValue> {
pub buffer: Rc<RefCell<crate::ast::rtl::series::RingBuffer<T>>>, pub buffer: Rc<RefCell<crate::ast::rtl::series::RingBuffer<T>>>,
pub lookback: Option<usize>, pub lookback: Option<usize>,
pub extractor: fn(Value) -> Option<T>,
} }
impl<T: crate::ast::rtl::series::ScalarValue> Observer for SeriesPusher<T> { impl<T: crate::ast::rtl::series::ScalarValue> Observer for SeriesPusher<T> {
fn notify(&mut self, _source_index: usize, _cycle_id: u64, _value: Value) { fn notify(&mut self, _source_index: usize, _cycle_id: u64, value: Value) {
// ... (Downcast and push logic) if let Some(v) = (self.extractor)(value) {
self.buffer.borrow_mut().push(v, self.lookback);
}
} }
} }
@@ -260,6 +263,137 @@ impl Observer for ValuePusher {
} }
} }
/// A specialized observer that splits a Record into its fields and pushes them into SoA buffers.
pub struct RecordPusher {
pub field_buffers: Vec<Rc<RefCell<dyn crate::ast::rtl::series::SeriesMember>>>,
pub lookback: Option<usize>,
}
impl Observer for RecordPusher {
fn notify(&mut self, _source_index: usize, _cycle_id: u64, value: Value) {
if let Value::Record(_, values) = value {
for (i, v) in values.iter().enumerate() {
if let Some(buf) = self.field_buffers.get(i) {
buf.borrow_mut().push_value(v.clone(), self.lookback);
}
}
}
}
}
/// Factory function to build a specialized pipeline node based on the output type.
/// This keeps the VM "dumb" and moves the buffer selection logic to the RTL.
pub fn build_pipeline_node(
inputs: Vec<Rc<dyn ObservableStream>>,
executor: Box<dyn FnMut(Vec<Value>) -> Value>,
out_type: &StaticType,
) -> Rc<PipelineNode> {
use crate::ast::rtl::series::*;
let pipe = Rc::new(RefCell::new(PipeStream::new(
"pipe".to_string(),
inputs.len(),
Some(executor),
)));
// Connect inputs to the pipe
for (i, input) in inputs.into_iter().enumerate() {
let adapter = Rc::new(RefCell::new(SourceAdapter {
target: pipe.clone(),
target_index: i,
}));
input.add_observer(adapter);
}
let series: Rc<dyn crate::ast::types::Series> = match out_type {
StaticType::Float => {
let buffer = Rc::new(RefCell::new(RingBuffer::<f64>::new()));
let pusher = Rc::new(RefCell::new(SeriesPusher {
buffer: buffer.clone(),
lookback: Some(100), // TODO: Configuration
extractor: |v| if let Value::Float(f) = v { Some(f) } else { None },
}));
pipe.borrow().add_observer(pusher);
Rc::new(SharedSeries {
buffer,
type_name: "SharedFloatSeries",
converter: |v| Value::Float(v),
})
}
StaticType::Int | StaticType::DateTime => {
let buffer = Rc::new(RefCell::new(RingBuffer::<i64>::new()));
let pusher = Rc::new(RefCell::new(SeriesPusher {
buffer: buffer.clone(),
lookback: Some(100),
extractor: |v| match v {
Value::Int(i) => Some(i),
Value::DateTime(i) => Some(i),
_ => None,
},
}));
pipe.borrow().add_observer(pusher);
Rc::new(SharedSeries {
buffer,
type_name: "SharedIntSeries",
converter: |v| Value::Int(v),
})
}
StaticType::Bool => {
let buffer = Rc::new(RefCell::new(RingBuffer::<bool>::new()));
let pusher = Rc::new(RefCell::new(SeriesPusher {
buffer: buffer.clone(),
lookback: Some(100),
extractor: |v| if let Value::Bool(b) = v { Some(b) } else { None },
}));
pipe.borrow().add_observer(pusher);
Rc::new(SharedSeries {
buffer,
type_name: "SharedBoolSeries",
converter: |v| Value::Bool(v),
})
}
StaticType::Record(layout) => {
let mut field_buffers = Vec::with_capacity(layout.fields.len());
for (_, ty) in &layout.fields {
let member: Rc<RefCell<dyn SeriesMember>> = match ty {
StaticType::Float => Rc::new(RefCell::new(ScalarSeries::<f64>::new("FloatSeries"))),
StaticType::Int | StaticType::DateTime => {
Rc::new(RefCell::new(ScalarSeries::<i64>::new("IntSeries")))
}
StaticType::Bool => Rc::new(RefCell::new(ScalarSeries::<bool>::new("BoolSeries"))),
_ => Rc::new(RefCell::new(ValueSeries::new())),
};
field_buffers.push(member);
}
let pusher = Rc::new(RefCell::new(RecordPusher {
field_buffers: field_buffers.clone(),
lookback: Some(100),
}));
pipe.borrow().add_observer(pusher);
Rc::new(SharedRecordSeries {
layout: layout.clone(),
field_buffers,
})
}
_ => {
let buffer = Rc::new(RefCell::new(RingBuffer::<Value>::new()));
let pusher = Rc::new(RefCell::new(ValuePusher {
buffer: buffer.clone(),
lookback: Some(100),
}));
pipe.borrow().add_observer(pusher);
Rc::new(SharedValueSeries { buffer })
}
};
Rc::new(PipelineNode {
stream: pipe,
series,
})
}
// ============================================================================ // ============================================================================
// Script Integration (RTL Registration) // Script Integration (RTL Registration)
// ============================================================================ // ============================================================================
@@ -271,11 +405,19 @@ pub fn register(env: &Environment) {
// (create-random-ohlc seed limit) -> StreamNode // (create-random-ohlc seed limit) -> StreamNode
let generators = env.pipeline_generators.clone(); let generators = env.pipeline_generators.clone();
// Define the OHLC layout for typing
let ohlc_layout = RecordLayout::get_or_create(vec![
(Keyword::intern("open"), StaticType::Float),
(Keyword::intern("high"), StaticType::Float),
(Keyword::intern("low"), StaticType::Float),
(Keyword::intern("close"), StaticType::Float),
]);
env.register_native_fn( env.register_native_fn(
"create-random-ohlc", "create-random-ohlc",
StaticType::Function(Box::new(Signature { StaticType::Function(Box::new(Signature {
params: StaticType::Tuple(vec![StaticType::Int, StaticType::Int]), params: StaticType::Tuple(vec![StaticType::Int, StaticType::Int]),
ret: StaticType::Any, // Returns a StreamNode ret: StaticType::Series(Box::new(StaticType::Record(ohlc_layout.clone()))),
})), })),
Purity::Impure, // Modifies global generator registry Purity::Impure, // Modifies global generator registry
move |args: std::vec::Vec<Value>| { move |args: std::vec::Vec<Value>| {
+24 -44
View File
@@ -368,10 +368,13 @@ impl VM {
Ok(Value::Void) Ok(Value::Void)
} }
} }
BoundKind::Pipe { inputs, lambda } => { BoundKind::Pipe {
use crate::ast::rtl::streams::{PipelineNode, PipeStream, StreamNode, ValuePusher, ObservableStream}; inputs,
use crate::ast::rtl::series::{RingBuffer, SharedValueSeries}; lambda,
out_type,
} => {
use crate::ast::rtl::streams::{PipelineNode, StreamNode};
let mut obs_streams = Vec::new(); let mut obs_streams = Vec::new();
for input in inputs { for input in inputs {
let val = self.eval_internal(obs, input)?; let val = self.eval_internal(obs, input)?;
@@ -381,7 +384,10 @@ impl VM {
} else if let Some(p) = obj.as_any().downcast_ref::<PipelineNode>() { } else if let Some(p) = obj.as_any().downcast_ref::<PipelineNode>() {
obs_streams.push(p.stream.clone()); obs_streams.push(p.stream.clone());
} else { } else {
return Err(format!("Pipe input must be a stream, found {}", obj.type_name())); return Err(format!(
"Pipe input must be a stream, found {}",
obj.type_name()
));
} }
} else { } else {
return Err("Pipe input must be an object (stream)".to_string()); return Err("Pipe input must be an object (stream)".to_string());
@@ -398,46 +404,20 @@ impl VM {
// Create the persistent execution closure for the PipeStream // Create the persistent execution closure for the PipeStream
let mut pipe_vm = crate::ast::vm::VM::new(self.globals.clone()); let mut pipe_vm = crate::ast::vm::VM::new(self.globals.clone());
let my_closure = lambda_obj.clone(); let my_closure = lambda_obj.clone();
let executor: Box<dyn FnMut(Vec<Value>) -> Value> = Box::new(move |args: Vec<Value>| -> Value { let executor: Box<dyn FnMut(Vec<Value>) -> Value> =
match pipe_vm.run_with_args(my_closure.clone(), args) { Box::new(move |args: Vec<Value>| -> Value {
Ok(res) => res, match pipe_vm.run_with_args(my_closure.clone(), args) {
Err(e) => panic!("Pipeline lambda execution failed: {}", e), Ok(res) => res,
} Err(e) => panic!("Pipeline lambda execution failed: {}", e),
}); }
});
let pipe = Rc::new(RefCell::new(PipeStream::new( // Delegate to the RTL Factory for specialized buffer instantiation
"pipe".to_string(), let node = crate::ast::rtl::streams::build_pipeline_node(
inputs.len(), obs_streams,
Some(executor) executor,
))); out_type,
);
for (i, stream) in obs_streams.into_iter().enumerate() {
let adapter = Rc::new(RefCell::new(crate::ast::rtl::streams::SourceAdapter {
target: pipe.clone() as Rc<RefCell<dyn crate::ast::rtl::streams::Observer>>,
target_index: i,
}));
stream.add_observer(adapter);
}
// Create the data buffer and pusher for the pipe's output
let buffer = Rc::new(RefCell::new(RingBuffer::<Value>::new()));
let pusher = Rc::new(RefCell::new(ValuePusher {
buffer: buffer.clone(),
lookback: Some(100), // Default lookback for now
}));
// The pipe pushes to the buffer
pipe.borrow().add_observer(pusher);
// Create the Series view for script access
let series = Rc::new(SharedValueSeries {
buffer,
});
let node = Rc::new(PipelineNode {
stream: pipe,
series,
});
Ok(Value::Object(node)) Ok(Value::Object(node))
} }