Skip to main content

volas_core/dataframe/
computed.rs

1//! The materialized-directive (computed) column cache lifecycle on a
2//! [`DataFrame`]: recording a cached directive result, tracking staleness
3//! after an append, incrementally refreshing its tail, and invalidating it on
4//! a manual write. The cache (`DataFrame::computed`) is private frame state;
5//! these methods own its invariants.
6
7
8use crate::column::Column;
9use crate::error::{Result, VolasError};
10
11use super::{ComputedMeta, DataFrame};
12
13impl DataFrame {
14    /// Record that column `name` is a materialized directive result (valid for
15    /// all current rows).
16    pub fn set_computed(&mut self, name: &str, lookback: usize) {
17        self.computed.insert(
18            name.to_string(),
19            ComputedMeta {
20                lookback,
21                valid_rows: self.height,
22                state: None,
23                origin: 0,
24            },
25        );
26    }
27
28    /// Attach (or replace) the carried recursive [`ComputedMeta::state`] for a
29    /// cached column, enabling an O(new-rows) append resume. No-op if `name` is
30    /// not a tracked computed column.
31    pub fn set_computed_state(&mut self, name: &str, state: Option<Vec<f64>>) {
32        if let Some(meta) = self.computed.get_mut(name) {
33            meta.state = state;
34        }
35    }
36
37    /// Overwrite element `idx` of a cached column's carried state **in place** (no
38    /// allocation) — the single-row resume fast path, where the new state reuses the
39    /// existing fixed-size buffer. No-op if `name` is not computed, has no state, or
40    /// `idx` is out of range.
41    pub fn update_computed_state_at(&mut self, name: &str, idx: usize, val: f64) {
42        if let Some(slot) = self
43            .computed
44            .get_mut(name)
45            .and_then(|m| m.state.as_mut())
46            .and_then(|s| s.get_mut(idx))
47        {
48            *slot = val;
49        }
50    }
51
52    /// Whether any materialized directive column is stale (its `valid_rows` lags
53    /// `height` after an `append`), i.e. a bulk read would see NaN until `fulfill`.
54    pub fn has_stale_computed(&self) -> bool {
55        self.computed.values().any(|m| m.valid_rows < self.height)
56    }
57
58    /// Borrow a cached column's carried resume state and its origin offset — the
59    /// `(&[f64], usize)` the resume kernels read — **without cloning** the state
60    /// `Vec`. Lets the live refresh / rollover-finalize loops continue the recursion
61    /// straight off the stored state instead of snapshotting a per-column copy.
62    /// `None` if `name` is untracked or carries no state.
63    pub fn computed_resume_state(&self, name: &str) -> Option<(&[f64], usize)> {
64        let meta = self.computed.get(name)?;
65        Some((meta.state.as_deref()?, meta.origin))
66    }
67
68    /// Snapshot of the materialized-directive columns (`name`, meta).
69    pub fn computed_columns(&self) -> Vec<(String, ComputedMeta)> {
70        self.computed
71            .iter()
72            .map(|(k, v)| (k.clone(), v.clone()))
73            .collect()
74    }
75
76    /// Stale materialized-directive columns as `(name, lookback, valid_rows)` — the
77    /// small `Copy` cursor fields only, **never the carried `state` Vec**. The live
78    /// fulfill loop drives off this owned name list (so it can take `&mut self` to
79    /// write the refreshed tail) and borrows each column's state (and origin) on
80    /// demand via [`computed_resume_state`](Self::computed_resume_state) just before
81    /// resuming it, so no per-column `Vec<f64>` is deep-copied per fulfill.
82    pub fn stale_computed_columns(&self, only: Option<&str>) -> Vec<(String, usize, usize)> {
83        self.computed
84            .iter()
85            .filter(|(name, meta)| {
86                meta.valid_rows < self.height && only.is_none_or(|target| target == name.as_str())
87            })
88            .map(|(name, meta)| (name.clone(), meta.lookback, meta.valid_rows))
89            .collect()
90    }
91
92    /// Names of all materialized-directive columns.
93    pub fn computed_names(&self) -> Vec<String> {
94        self.computed.keys().cloned().collect()
95    }
96
97    /// Overwrite a computed column's rows `[from, from + tail.len())` in place
98    /// (copy-on-write) with the recomputed `tail`, and mark it valid up to the
99    /// full height. Directive results are F64 or Bool, so both are handled.
100    /// O(tail.len()).
101    pub fn update_computed_tail(&mut self, name: &str, from: usize, tail: &Column) -> Result<()> {
102        let pos = self
103            .name_to_idx
104            .get(name)
105            .copied()
106            .ok_or_else(|| VolasError::ColumnNotFound(name.to_string()))?;
107        match (&mut self.columns[pos], tail) {
108            (Column::F64(arc), Column::F64(t)) => {
109                let buf = arc.make_mut();
110                for (i, &v) in t.iter().enumerate() {
111                    if from + i < buf.len() {
112                        buf[from + i] = v;
113                    }
114                }
115            }
116            (Column::Bool(arc, _), Column::Bool(t, _)) => {
117                let buf = arc.make_mut();
118                for (i, &v) in t.iter().enumerate() {
119                    if from + i < buf.len() {
120                        buf[from + i] = v;
121                    }
122                }
123            }
124            (col, t) => {
125                return Err(VolasError::DType(format!(
126                    "computed tail dtype {} does not match column \"{name}\" dtype {}",
127                    t.dtype(),
128                    col.dtype()
129                )))
130            }
131        }
132        if let Some(meta) = self.computed.get_mut(name) {
133            meta.valid_rows = self.height;
134        }
135        Ok(())
136    }
137
138    /// Overwrite one F64 computed value and mark the computed column current.
139    /// This avoids allocating a one-value tail column on the single-bar append path.
140    pub fn update_computed_f64_value(&mut self, name: &str, row: usize, value: f64) -> Result<()> {
141        let pos = self
142            .name_to_idx
143            .get(name)
144            .copied()
145            .ok_or_else(|| VolasError::ColumnNotFound(name.to_string()))?;
146        match &mut self.columns[pos] {
147            Column::F64(arc) => {
148                let buf = arc.make_mut();
149                if row < buf.len() {
150                    buf[row] = value;
151                }
152            }
153            col => {
154                return Err(VolasError::DType(format!(
155                    "computed scalar dtype F64 does not match column \"{name}\" dtype {}",
156                    col.dtype()
157                )))
158            }
159        }
160        if let Some(meta) = self.computed.get_mut(name) {
161            meta.valid_rows = self.height;
162        }
163        Ok(())
164    }
165
166    /// Drop the computed (cached-directive) status of the column at `col`, if any.
167    fn drop_computed_at(&mut self, col: usize) {
168        if let Some(name) = self.names.get(col) {
169            self.computed.remove(name);
170        }
171    }
172
173    /// Live tf-fold invalidation: only the **forming row** (the last, still-open
174    /// period bar at `forming_row`) changed, so mark each cached column stale from
175    /// `forming_row` on but **keep** its carried recursive state. The state stays
176    /// anchored at the last CLOSED bar (`forming_row - 1`); `refresh_computed`
177    /// resumes just the forming row from it (O(lookback)) without advancing the
178    /// anchor, and the period rollover advances the anchor over the now-closed bar.
179    /// Unlike [`invalidate_computed_on_write_at`] this neither drops the state nor
180    /// zeroes `valid_rows`, so the live fold never forces a full O(buffer) recompute.
181    pub(super) fn invalidate_computed_forming_row(&mut self, forming_row: usize) {
182        for meta in self.computed.values_mut() {
183            if meta.valid_rows > forming_row {
184                meta.valid_rows = forming_row;
185            }
186        }
187    }
188
189    /// A user write to column `col` invalidates the directive cache: `col` loses any
190    /// computed status, and every OTHER cached directive column is marked fully stale —
191    /// it may have been derived from `col`, so it is recomputed on next access (a bulk
192    /// read raises until `fulfill`, exactly like an append). Conservative (no per-column
193    /// dependency tracking), but writes are rare relative to reads.
194    pub(super) fn invalidate_computed_on_write_at(&mut self, col: usize) {
195        self.drop_computed_at(col);
196        for meta in self.computed.values_mut() {
197            meta.valid_rows = 0;
198            // The carried recursive state described row `valid_rows - 1`; resetting
199            // `valid_rows` to 0 breaks that correspondence, so drop it. A later refresh
200            // recomputes from scratch (correct) and repopulates the state.
201            meta.state = None;
202        }
203    }
204
205    /// Name-based variant for the `df[name] = value` whole-column replace path.
206    pub fn invalidate_computed_on_write(&mut self, written_name: &str) {
207        self.computed.remove(written_name);
208        for meta in self.computed.values_mut() {
209            meta.valid_rows = 0;
210            meta.state = None;
211        }
212    }
213}