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}