Skip to main content

ailake_query/
memory_decay.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Periodic recency-decay job for `EpisodicMemorySchema` tables.
3//!
4//! Reads the `last_accessed_at` column from each data file (Timestamp(ns, UTC) or legacy Utf8),
5//! recomputes
6//! `recency_weight = exp(-lambda * days_since_access)`, rewrites the column,
7//! and commits a new Iceberg snapshot replacing the old files.
8//!
9//! Integrates with the existing `CompactionExecutor` infrastructure: it reads
10//! and rewrites individual data files (not a merge), preserving HNSW indexes.
11
12use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use tracing::{info, warn};
16
17use ailake_catalog::{
18    make_multi_column_data_file_entry, new_snapshot_id, CatalogProvider, ExtraVectorIndex,
19    NewSnapshot, SnapshotOperation, TableIdent, VectorIndexInfo,
20};
21use ailake_core::{AilakeError, AilakeResult, VectorStoragePolicy};
22use ailake_file::{AilakeFileReader, AilakeFileWriter, VectorColumnBatch};
23use ailake_store::Store;
24use ailake_vec::compute_centroid_and_radius;
25use arrow_array::{
26    Array, Float32Array, RecordBatch, TimestampMicrosecondArray, TimestampNanosecondArray,
27};
28use arrow_schema::{DataType, Field};
29
30const LAST_ACCESSED_COL: &str = "last_accessed_at";
31const RECENCY_WEIGHT_COL: &str = "recency_weight";
32
33/// Periodic job that updates `recency_weight` for all records in a table.
34///
35/// The weight decays exponentially with age:
36/// `recency_weight = exp(-lambda * days_since_last_access)`
37///
38/// Where `days_since_last_access` is computed from the `last_accessed_at`
39/// column (ISO 8601 string or Unix timestamp string in the record).
40///
41/// # Usage
42///
43/// ```ignore
44/// let job = MemoryDecayJob::new(catalog, store, policy, lambda: 0.1);
45/// let updated = job.run(&table).await?;
46/// println!("{updated} files updated");
47/// ```
48pub struct MemoryDecayJob {
49    catalog: Arc<dyn CatalogProvider>,
50    store: Arc<dyn Store>,
51    policy: VectorStoragePolicy,
52    /// Exponential decay rate. Higher lambda → faster decay.
53    /// Typical values: 0.05 (slow) to 0.5 (aggressive).
54    pub decay_lambda: f32,
55}
56
57impl MemoryDecayJob {
58    pub fn new(
59        catalog: Arc<dyn CatalogProvider>,
60        store: Arc<dyn Store>,
61        policy: VectorStoragePolicy,
62        decay_lambda: f32,
63    ) -> Self {
64        Self {
65            catalog,
66            store,
67            policy,
68            decay_lambda,
69        }
70    }
71
72    /// Run decay update across all data files in the table's current snapshot.
73    ///
74    /// Returns the number of files that were rewritten (files missing the
75    /// `last_accessed_at` column are skipped).
76    pub async fn run(&self, table: &TableIdent) -> AilakeResult<usize> {
77        let files = self.catalog.list_files(table, None).await?;
78        if files.is_empty() {
79            return Ok(0);
80        }
81
82        let today_day = current_day_since_epoch();
83        let mut new_entries = Vec::with_capacity(files.len());
84        let mut retired_paths: Vec<String> = Vec::new();
85        let mut updated = 0usize;
86
87        for file_entry in &files {
88            let file_bytes = self.store.get(&file_entry.path).await?;
89            // Clone before moving into the primary reader so extra columns can reuse the bytes.
90            let orig_bytes = file_bytes.clone();
91            let reader =
92                AilakeFileReader::new(file_bytes, &self.policy.column_name, self.policy.dim);
93
94            if !reader.is_ailake_file() {
95                // Not an AI-Lake file — carry forward unchanged.
96                new_entries.push(file_entry.clone());
97                continue;
98            }
99
100            let (batch, embeddings) = match reader.read_parquet() {
101                Ok(pair) => pair,
102                Err(e) => {
103                    warn!(
104                        "ailake: MemoryDecayJob skipping {} — read error: {}",
105                        file_entry.path, e
106                    );
107                    new_entries.push(file_entry.clone());
108                    continue;
109                }
110            };
111
112            if batch.column_by_name(LAST_ACCESSED_COL).is_none() {
113                // Table doesn't have last_accessed_at — nothing to decay.
114                new_entries.push(file_entry.clone());
115                continue;
116            }
117
118            let updated_batch = apply_decay(&batch, today_day, self.decay_lambda)?;
119
120            // Read extra column embeddings from original bytes before rewriting.
121            let extra_embeddings: Vec<(String, u32, Vec<Vec<f32>>)> = file_entry
122                .extra_vector_indexes
123                .iter()
124                .filter_map(|xi| {
125                    let r = AilakeFileReader::new(orig_bytes.clone(), &xi.column, xi.dim);
126                    r.read_parquet()
127                        .ok()
128                        .map(|(_, embs)| (xi.column.clone(), xi.dim, embs))
129                })
130                .collect();
131
132            // Rewrite file with updated recency_weight column, preserving all HNSW sections.
133            let file_writer = AilakeFileWriter::new(self.policy.clone());
134            let new_bytes = if extra_embeddings.is_empty() {
135                file_writer.write(&updated_batch, &embeddings)?
136            } else {
137                // Build minimal policies for secondary columns (metric/precision from primary).
138                let extra_policies: Vec<VectorStoragePolicy> = extra_embeddings
139                    .iter()
140                    .map(|(col, dim, _)| VectorStoragePolicy {
141                        column_name: col.clone(),
142                        dim: *dim,
143                        metric: self.policy.metric,
144                        precision: self.policy.precision,
145                        pq: None,
146                        keep_raw_for_reranking: false,
147                        pre_normalize: false,
148                        hnsw_m: None,
149                        hnsw_ef_construction: None,
150                        ivf_residual: false,
151                        embedding_model: None,
152                        modality: None,
153                        partition_by: None,
154                        partition_value: None,
155                        partition_column_type: None,
156                        partition_fields: vec![],
157                    })
158                    .collect();
159                let primary_col = VectorColumnBatch {
160                    policy: &self.policy,
161                    embeddings: &embeddings,
162                };
163                let mut col_batches: Vec<VectorColumnBatch<'_>> = vec![primary_col];
164                for (policy, (_, _, embs)) in extra_policies.iter().zip(extra_embeddings.iter()) {
165                    col_batches.push(VectorColumnBatch {
166                        policy,
167                        embeddings: embs,
168                    });
169                }
170                file_writer.write_multi(&updated_batch, &col_batches)?
171            };
172            let new_size = new_bytes.len() as u64;
173            // Clone before moving into the primary reader used for header parsing.
174            let new_bytes_ref = new_bytes.clone();
175            // Fresh path, never in place: catalogs may record per-file stats and
176            // footer size at registration time and trust them afterwards
177            // (DuckLake does — `supports_in_place_rewrite() == false`; a
178            // same-path rewrite there silently breaks filtered native reads or
179            // the whole file read, verified live). A decayed-<ts>-<idx> path +
180            // retiring the old entry works uniformly on every backend.
181            let new_path = format!(
182                "data/decayed-{}-{:05}.parquet",
183                std::time::SystemTime::now()
184                    .duration_since(std::time::UNIX_EPOCH)
185                    .unwrap_or_else(|e| e.duration())
186                    .as_millis(),
187                updated
188            );
189            self.store.put(&new_path, new_bytes.clone()).await?;
190
191            let centroid = compute_centroid_and_radius(&embeddings, self.policy.metric);
192            let new_reader =
193                AilakeFileReader::new(new_bytes, &self.policy.column_name, self.policy.dim);
194            let header = new_reader.read_header()?;
195            let ailk_start = new_reader.ailk_offset()?;
196
197            // Rebuild ExtraVectorIndex entries from the new file's headers.
198            let new_extra: Vec<ExtraVectorIndex> = extra_embeddings
199                .iter()
200                .filter_map(|(col, dim, _)| {
201                    let xr = AilakeFileReader::new(new_bytes_ref.clone(), col, *dim);
202                    let xailk = xr.ailk_offset_for_column(col).ok()?;
203                    let xhdr = xr.read_header_for_column(col).ok()?;
204                    Some(ExtraVectorIndex {
205                        column: col.clone(),
206                        dim: *dim,
207                        hnsw_offset: xailk + xhdr.hnsw_offset,
208                        hnsw_len: xhdr.hnsw_len,
209                        centroid_b64: None,
210                        radius: None,
211                    })
212                })
213                .collect();
214
215            let mut new_entry = make_multi_column_data_file_entry(
216                &new_path,
217                updated_batch.num_rows() as u64,
218                new_size,
219                &centroid,
220                VectorIndexInfo {
221                    column: &self.policy.column_name,
222                    dim: self.policy.dim,
223                    hnsw_offset: ailk_start + header.hnsw_offset,
224                    hnsw_len: header.hnsw_len,
225                },
226                &new_extra,
227            );
228            // Decay preserves row count and order (`apply_decay` only replaces/adds
229            // a column, never filters rows), so any existing DV bitmap is still
230            // positionally valid against the new file and must be carried forward,
231            // or the rows it masks reappear on the very next search. The DV points
232            // at its own Puffin `.dvd` file, not the data file — the path change
233            // here doesn't touch it.
234            new_entry.deletion_vector = file_entry.deletion_vector.clone();
235            new_entries.push(new_entry);
236            retired_paths.push(file_entry.path.clone());
237            updated += 1;
238        }
239
240        if updated == 0 {
241            info!(
242                "ailake: MemoryDecayJob — no files with last_accessed_at column; skipping commit"
243            );
244            return Ok(0);
245        }
246
247        let parent_snapshot_id = self
248            .catalog
249            .load_table(table)
250            .await
251            .ok()
252            .and_then(|m| m.current_snapshot_id);
253        let snap = NewSnapshot {
254            snapshot_id: new_snapshot_id(),
255            parent_snapshot_id,
256            files: new_entries,
257            operation: SnapshotOperation::Overwrite,
258            iceberg_schema: None,
259            extra_properties: std::collections::HashMap::new(),
260            bloom_filters: vec![],
261            equality_delete_files: vec![],
262        };
263        self.catalog.commit_snapshot(table, snap).await?;
264        // Old files are out of the committed state now. Physically reclaim them
265        // only when the catalog backend allows it — DuckLake keeps a retired
266        // path registered until its own maintenance runs, so deleting the bytes
267        // there would break subsequent native reads (same gating as compaction,
268        // see docs/guides/DUCKLAKE_CATALOG.md "The retirement problem").
269        if self.catalog.retires_files_physically() {
270            for path in &retired_paths {
271                if let Err(e) = self.store.delete(path).await {
272                    warn!(
273                        "ailake: MemoryDecayJob cleanup — could not delete retired {}: {} \
274                         (orphan file; delete manually to reclaim storage)",
275                        path, e
276                    );
277                }
278            }
279        }
280        info!(
281            "ailake: MemoryDecayJob — updated recency_weight in {} files (lambda={})",
282            updated, self.decay_lambda
283        );
284        Ok(updated)
285    }
286}
287
288/// Extract days-since-access for each row, supporting Timestamp(ns/us) and legacy Utf8.
289fn days_old_vec(col: &Arc<dyn Array>, today_day: i64) -> AilakeResult<Vec<f32>> {
290    if let Some(ts) = col.as_any().downcast_ref::<TimestampNanosecondArray>() {
291        return Ok((0..ts.len())
292            .map(|i| {
293                if !ts.is_valid(i) {
294                    return 0.0f32;
295                }
296                let day = ts.value(i) / (86_400 * 1_000_000_000i64);
297                (today_day - day).max(0) as f32
298            })
299            .collect());
300    }
301    if let Some(ts) = col.as_any().downcast_ref::<TimestampMicrosecondArray>() {
302        return Ok((0..ts.len())
303            .map(|i| {
304                if !ts.is_valid(i) {
305                    return 0.0f32;
306                }
307                let day = ts.value(i) / (86_400 * 1_000_000i64);
308                (today_day - day).max(0) as f32
309            })
310            .collect());
311    }
312    if let Some(sa) = col.as_any().downcast_ref::<arrow_array::StringArray>() {
313        return Ok((0..sa.len())
314            .map(|i| {
315                if !sa.is_valid(i) {
316                    return 0.0f32;
317                }
318                let access_day = parse_iso_date_days(sa.value(i)).unwrap_or(today_day);
319                (today_day - access_day).max(0) as f32
320            })
321            .collect());
322    }
323    Err(AilakeError::Catalog(
324        "last_accessed_at must be Timestamp(Nanosecond/Microsecond) or Utf8".into(),
325    ))
326}
327
328/// Rewrite the `recency_weight` column in `batch` based on `last_accessed_at`.
329fn apply_decay(batch: &RecordBatch, today_day: i64, lambda: f32) -> AilakeResult<RecordBatch> {
330    let col = batch
331        .column_by_name(LAST_ACCESSED_COL)
332        .ok_or_else(|| AilakeError::Catalog("last_accessed_at column not found".into()))?;
333
334    let days_old = days_old_vec(col, today_day)?;
335    let new_weights: Vec<f32> = days_old.into_iter().map(|d| (-lambda * d).exp()).collect();
336
337    let new_weight_array = Arc::new(Float32Array::from(new_weights));
338
339    // Rebuild RecordBatch replacing (or adding) the recency_weight column.
340    let old_schema = batch.schema();
341    let decay_field = Field::new(RECENCY_WEIGHT_COL, DataType::Float32, false);
342
343    let mut new_fields: Vec<arrow_schema::FieldRef> = old_schema.fields().iter().cloned().collect();
344    let mut new_columns: Vec<Arc<dyn Array>> = (0..batch.num_columns())
345        .map(|i| batch.column(i).clone())
346        .collect();
347
348    if let Some(pos) = old_schema
349        .fields()
350        .iter()
351        .position(|f| f.name() == RECENCY_WEIGHT_COL)
352    {
353        new_fields[pos] = Arc::new(decay_field);
354        new_columns[pos] = new_weight_array;
355    } else {
356        new_fields.push(Arc::new(decay_field));
357        new_columns.push(new_weight_array);
358    }
359
360    let new_schema = Arc::new(arrow_schema::Schema::new(new_fields));
361    RecordBatch::try_new(new_schema, new_columns).map_err(|e| AilakeError::Arrow(e.to_string()))
362}
363
364/// Parse first 10 chars of an ISO 8601 string as YYYY-MM-DD and return
365/// days since Unix epoch (1970-01-01). Returns None on parse failure.
366fn parse_iso_date_days(s: &str) -> Option<i64> {
367    if s.len() < 10 {
368        return None;
369    }
370    let y: i64 = s[0..4].parse().ok()?;
371    let m: i64 = s[5..7].parse().ok()?;
372    let d: i64 = s[8..10].parse().ok()?;
373    // Julian Day Number (Gregorian calendar formula)
374    let a = (14 - m) / 12;
375    let y2 = y + 4800 - a;
376    let m2 = m + 12 * a - 3;
377    let jdn = d + (153 * m2 + 2) / 5 + 365 * y2 + y2 / 4 - y2 / 100 + y2 / 400 - 32045;
378    // Unix epoch = JDN 2440588
379    Some(jdn - 2440588)
380}
381
382fn current_day_since_epoch() -> i64 {
383    SystemTime::now()
384        .duration_since(UNIX_EPOCH)
385        .map(|d| d.as_secs() as i64 / 86400)
386        .unwrap_or(0)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn parse_iso_date_unix_epoch() {
395        assert_eq!(parse_iso_date_days("1970-01-01T00:00:00"), Some(0));
396    }
397
398    #[test]
399    fn parse_iso_date_known_date() {
400        // 2024-01-15 — verify against known day count
401        let days = parse_iso_date_days("2024-01-15").unwrap();
402        // 2024-01-15 is 19737 days after 1970-01-01
403        assert_eq!(days, 19737);
404    }
405
406    #[test]
407    fn parse_iso_date_returns_none_on_short_string() {
408        assert!(parse_iso_date_days("2024").is_none());
409        assert!(parse_iso_date_days("").is_none());
410    }
411
412    #[test]
413    fn apply_decay_updates_recency_weight() {
414        use arrow_array::StringArray;
415        use arrow_schema::{Field, Schema};
416
417        let today = current_day_since_epoch();
418        // 10 days ago
419        let past_day = today - 10;
420        let y = 1970 + past_day / 365; // rough
421                                       // Use a fixed known date instead
422        let past_str = "2024-01-05T00:00:00"; // 10 days before 2024-01-15
423
424        let schema = Arc::new(Schema::new(vec![
425            Field::new(LAST_ACCESSED_COL, DataType::Utf8, true),
426            Field::new(RECENCY_WEIGHT_COL, DataType::Float32, false),
427        ]));
428        let batch = RecordBatch::try_new(
429            schema,
430            vec![
431                Arc::new(StringArray::from(vec![past_str])),
432                Arc::new(Float32Array::from(vec![1.0f32])),
433            ],
434        )
435        .unwrap();
436
437        // Use today fixed to 2024-01-15 = day 19737
438        let today_day = 19737i64;
439        let result = apply_decay(&batch, today_day, 0.1).unwrap();
440        let weights = result
441            .column_by_name(RECENCY_WEIGHT_COL)
442            .unwrap()
443            .as_any()
444            .downcast_ref::<Float32Array>()
445            .unwrap();
446
447        let w = weights.value(0);
448        // 2024-01-05 = day 19727, so 10 days old: exp(-0.1 * 10) = exp(-1) ≈ 0.368
449        let expected = (-0.1f32 * 10.0).exp();
450        assert!((w - expected).abs() < 0.001, "expected {expected}, got {w}");
451        let _ = y; // suppress unused warning
452    }
453
454    #[test]
455    fn apply_decay_handles_timestamp_nanosecond() {
456        use arrow_schema::{Field, Schema, TimeUnit};
457
458        // 2024-01-05 00:00:00 UTC in nanoseconds = day 19727
459        // 2024-01-05 = 19727 days × 86400s × 1e9 ns
460        let day_19727_ns: i64 = 19727i64 * 86_400 * 1_000_000_000;
461
462        let schema = Arc::new(Schema::new(vec![
463            Field::new(
464                LAST_ACCESSED_COL,
465                DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
466                true,
467            ),
468            Field::new(RECENCY_WEIGHT_COL, DataType::Float32, false),
469        ]));
470        let batch = RecordBatch::try_new(
471            schema,
472            vec![
473                Arc::new(TimestampNanosecondArray::from(vec![day_19727_ns]).with_timezone("UTC")),
474                Arc::new(Float32Array::from(vec![1.0f32])),
475            ],
476        )
477        .unwrap();
478
479        // today = 2024-01-15 = day 19737 → 10 days old → exp(-0.1 * 10) ≈ 0.368
480        let today_day = 19737i64;
481        let result = apply_decay(&batch, today_day, 0.1).unwrap();
482        let weights = result
483            .column_by_name(RECENCY_WEIGHT_COL)
484            .unwrap()
485            .as_any()
486            .downcast_ref::<Float32Array>()
487            .unwrap();
488        let w = weights.value(0);
489        let expected = (-0.1f32 * 10.0).exp();
490        assert!((w - expected).abs() < 0.001, "expected {expected}, got {w}");
491    }
492
493    #[test]
494    fn now_ns_is_recent() {
495        // now_ns() must be > 2025-01-01 00:00:00 UTC in nanoseconds
496        let floor_2025_ns: i64 = 55 * 365 * 86_400 * 1_000_000_000i64; // ~2025
497        let t = ailake_core::now_ns();
498        assert!(
499            t > floor_2025_ns,
500            "now_ns() returned suspiciously small value: {t}"
501        );
502    }
503}