Update examples to use StreamNode and Series with lookback

The `PipelineNode` has been refactored into `StreamNode`. This commit
updates the example files to reflect this change and also updates the
`series` constructor to accept a lookback parameter.

The `PipelineNode` was an artifact of a previous implementation and is
no longer needed. The `StreamNode` is the appropriate type for
representing the output of a pipe operation.

The `series` function now requires a `lookback` argument to specify the
maximum number of items to store. This makes the behavior of series more
explicit and prevents accidental unbounded memory growth.
This commit is contained in:
Michael Schimmel
2026-03-07 23:48:09 +01:00
parent d08fab4f73
commit 595bcf09e5
15 changed files with 108 additions and 233 deletions
+42 -49
View File
@@ -18,32 +18,28 @@ use crate::ast::types::{Purity, Signature};
pub struct RingBuffer<T> {
buffer: VecDeque<T>,
total_count: u64,
}
impl<T> Default for RingBuffer<T> {
fn default() -> Self {
Self::new()
}
lookback_limit: usize,
}
impl<T> RingBuffer<T> {
pub fn new() -> Self {
pub fn new(lookback_limit: usize) -> Self {
// Pre-allocate up to 500 elements to minimize heap fragmentation
let initial_capacity = std::cmp::min(lookback_limit, 500);
Self {
buffer: VecDeque::new(),
buffer: VecDeque::with_capacity(initial_capacity),
total_count: 0,
lookback_limit,
}
}
/// Adds an element. Removes the oldest if the lookback limit is reached.
#[inline]
pub fn push(&mut self, item: T, lookback: Option<usize>) {
pub fn push(&mut self, item: T) {
self.buffer.push_back(item);
self.total_count += 1;
if let Some(limit) = lookback {
while self.buffer.len() > limit {
self.buffer.pop_front();
}
while self.buffer.len() > self.lookback_limit {
self.buffer.pop_front();
}
}
@@ -107,9 +103,9 @@ pub struct ScalarSeries<T: ScalarValue> {
}
impl<T: ScalarValue> ScalarSeries<T> {
pub fn new(type_name: &'static str) -> Self {
pub fn new(type_name: &'static str, lookback_limit: usize) -> Self {
Self {
data: std::cell::RefCell::new(RingBuffer::new()),
data: std::cell::RefCell::new(RingBuffer::new(lookback_limit)),
type_name,
}
}
@@ -161,16 +157,10 @@ pub struct ValueSeries {
pub data: std::cell::RefCell<RingBuffer<Value>>,
}
impl Default for ValueSeries {
fn default() -> Self {
Self::new()
}
}
impl ValueSeries {
pub fn new() -> Self {
pub fn new(lookback_limit: usize) -> Self {
Self {
data: std::cell::RefCell::new(RingBuffer::new()),
data: std::cell::RefCell::new(RingBuffer::new(lookback_limit)),
}
}
}
@@ -213,36 +203,36 @@ impl Series for ValueSeries {
/// of the exact type (Type Erasure for member arrays).
pub trait SeriesMember: Object + Series {
/// Pushes a dynamic Value into the series, performing the necessary downcast.
fn push_value(&self, value: Value, limit: Option<usize>);
fn push_value(&self, value: Value);
}
impl SeriesMember for ScalarSeries<f64> {
fn push_value(&self, value: Value, limit: Option<usize>) {
fn push_value(&self, value: Value) {
if let Some(v) = value.as_float() {
self.data.borrow_mut().push(v, limit);
self.data.borrow_mut().push(v);
}
}
}
impl SeriesMember for ScalarSeries<i64> {
fn push_value(&self, value: Value, limit: Option<usize>) {
fn push_value(&self, value: Value) {
if let Some(v) = value.as_int() {
self.data.borrow_mut().push(v, limit);
self.data.borrow_mut().push(v);
}
}
}
impl SeriesMember for ScalarSeries<bool> {
fn push_value(&self, value: Value, limit: Option<usize>) {
fn push_value(&self, value: Value) {
if let Value::Bool(v) = value {
self.data.borrow_mut().push(v, limit);
self.data.borrow_mut().push(v);
}
}
}
impl SeriesMember for ValueSeries {
fn push_value(&self, value: Value, limit: Option<usize>) {
self.data.borrow_mut().push(value, limit);
fn push_value(&self, value: Value) {
self.data.borrow_mut().push(value);
}
}
@@ -260,7 +250,7 @@ pub struct RecordSeries {
impl RecordSeries {
/// Creates a new RecordSeries matching the given layout.
pub fn new(layout: Arc<RecordLayout>) -> Self {
pub fn new(layout: Arc<RecordLayout>, lookback_limit: usize) -> Self {
let mut fields: Vec<Rc<std::cell::RefCell<dyn SeriesMember>>> =
Vec::with_capacity(layout.fields.len());
@@ -269,15 +259,17 @@ impl RecordSeries {
let member: Rc<std::cell::RefCell<dyn SeriesMember>> = match static_type {
StaticType::Float => Rc::new(std::cell::RefCell::new(ScalarSeries::<f64>::new(
"FloatSeries",
lookback_limit,
))),
StaticType::Int | StaticType::DateTime => Rc::new(std::cell::RefCell::new(
ScalarSeries::<i64>::new("IntSeries"),
ScalarSeries::<i64>::new("IntSeries", lookback_limit),
)),
StaticType::Bool => Rc::new(std::cell::RefCell::new(ScalarSeries::<bool>::new(
"BoolSeries",
lookback_limit,
))),
// Fallback for everything else (Text, Lists, nested Records without SoA, etc.)
_ => Rc::new(std::cell::RefCell::new(ValueSeries::new())),
_ => Rc::new(std::cell::RefCell::new(ValueSeries::new(lookback_limit))),
};
fields.push(member);
}
@@ -547,23 +539,24 @@ impl Series for SharedRecordSeries {
// ============================================================================
pub fn register(env: &Environment) {
// (series template_or_type_keyword) -> Series
// (series lookback_limit template_or_type_keyword) -> Series
env.register_native_fn(
"series",
StaticType::Function(Box::new(Signature {
params: StaticType::Tuple(vec![StaticType::Any]),
params: StaticType::Tuple(vec![StaticType::Int, StaticType::Any]),
ret: StaticType::Any,
})),
Purity::Impure,
|args: &[Value]| {
let arg = &args[0];
let lookback_limit = args[0].as_int().unwrap_or(0) as usize;
let arg = &args[1];
match arg {
Value::Keyword(k) => {
match k.name().to_lowercase().as_str() {
"float" => Value::Object(Rc::new(ScalarSeries::<f64>::new("FloatSeries"))),
"int" | "datetime" => Value::Object(Rc::new(ScalarSeries::<i64>::new("IntSeries"))),
"bool" => Value::Object(Rc::new(ScalarSeries::<bool>::new("BoolSeries"))),
"text" => Value::Object(Rc::new(ValueSeries::new())),
"float" => Value::Object(Rc::new(ScalarSeries::<f64>::new("FloatSeries", lookback_limit))),
"int" | "datetime" => Value::Object(Rc::new(ScalarSeries::<i64>::new("IntSeries", lookback_limit))),
"bool" => Value::Object(Rc::new(ScalarSeries::<bool>::new("BoolSeries", lookback_limit))),
"text" => Value::Object(Rc::new(ValueSeries::new(lookback_limit))),
_ => panic!("Unknown or unsupported series type keyword: :{}", k.name()),
}
}
@@ -586,9 +579,9 @@ pub fn register(env: &Environment) {
fields.push((*name, ty));
}
let final_layout = RecordLayout::get_or_create(fields);
Value::Object(Rc::new(RecordSeries::new(final_layout)))
Value::Object(Rc::new(RecordSeries::new(final_layout, lookback_limit)))
}
_ => panic!("series expects a type keyword (:float, :int...) or a schema record (e.g., {{:price :float}})"),
_ => panic!("series expects a lookback_limit and a type keyword (:float, :int...) or a schema record (e.g., {{:price :float}})"),
}
},
);
@@ -612,20 +605,20 @@ pub fn register(env: &Environment) {
if let Value::Record(_, values) = val {
for (i, v) in values.iter().enumerate() {
if let Some(f) = rs.fields.get(i) {
f.borrow().push_value(v.clone(), None);
f.borrow().push_value(v.clone());
}
}
} else {
panic!("push to RecordSeries expects a record");
}
} else if let Some(s) = any.downcast_ref::<ScalarSeries<f64>>() {
s.push_value(val.clone(), None);
s.push_value(val.clone());
} else if let Some(s) = any.downcast_ref::<ScalarSeries<i64>>() {
s.push_value(val.clone(), None);
s.push_value(val.clone());
} else if let Some(s) = any.downcast_ref::<ScalarSeries<bool>>() {
s.push_value(val.clone(), None);
s.push_value(val.clone());
} else if let Some(s) = any.downcast_ref::<ValueSeries>() {
s.push_value(val.clone(), None);
s.push_value(val.clone());
} else {
panic!("Object is not a mutable series");
}