Skip to main content

oxiphysics_io/
simulation_database.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! SQLite-like in-memory simulation database with time-indexed snapshots,
5//! run-length-encoded compression, differential storage, event logging,
6//! result aggregation, and CSV/JSON import-export.
7//!
8//! # Overview
9//!
10//! - [`SimulationRecord`] — a single simulation run with parameters and metadata.
11//! - [`SimulationDatabase`] — a collection of records with CSV/JSON persistence.
12//! - [`SnapshotTable`] — time-indexed snapshots with RLE compression and
13//!   differential storage.
14//! - [`EventLog`] — timestamped event records for a simulation session.
15//! - [`ResultAggregator`] — computes min/max/mean/std over snapshot collections.
16//! - [`ProvenanceTracker`] — W3C-PROV-style provenance graph.
17//! - [`CheckpointManager`] — rolling-window checkpoint storage.
18//! - [`ParameterSweep`] — Cartesian-product and Latin-hypercube parameter sweeps.
19
20use std::collections::HashMap;
21
22// ---------------------------------------------------------------------------
23// SimulationRecord
24// ---------------------------------------------------------------------------
25
26/// A single simulation run record storing parameters and metadata.
27#[derive(Debug, Clone)]
28pub struct SimulationRecord {
29    /// Unique identifier for this simulation run.
30    pub id: String,
31    /// Unix timestamp (seconds since epoch) when the simulation was created.
32    pub timestamp: u64,
33    /// Numeric simulation parameters (e.g. `"dt"`, `"viscosity"`).
34    pub parameters: HashMap<String, f64>,
35    /// Arbitrary string metadata (e.g. `"solver"`, `"git_hash"`).
36    pub metadata: HashMap<String, String>,
37}
38
39impl SimulationRecord {
40    /// Construct a new record with the given id and timestamp.
41    pub fn new(id: impl Into<String>, timestamp: u64) -> Self {
42        Self {
43            id: id.into(),
44            timestamp,
45            parameters: HashMap::new(),
46            metadata: HashMap::new(),
47        }
48    }
49
50    /// Set a numeric parameter, overwriting any existing value.
51    pub fn set_param(&mut self, key: impl Into<String>, value: f64) {
52        self.parameters.insert(key.into(), value);
53    }
54
55    /// Set a string metadata entry.
56    pub fn set_meta(&mut self, key: impl Into<String>, value: impl Into<String>) {
57        self.metadata.insert(key.into(), value.into());
58    }
59}
60
61// ---------------------------------------------------------------------------
62// SimulationDatabase
63// ---------------------------------------------------------------------------
64
65/// In-memory database of simulation records with CSV/JSON persistence.
66#[derive(Debug, Default)]
67pub struct SimulationDatabase {
68    /// All stored simulation records.
69    pub records: Vec<SimulationRecord>,
70    /// Path to the backing CSV file (informational; used by save/load helpers).
71    pub file_path: String,
72}
73
74impl SimulationDatabase {
75    /// Create a new, empty database associated with the given file path.
76    pub fn new(file_path: impl Into<String>) -> Self {
77        Self {
78            records: Vec::new(),
79            file_path: file_path.into(),
80        }
81    }
82
83    /// Append a record to the database.
84    pub fn add_record(&mut self, record: SimulationRecord) {
85        self.records.push(record);
86    }
87
88    /// Find a record by its unique id, returning a reference or `None`.
89    pub fn find_by_id(&self, id: &str) -> Option<&SimulationRecord> {
90        self.records.iter().find(|r| r.id == id)
91    }
92
93    /// Return all records where `param` lies in the inclusive range `[lo, hi]`.
94    pub fn query_range(&self, param: &str, lo: f64, hi: f64) -> Vec<&SimulationRecord> {
95        self.records
96            .iter()
97            .filter(|r| r.parameters.get(param).is_some_and(|&v| v >= lo && v <= hi))
98            .collect()
99    }
100
101    /// Return all records whose timestamp falls in `[t_lo, t_hi]`.
102    pub fn query_time_range(&self, t_lo: u64, t_hi: u64) -> Vec<&SimulationRecord> {
103        self.records
104            .iter()
105            .filter(|r| r.timestamp >= t_lo && r.timestamp <= t_hi)
106            .collect()
107    }
108
109    /// Delete all records matching the given id.  Returns the number of
110    /// records removed.
111    pub fn delete_by_id(&mut self, id: &str) -> usize {
112        let before = self.records.len();
113        self.records.retain(|r| r.id != id);
114        before - self.records.len()
115    }
116
117    /// Serialize all records to a CSV string.
118    ///
119    /// Format: `id,timestamp,key=value;…,metakey=metaval;…`
120    pub fn save_to_csv(&self) -> String {
121        let mut out = String::from("id,timestamp,parameters,metadata\n");
122        for r in &self.records {
123            let params: Vec<String> = r
124                .parameters
125                .iter()
126                .map(|(k, v)| format!("{k}={v}"))
127                .collect();
128            let meta: Vec<String> = r.metadata.iter().map(|(k, v)| format!("{k}={v}")).collect();
129            out.push_str(&format!(
130                "{},{},{},{}\n",
131                r.id,
132                r.timestamp,
133                params.join(";"),
134                meta.join(";")
135            ));
136        }
137        out
138    }
139
140    /// Populate the database by parsing a CSV string produced by `save_to_csv`.
141    ///
142    /// Clears existing records before loading.
143    pub fn load_from_csv(&mut self, s: &str) {
144        self.records.clear();
145        for line in s.lines().skip(1) {
146            let parts: Vec<&str> = line.splitn(4, ',').collect();
147            if parts.len() < 2 {
148                continue;
149            }
150            let id = parts[0].to_string();
151            let timestamp: u64 = parts[1].parse().unwrap_or(0);
152            let mut record = SimulationRecord::new(id, timestamp);
153            if parts.len() > 2 && !parts[2].is_empty() {
154                for pair in parts[2].split(';') {
155                    let kv: Vec<&str> = pair.splitn(2, '=').collect();
156                    if kv.len() == 2
157                        && let Ok(v) = kv[1].parse::<f64>()
158                    {
159                        record.parameters.insert(kv[0].to_string(), v);
160                    }
161                }
162            }
163            if parts.len() > 3 && !parts[3].is_empty() {
164                for pair in parts[3].split(';') {
165                    let kv: Vec<&str> = pair.splitn(2, '=').collect();
166                    if kv.len() == 2 {
167                        record.metadata.insert(kv[0].to_string(), kv[1].to_string());
168                    }
169                }
170            }
171            self.records.push(record);
172        }
173    }
174
175    /// Compute `(min, max, mean)` for the named parameter across all records
176    /// that contain it. Returns `(0.0, 0.0, 0.0)` if no records match.
177    pub fn statistics(&self, param: &str) -> (f64, f64, f64) {
178        let values: Vec<f64> = self
179            .records
180            .iter()
181            .filter_map(|r| r.parameters.get(param).copied())
182            .collect();
183        if values.is_empty() {
184            return (0.0, 0.0, 0.0);
185        }
186        let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
187        let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
188        let mean = values.iter().sum::<f64>() / values.len() as f64;
189        (min, max, mean)
190    }
191
192    /// Export all records as a compact JSON string.
193    pub fn export_json(&self) -> String {
194        let mut out = String::from("[\n");
195        for (i, r) in self.records.iter().enumerate() {
196            out.push_str("  {\n");
197            out.push_str(&format!("    \"id\": \"{}\",\n", r.id));
198            out.push_str(&format!("    \"timestamp\": {},\n", r.timestamp));
199            out.push_str("    \"parameters\": {");
200            let params: Vec<String> = r
201                .parameters
202                .iter()
203                .map(|(k, v)| format!("\"{k}\": {v}"))
204                .collect();
205            out.push_str(&params.join(", "));
206            out.push_str("},\n");
207            out.push_str("    \"metadata\": {");
208            let meta: Vec<String> = r
209                .metadata
210                .iter()
211                .map(|(k, v)| format!("\"{k}\": \"{v}\""))
212                .collect();
213            out.push_str(&meta.join(", "));
214            out.push_str("}\n");
215            if i + 1 < self.records.len() {
216                out.push_str("  },\n");
217            } else {
218                out.push_str("  }\n");
219            }
220        }
221        out.push(']');
222        out
223    }
224
225    /// Import records from a minimal JSON array string as produced by
226    /// `export_json`.  Only the `id` and `timestamp` fields are parsed in
227    /// this lightweight implementation; full parameter parsing requires a
228    /// proper JSON library.
229    pub fn import_json_ids(&mut self, json: &str) {
230        // Very lightweight scanner: extract "id": "VALUE" and "timestamp": N pairs.
231        for chunk in json.split('{') {
232            let id = extract_json_str(chunk, "\"id\"");
233            let ts_str = extract_json_number(chunk, "\"timestamp\"");
234            if let Some(id) = id {
235                let ts: u64 = ts_str.unwrap_or_default().parse().unwrap_or(0);
236                self.records.push(SimulationRecord::new(id, ts));
237            }
238        }
239    }
240
241    /// Return the total number of records.
242    pub fn len(&self) -> usize {
243        self.records.len()
244    }
245
246    /// Return `true` if the database holds no records.
247    pub fn is_empty(&self) -> bool {
248        self.records.is_empty()
249    }
250}
251
252/// Extract the first string value for `key` from a JSON fragment.
253fn extract_json_str(chunk: &str, key: &str) -> Option<String> {
254    let pos = chunk.find(key)?;
255    let rest = &chunk[pos + key.len()..];
256    let colon = rest.find(':')? + 1;
257    let rest2 = rest[colon..].trim_start();
258    if !rest2.starts_with('"') {
259        return None;
260    }
261    let inner = &rest2[1..];
262    let end = inner.find('"')?;
263    Some(inner[..end].to_string())
264}
265
266/// Extract the first numeric (integer) value for `key` from a JSON fragment.
267fn extract_json_number(chunk: &str, key: &str) -> Option<String> {
268    let pos = chunk.find(key)?;
269    let rest = &chunk[pos + key.len()..];
270    let colon = rest.find(':')? + 1;
271    let rest2 = rest[colon..].trim_start();
272    let end = rest2
273        .find(|c: char| !c.is_ascii_digit())
274        .unwrap_or(rest2.len());
275    if end == 0 {
276        return None;
277    }
278    Some(rest2[..end].to_string())
279}
280
281// ---------------------------------------------------------------------------
282// Run-length encoding helpers
283// ---------------------------------------------------------------------------
284
285/// Encode a slice of `f64` values using run-length encoding.
286///
287/// Returns a `Vec<(f64, usize)>` where each tuple is `(value, count)`.
288pub fn rle_encode(data: &[f64]) -> Vec<(f64, usize)> {
289    if data.is_empty() {
290        return vec![];
291    }
292    let mut result = Vec::new();
293    let mut current = data[0];
294    let mut count = 1usize;
295    for &v in &data[1..] {
296        if (v - current).abs() < f64::EPSILON {
297            count += 1;
298        } else {
299            result.push((current, count));
300            current = v;
301            count = 1;
302        }
303    }
304    result.push((current, count));
305    result
306}
307
308/// Decode a run-length-encoded slice back to a flat `Vec`f64`.
309pub fn rle_decode(encoded: &[(f64, usize)]) -> Vec<f64> {
310    let mut result = Vec::new();
311    for &(v, n) in encoded {
312        for _ in 0..n {
313            result.push(v);
314        }
315    }
316    result
317}
318
319/// Compression ratio: `original_len / encoded_len` (number of (value, count) pairs).
320///
321/// Returns `1.0` if either side is zero.
322pub fn rle_compression_ratio(original_len: usize, encoded: &[(f64, usize)]) -> f64 {
323    if encoded.is_empty() || original_len == 0 {
324        return 1.0;
325    }
326    original_len as f64 / encoded.len() as f64
327}
328
329// ---------------------------------------------------------------------------
330// SnapshotTable — time-indexed snapshot storage
331// ---------------------------------------------------------------------------
332
333/// A single simulation snapshot: a named collection of scalar fields
334/// recorded at a given simulation time.
335#[derive(Debug, Clone)]
336pub struct Snapshot {
337    /// Simulation time at which this snapshot was taken.
338    pub time: f64,
339    /// Named field arrays (e.g. `"velocity_x"`, `"pressure"`).
340    pub fields: HashMap<String, Vec<f64>>,
341}
342
343impl Snapshot {
344    /// Create an empty snapshot at the given time.
345    pub fn new(time: f64) -> Self {
346        Self {
347            time,
348            fields: HashMap::new(),
349        }
350    }
351
352    /// Add or overwrite a named field.
353    pub fn set_field(&mut self, name: impl Into<String>, data: Vec<f64>) {
354        self.fields.insert(name.into(), data);
355    }
356
357    /// Return the length of the first field array, or 0 if no fields exist.
358    pub fn node_count(&self) -> usize {
359        self.fields.values().next().map_or(0, |v| v.len())
360    }
361}
362
363/// Differential storage entry: stores only the indices and new values
364/// that differ from the previous snapshot.
365#[derive(Debug, Clone)]
366pub struct DiffEntry {
367    /// Simulation time this diff applies to.
368    pub time: f64,
369    /// Field name.
370    pub field: String,
371    /// Indices of changed values.
372    pub indices: Vec<usize>,
373    /// New values at those indices.
374    pub new_values: Vec<f64>,
375}
376
377impl DiffEntry {
378    /// Create a new diff entry.
379    pub fn new(time: f64, field: impl Into<String>) -> Self {
380        Self {
381            time,
382            field: field.into(),
383            indices: Vec::new(),
384            new_values: Vec::new(),
385        }
386    }
387}
388
389/// Time-indexed table of simulation snapshots with optional RLE compression
390/// and differential storage.
391#[derive(Debug, Default)]
392pub struct SnapshotTable {
393    /// Stored snapshots ordered by insertion time.
394    pub snapshots: Vec<Snapshot>,
395    /// Differential entries for fields that change sparsely between snapshots.
396    pub diffs: Vec<DiffEntry>,
397    /// RLE-compressed versions of field arrays, keyed by `"field@time_idx"`.
398    pub compressed: HashMap<String, Vec<(f64, usize)>>,
399}
400
401impl SnapshotTable {
402    /// Create an empty table.
403    pub fn new() -> Self {
404        Self::default()
405    }
406
407    /// Insert a snapshot; snapshots are kept in ascending time order.
408    pub fn insert(&mut self, snap: Snapshot) {
409        let pos = self.snapshots.partition_point(|s| s.time < snap.time);
410        self.snapshots.insert(pos, snap);
411    }
412
413    /// Return all snapshots whose time falls in `\[t_lo, t_hi\]`.
414    pub fn query_time_range(&self, t_lo: f64, t_hi: f64) -> Vec<&Snapshot> {
415        self.snapshots
416            .iter()
417            .filter(|s| s.time >= t_lo && s.time <= t_hi)
418            .collect()
419    }
420
421    /// Retrieve a single snapshot whose time is closest to `t`.
422    pub fn nearest(&self, t: f64) -> Option<&Snapshot> {
423        self.snapshots.iter().min_by(|a, b| {
424            (a.time - t)
425                .abs()
426                .partial_cmp(&(b.time - t).abs())
427                .unwrap_or(std::cmp::Ordering::Equal)
428        })
429    }
430
431    /// Compress a named field of the snapshot at index `snap_idx` using RLE.
432    ///
433    /// The compressed data is stored internally under the key
434    /// `"`field`@`snap_idx`"`.
435    pub fn compress_field(&mut self, snap_idx: usize, field: &str) {
436        if let Some(snap) = self.snapshots.get(snap_idx)
437            && let Some(data) = snap.fields.get(field)
438        {
439            let encoded = rle_encode(data);
440            let key = format!("{field}@{snap_idx}");
441            self.compressed.insert(key, encoded);
442        }
443    }
444
445    /// Decompress a previously compressed field, returning the flat `Vec`f64`.
446    ///
447    /// Returns `None` if the field was not compressed.
448    pub fn decompress_field(&self, snap_idx: usize, field: &str) -> Option<Vec<f64>> {
449        let key = format!("{field}@{snap_idx}");
450        self.compressed.get(&key).map(|enc| rle_decode(enc))
451    }
452
453    /// Compute the differential between consecutive snapshots for a named field
454    /// and store the result in `self.diffs`.
455    ///
456    /// Returns the number of changed elements.
457    pub fn compute_diff(&mut self, field: &str, snap_idx: usize) -> usize {
458        if snap_idx == 0 || snap_idx >= self.snapshots.len() {
459            return 0;
460        }
461        let prev = self.snapshots[snap_idx - 1]
462            .fields
463            .get(field)
464            .cloned()
465            .unwrap_or_default();
466        let curr = self.snapshots[snap_idx]
467            .fields
468            .get(field)
469            .cloned()
470            .unwrap_or_default();
471        let time = self.snapshots[snap_idx].time;
472        let mut entry = DiffEntry::new(time, field);
473        for (i, (&p, &c)) in prev.iter().zip(curr.iter()).enumerate() {
474            if (c - p).abs() > f64::EPSILON {
475                entry.indices.push(i);
476                entry.new_values.push(c);
477            }
478        }
479        let changed = entry.indices.len();
480        self.diffs.push(entry);
481        changed
482    }
483
484    /// Apply a stored diff to a base field array, returning the updated array.
485    ///
486    /// `base` is modified in-place by overwriting the changed indices.
487    pub fn apply_diff(base: &mut [f64], diff: &DiffEntry) {
488        for (&idx, &val) in diff.indices.iter().zip(diff.new_values.iter()) {
489            if idx < base.len() {
490                base[idx] = val;
491            }
492        }
493    }
494
495    /// Return the number of snapshots.
496    pub fn len(&self) -> usize {
497        self.snapshots.len()
498    }
499
500    /// Return `true` if the table is empty.
501    pub fn is_empty(&self) -> bool {
502        self.snapshots.is_empty()
503    }
504}
505
506// ---------------------------------------------------------------------------
507// EventLog — timestamped event logging
508// ---------------------------------------------------------------------------
509
510/// Severity level for a logged event.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
512pub enum EventLevel {
513    /// Diagnostic trace messages.
514    Debug,
515    /// General informational messages.
516    Info,
517    /// Non-fatal warnings.
518    Warning,
519    /// Recoverable errors.
520    Error,
521    /// Fatal errors that stop the simulation.
522    Critical,
523}
524
525impl EventLevel {
526    /// Return the level as a static string label.
527    pub fn as_str(self) -> &'static str {
528        match self {
529            EventLevel::Debug => "DEBUG",
530            EventLevel::Info => "INFO",
531            EventLevel::Warning => "WARNING",
532            EventLevel::Error => "ERROR",
533            EventLevel::Critical => "CRITICAL",
534        }
535    }
536}
537
538/// A single logged event.
539#[derive(Debug, Clone)]
540pub struct LogEvent {
541    /// Simulation time at which the event occurred.
542    pub sim_time: f64,
543    /// Wall-clock timestamp (Unix seconds).
544    pub wall_time: u64,
545    /// Severity level.
546    pub level: EventLevel,
547    /// Category or subsystem name (e.g. `"solver"`, `"io"`).
548    pub category: String,
549    /// Human-readable message.
550    pub message: String,
551}
552
553impl LogEvent {
554    /// Construct a new event record.
555    pub fn new(
556        sim_time: f64,
557        wall_time: u64,
558        level: EventLevel,
559        category: impl Into<String>,
560        message: impl Into<String>,
561    ) -> Self {
562        Self {
563            sim_time,
564            wall_time,
565            level,
566            category: category.into(),
567            message: message.into(),
568        }
569    }
570}
571
572/// Append-only timestamped event log for a simulation session.
573#[derive(Debug, Default)]
574pub struct EventLog {
575    /// All recorded events.
576    pub events: Vec<LogEvent>,
577}
578
579impl EventLog {
580    /// Create an empty event log.
581    pub fn new() -> Self {
582        Self::default()
583    }
584
585    /// Append an event.
586    pub fn log(&mut self, event: LogEvent) {
587        self.events.push(event);
588    }
589
590    /// Convenience helper: log a message at `Info` level.
591    pub fn info(&mut self, sim_time: f64, category: impl Into<String>, message: impl Into<String>) {
592        self.log(LogEvent::new(
593            sim_time,
594            0,
595            EventLevel::Info,
596            category,
597            message,
598        ));
599    }
600
601    /// Convenience helper: log a message at `Warning` level.
602    pub fn warn(&mut self, sim_time: f64, category: impl Into<String>, message: impl Into<String>) {
603        self.log(LogEvent::new(
604            sim_time,
605            0,
606            EventLevel::Warning,
607            category,
608            message,
609        ));
610    }
611
612    /// Convenience helper: log a message at `Error` level.
613    pub fn error(
614        &mut self,
615        sim_time: f64,
616        category: impl Into<String>,
617        message: impl Into<String>,
618    ) {
619        self.log(LogEvent::new(
620            sim_time,
621            0,
622            EventLevel::Error,
623            category,
624            message,
625        ));
626    }
627
628    /// Return all events whose severity is at least `min_level`.
629    pub fn filter_level(&self, min_level: EventLevel) -> Vec<&LogEvent> {
630        self.events
631            .iter()
632            .filter(|e| e.level >= min_level)
633            .collect()
634    }
635
636    /// Return all events for the given category.
637    pub fn filter_category<'a>(&'a self, cat: &str) -> Vec<&'a LogEvent> {
638        self.events.iter().filter(|e| e.category == cat).collect()
639    }
640
641    /// Return all events whose simulation time falls in `[t_lo, t_hi]`.
642    pub fn filter_sim_time(&self, t_lo: f64, t_hi: f64) -> Vec<&LogEvent> {
643        self.events
644            .iter()
645            .filter(|e| e.sim_time >= t_lo && e.sim_time <= t_hi)
646            .collect()
647    }
648
649    /// Serialize the log to a CSV string.
650    pub fn to_csv(&self) -> String {
651        let mut out = String::from("sim_time,wall_time,level,category,message\n");
652        for e in &self.events {
653            out.push_str(&format!(
654                "{},{},{},{},{}\n",
655                e.sim_time,
656                e.wall_time,
657                e.level.as_str(),
658                e.category,
659                e.message
660            ));
661        }
662        out
663    }
664
665    /// Total number of events recorded.
666    pub fn len(&self) -> usize {
667        self.events.len()
668    }
669
670    /// Return `true` if no events have been logged.
671    pub fn is_empty(&self) -> bool {
672        self.events.is_empty()
673    }
674}
675
676// ---------------------------------------------------------------------------
677// ResultAggregator — statistical aggregation over snapshot collections
678// ---------------------------------------------------------------------------
679
680/// Summary statistics computed over a sequence of scalar values.
681#[derive(Debug, Clone)]
682pub struct AggStats {
683    /// Number of values in the sample.
684    pub count: usize,
685    /// Minimum value.
686    pub min: f64,
687    /// Maximum value.
688    pub max: f64,
689    /// Arithmetic mean.
690    pub mean: f64,
691    /// Population standard deviation.
692    pub std: f64,
693    /// Sum of all values.
694    pub sum: f64,
695}
696
697impl AggStats {
698    /// Compute statistics from a slice.  Returns `None` if the slice is empty.
699    pub fn from_slice(data: &[f64]) -> Option<Self> {
700        if data.is_empty() {
701            return None;
702        }
703        let n = data.len();
704        let sum = data.iter().sum::<f64>();
705        let mean = sum / n as f64;
706        let min = data.iter().cloned().fold(f64::INFINITY, f64::min);
707        let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
708        let variance = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
709        let std = variance.sqrt();
710        Some(Self {
711            count: n,
712            min,
713            max,
714            mean,
715            std,
716            sum,
717        })
718    }
719}
720
721/// Aggregates statistics over a collection of snapshots for a named field.
722#[derive(Debug, Default)]
723pub struct ResultAggregator {
724    /// Collected scalar samples (one per snapshot time step).
725    pub samples: Vec<f64>,
726}
727
728impl ResultAggregator {
729    /// Create an empty aggregator.
730    pub fn new() -> Self {
731        Self::default()
732    }
733
734    /// Add the mean value of `field` from `snapshot` to the sample set.
735    pub fn add_snapshot_mean(&mut self, snapshot: &Snapshot, field: &str) {
736        if let Some(data) = snapshot.fields.get(field)
737            && !data.is_empty()
738        {
739            let mean = data.iter().sum::<f64>() / data.len() as f64;
740            self.samples.push(mean);
741        }
742    }
743
744    /// Add the maximum absolute value of `field` from `snapshot`.
745    pub fn add_snapshot_max_abs(&mut self, snapshot: &Snapshot, field: &str) {
746        if let Some(data) = snapshot.fields.get(field)
747            && let Some(max_abs) = data.iter().cloned().map(f64::abs).reduce(f64::max)
748        {
749            self.samples.push(max_abs);
750        }
751    }
752
753    /// Push a raw scalar sample directly.
754    pub fn push(&mut self, v: f64) {
755        self.samples.push(v);
756    }
757
758    /// Compute aggregate statistics over all pushed samples.
759    pub fn compute(&self) -> Option<AggStats> {
760        AggStats::from_slice(&self.samples)
761    }
762
763    /// Reset all samples.
764    pub fn reset(&mut self) {
765        self.samples.clear();
766    }
767}
768
769// ---------------------------------------------------------------------------
770// MetadataStore — simulation parameter and provenance metadata
771// ---------------------------------------------------------------------------
772
773/// A structured store for simulation-level metadata including git hash,
774/// build timestamp, and runtime parameters.
775#[derive(Debug, Default, Clone)]
776pub struct MetadataStore {
777    /// Key-value string pairs (e.g. `"git_hash"`, `"compiler_version"`).
778    pub entries: HashMap<String, String>,
779}
780
781impl MetadataStore {
782    /// Create an empty metadata store.
783    pub fn new() -> Self {
784        Self::default()
785    }
786
787    /// Set or overwrite an entry.
788    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
789        self.entries.insert(key.into(), value.into());
790    }
791
792    /// Retrieve an entry by key.
793    pub fn get(&self, key: &str) -> Option<&str> {
794        self.entries.get(key).map(String::as_str)
795    }
796
797    /// Serialize to a newline-delimited `KEY=VALUE` format.
798    pub fn to_properties(&self) -> String {
799        let mut lines: Vec<String> = self
800            .entries
801            .iter()
802            .map(|(k, v)| format!("{k}={v}"))
803            .collect();
804        lines.sort();
805        lines.join("\n")
806    }
807
808    /// Parse from a newline-delimited `KEY=VALUE` format.
809    pub fn from_properties(s: &str) -> Self {
810        let mut store = Self::new();
811        for line in s.lines() {
812            if let Some(pos) = line.find('=') {
813                let k = &line[..pos];
814                let v = &line[pos + 1..];
815                store.set(k, v);
816            }
817        }
818        store
819    }
820
821    /// Merge another store into `self`, overwriting on conflict.
822    pub fn merge(&mut self, other: &MetadataStore) {
823        for (k, v) in &other.entries {
824            self.entries.insert(k.clone(), v.clone());
825        }
826    }
827
828    /// Return the number of entries.
829    pub fn len(&self) -> usize {
830        self.entries.len()
831    }
832
833    /// Return `true` if the store is empty.
834    pub fn is_empty(&self) -> bool {
835        self.entries.is_empty()
836    }
837}
838
839// ---------------------------------------------------------------------------
840// ProvenanceType
841// ---------------------------------------------------------------------------
842
843/// Discriminant for provenance graph nodes.
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub enum ProvenanceType {
846    /// A computational activity (e.g. a solver step).
847    Computation,
848    /// A data artifact (e.g. an input file or result dataset).
849    Dataset,
850    /// A software or human agent responsible for an action.
851    Agent,
852}
853
854// ---------------------------------------------------------------------------
855// ProvenanceNode
856// ---------------------------------------------------------------------------
857
858/// A node in the provenance graph.
859#[derive(Debug, Clone)]
860pub struct ProvenanceNode {
861    /// Unique node identifier.
862    pub id: String,
863    /// Category of this node.
864    pub type_: ProvenanceType,
865    /// IDs of nodes that are inputs to this node.
866    pub inputs: Vec<String>,
867    /// IDs of nodes that are outputs of this node.
868    pub outputs: Vec<String>,
869}
870
871impl ProvenanceNode {
872    /// Create a leaf node with no inputs or outputs.
873    pub fn new(id: impl Into<String>, type_: ProvenanceType) -> Self {
874        Self {
875            id: id.into(),
876            type_,
877            inputs: Vec::new(),
878            outputs: Vec::new(),
879        }
880    }
881}
882
883// ---------------------------------------------------------------------------
884// ProvenanceTracker
885// ---------------------------------------------------------------------------
886
887/// Directed acyclic graph for W3C-PROV-style provenance tracking.
888#[derive(Debug, Default)]
889pub struct ProvenanceTracker {
890    /// Ordered list of nodes in the provenance graph.
891    pub graph: Vec<ProvenanceNode>,
892}
893
894impl ProvenanceTracker {
895    /// Create an empty tracker.
896    pub fn new() -> Self {
897        Self::default()
898    }
899
900    /// Record a computation node that consumes `inputs` and produces `outputs`.
901    pub fn add_computation(
902        &mut self,
903        id: impl Into<String>,
904        inputs: Vec<String>,
905        outputs: Vec<String>,
906    ) {
907        let mut node = ProvenanceNode::new(id, ProvenanceType::Computation);
908        node.inputs = inputs;
909        node.outputs = outputs;
910        self.graph.push(node);
911    }
912
913    /// Record a data-source (dataset) node.
914    pub fn add_data_source(&mut self, id: impl Into<String>, outputs: Vec<String>) {
915        let mut node = ProvenanceNode::new(id, ProvenanceType::Dataset);
916        node.outputs = outputs;
917        self.graph.push(node);
918    }
919
920    /// Trace the full ancestry of `target_id`.
921    ///
922    /// Returns all node IDs that transitively contribute to `target_id`.
923    pub fn trace_lineage(&self, target_id: &str) -> Vec<String> {
924        let mut visited: Vec<String> = Vec::new();
925        let mut queue: Vec<String> = vec![target_id.to_string()];
926        while let Some(current) = queue.pop() {
927            if visited.contains(&current) {
928                continue;
929            }
930            visited.push(current.clone());
931            for node in &self.graph {
932                if node.outputs.contains(&current) && !visited.contains(&node.id) {
933                    queue.push(node.id.clone());
934                }
935            }
936            if let Some(node) = self.graph.iter().find(|n| n.id == current) {
937                for inp in &node.inputs {
938                    if !visited.contains(inp) {
939                        queue.push(inp.clone());
940                    }
941                }
942            }
943        }
944        visited
945    }
946
947    /// Serialise the provenance graph to a simplified PROV-JSON string.
948    pub fn to_prov_json(&self) -> String {
949        let mut out = String::from("{\n  \"nodes\": [\n");
950        for (i, node) in self.graph.iter().enumerate() {
951            let type_str = match node.type_ {
952                ProvenanceType::Computation => "Activity",
953                ProvenanceType::Dataset => "Entity",
954                ProvenanceType::Agent => "Agent",
955            };
956            out.push_str(&format!(
957                "    {{\"id\": \"{}\", \"type\": \"{}\", \"inputs\": [{}], \"outputs\": [{}]}}",
958                node.id,
959                type_str,
960                node.inputs
961                    .iter()
962                    .map(|s| format!("\"{s}\""))
963                    .collect::<Vec<_>>()
964                    .join(", "),
965                node.outputs
966                    .iter()
967                    .map(|s| format!("\"{s}\""))
968                    .collect::<Vec<_>>()
969                    .join(", "),
970            ));
971            if i + 1 < self.graph.len() {
972                out.push_str(",\n");
973            } else {
974                out.push('\n');
975            }
976        }
977        out.push_str("  ]\n}");
978        out
979    }
980}
981
982// ---------------------------------------------------------------------------
983// CheckpointManager
984// ---------------------------------------------------------------------------
985
986/// Manages a rolling window of simulation checkpoints stored in memory.
987///
988/// In a real deployment `base_dir` would name a directory on disk; here the
989/// data is kept in an internal `HashMap` so the module has no I/O dependency.
990#[derive(Debug)]
991pub struct CheckpointManager {
992    /// Base directory for checkpoint files (informational, not used for in-memory store).
993    pub base_dir: String,
994    /// Maximum number of checkpoints to retain.
995    pub max_checkpoints: usize,
996    store: HashMap<usize, Vec<f64>>,
997}
998
999impl CheckpointManager {
1000    /// Create a new manager for `base_dir` retaining at most `max_checkpoints`.
1001    pub fn new(base_dir: impl Into<String>, max_checkpoints: usize) -> Self {
1002        Self {
1003            base_dir: base_dir.into(),
1004            max_checkpoints: max_checkpoints.max(1),
1005            store: HashMap::new(),
1006        }
1007    }
1008
1009    /// Save a checkpoint for `step`, returning the logical path string.
1010    pub fn save_checkpoint(&mut self, step: usize, data: &[f64]) -> String {
1011        self.store.insert(step, data.to_vec());
1012        self.cleanup_old();
1013        format!("{}/checkpoint_{step:06}.bin", self.base_dir)
1014    }
1015
1016    /// Return all checkpoint step numbers in ascending order.
1017    pub fn list_checkpoints(&self) -> Vec<usize> {
1018        let mut steps: Vec<usize> = self.store.keys().copied().collect();
1019        steps.sort_unstable();
1020        steps
1021    }
1022
1023    /// Load the data for `step`, or `None` if no such checkpoint exists.
1024    pub fn load_checkpoint(&self, step: usize) -> Option<Vec<f64>> {
1025        self.store.get(&step).cloned()
1026    }
1027
1028    /// Remove old checkpoints so that at most `max_checkpoints` are kept.
1029    pub fn cleanup_old(&mut self) {
1030        let mut steps = self.list_checkpoints();
1031        while steps.len() > self.max_checkpoints {
1032            let oldest = steps.remove(0);
1033            self.store.remove(&oldest);
1034        }
1035    }
1036}
1037
1038// ---------------------------------------------------------------------------
1039// ParameterSweep
1040// ---------------------------------------------------------------------------
1041
1042/// A multi-dimensional parameter sweep specification.
1043///
1044/// Each entry is a `(name, values)` pair.  The sweep can be evaluated either
1045/// as a full Cartesian product or via a Latin-hypercube sample.
1046#[derive(Debug, Default)]
1047pub struct ParameterSweep {
1048    /// Ordered list of `(parameter_name, candidate_values)` pairs.
1049    pub params: Vec<(String, Vec<f64>)>,
1050}
1051
1052impl ParameterSweep {
1053    /// Create an empty sweep.
1054    pub fn new() -> Self {
1055        Self::default()
1056    }
1057
1058    /// Add a parameter with its list of candidate values.
1059    pub fn add_param(&mut self, name: impl Into<String>, values: Vec<f64>) {
1060        self.params.push((name.into(), values));
1061    }
1062
1063    /// Total number of points in the Cartesian product.
1064    pub fn count(&self) -> usize {
1065        if self.params.is_empty() {
1066            return 0;
1067        }
1068        self.params.iter().map(|(_, v)| v.len()).product()
1069    }
1070
1071    /// Enumerate every combination of parameter values (Cartesian product).
1072    pub fn cartesian_product(&self) -> Vec<HashMap<String, f64>> {
1073        if self.params.is_empty() {
1074            return vec![];
1075        }
1076        let mut result: Vec<HashMap<String, f64>> = vec![HashMap::new()];
1077        for (name, values) in &self.params {
1078            let mut next: Vec<HashMap<String, f64>> = Vec::new();
1079            for existing in &result {
1080                for &v in values {
1081                    let mut map = existing.clone();
1082                    map.insert(name.clone(), v);
1083                    next.push(map);
1084                }
1085            }
1086            result = next;
1087        }
1088        result
1089    }
1090
1091    /// Draw `n` samples using a deterministic Latin-hypercube strategy.
1092    pub fn latin_hypercube_sample(&self, n: usize) -> Vec<HashMap<String, f64>> {
1093        if n == 0 || self.params.is_empty() {
1094            return vec![];
1095        }
1096        let mut samples: Vec<HashMap<String, f64>> = (0..n).map(|_| HashMap::new()).collect();
1097        for (dim, (name, values)) in self.params.iter().enumerate() {
1098            let k = values.len();
1099            if k == 0 {
1100                continue;
1101            }
1102            let mut perm: Vec<usize> = (0..n).collect();
1103            let seed: usize = dim
1104                .wrapping_mul(6_364_136_223_846_793_005)
1105                .wrapping_add(1_442_695_040_888_963_407);
1106            for i in (1..n).rev() {
1107                let j = seed.wrapping_mul(i).wrapping_add(dim) % (i + 1);
1108                perm.swap(i, j);
1109            }
1110            for (i, map) in samples.iter_mut().enumerate() {
1111                let idx = ((perm[i] * k) / n).min(k - 1);
1112                let t = (perm[i] as f64 + 0.5) / n as f64;
1113                let lo = values[idx];
1114                let hi = if idx + 1 < k { values[idx + 1] } else { lo };
1115                let local_t = (t * n as f64 - perm[i] as f64).clamp(0.0, 1.0);
1116                let v = lo + local_t * (hi - lo);
1117                map.insert(name.clone(), v);
1118            }
1119        }
1120        samples
1121    }
1122}
1123
1124// ---------------------------------------------------------------------------
1125// QueryBuilder — fluent interface for filtering records
1126// ---------------------------------------------------------------------------
1127
1128/// A builder for composing multi-criteria queries over a
1129/// [`SimulationDatabase`].
1130#[derive(Debug, Default)]
1131pub struct QueryBuilder<'a> {
1132    db: Option<&'a SimulationDatabase>,
1133    param_filters: Vec<(String, f64, f64)>,
1134    time_range: Option<(u64, u64)>,
1135    meta_filter: Option<(String, String)>,
1136}
1137
1138impl<'a> QueryBuilder<'a> {
1139    /// Attach a database to query.
1140    pub fn from(db: &'a SimulationDatabase) -> Self {
1141        Self {
1142            db: Some(db),
1143            ..Self::default()
1144        }
1145    }
1146
1147    /// Filter by a numeric parameter range `[lo, hi]`.
1148    pub fn param_range(mut self, name: impl Into<String>, lo: f64, hi: f64) -> Self {
1149        self.param_filters.push((name.into(), lo, hi));
1150        self
1151    }
1152
1153    /// Filter by wall-clock timestamp range `[t_lo, t_hi]`.
1154    pub fn time_range(mut self, t_lo: u64, t_hi: u64) -> Self {
1155        self.time_range = Some((t_lo, t_hi));
1156        self
1157    }
1158
1159    /// Filter by a required metadata key-value pair.
1160    pub fn meta_eq(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1161        self.meta_filter = Some((key.into(), value.into()));
1162        self
1163    }
1164
1165    /// Execute the query and return matching records.
1166    pub fn execute(&self) -> Vec<&SimulationRecord> {
1167        let db = match self.db {
1168            Some(d) => d,
1169            None => return vec![],
1170        };
1171        db.records
1172            .iter()
1173            .filter(|r| {
1174                // Parameter range filters
1175                for (name, lo, hi) in &self.param_filters {
1176                    match r.parameters.get(name.as_str()) {
1177                        Some(&v) if v >= *lo && v <= *hi => {}
1178                        _ => return false,
1179                    }
1180                }
1181                // Time range filter
1182                if let Some((t_lo, t_hi)) = self.time_range
1183                    && (r.timestamp < t_lo || r.timestamp > t_hi)
1184                {
1185                    return false;
1186                }
1187                // Metadata filter
1188                if let Some((k, v)) = &self.meta_filter {
1189                    match r.metadata.get(k.as_str()) {
1190                        Some(val) if val == v => {}
1191                        _ => return false,
1192                    }
1193                }
1194                true
1195            })
1196            .collect()
1197    }
1198}
1199
1200// ---------------------------------------------------------------------------
1201// Tests
1202// ---------------------------------------------------------------------------
1203
1204#[cfg(test)]
1205mod tests {
1206    use super::*;
1207
1208    // --- SimulationRecord ---
1209
1210    #[test]
1211    fn test_record_new() {
1212        let r = SimulationRecord::new("run-001", 1_700_000_000);
1213        assert_eq!(r.id, "run-001");
1214        assert_eq!(r.timestamp, 1_700_000_000);
1215        assert!(r.parameters.is_empty());
1216        assert!(r.metadata.is_empty());
1217    }
1218
1219    #[test]
1220    fn test_record_set_param() {
1221        let mut r = SimulationRecord::new("r1", 0);
1222        r.set_param("dt", 0.01);
1223        assert_eq!(r.parameters["dt"], 0.01);
1224    }
1225
1226    #[test]
1227    fn test_record_set_meta() {
1228        let mut r = SimulationRecord::new("r2", 0);
1229        r.set_meta("solver", "rk4");
1230        assert_eq!(r.metadata["solver"], "rk4");
1231    }
1232
1233    // --- SimulationDatabase ---
1234
1235    #[test]
1236    fn test_db_new_empty() {
1237        let path = std::env::temp_dir().join("test_simdb.csv");
1238        let path_str = path.to_str().unwrap_or("").to_string();
1239        let db = SimulationDatabase::new(&path_str);
1240        assert!(db.records.is_empty());
1241        assert_eq!(db.file_path, path_str);
1242    }
1243
1244    #[test]
1245    fn test_db_add_and_find() {
1246        let path = std::env::temp_dir().join("test_simdb_find.csv");
1247        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1248        let r = SimulationRecord::new("abc", 42);
1249        db.add_record(r);
1250        assert!(db.find_by_id("abc").is_some());
1251        assert!(db.find_by_id("xyz").is_none());
1252    }
1253
1254    #[test]
1255    fn test_db_delete_by_id() {
1256        let path = std::env::temp_dir().join("test_simdb_del.csv");
1257        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1258        db.add_record(SimulationRecord::new("to_delete", 0));
1259        db.add_record(SimulationRecord::new("keep", 0));
1260        let removed = db.delete_by_id("to_delete");
1261        assert_eq!(removed, 1);
1262        assert!(db.find_by_id("to_delete").is_none());
1263        assert!(db.find_by_id("keep").is_some());
1264    }
1265
1266    #[test]
1267    fn test_db_query_range_basic() {
1268        let path = std::env::temp_dir().join("test_simdb_qr.csv");
1269        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1270        let mut r1 = SimulationRecord::new("a", 0);
1271        r1.set_param("dt", 0.01);
1272        let mut r2 = SimulationRecord::new("b", 0);
1273        r2.set_param("dt", 0.1);
1274        let mut r3 = SimulationRecord::new("c", 0);
1275        r3.set_param("dt", 1.0);
1276        db.add_record(r1);
1277        db.add_record(r2);
1278        db.add_record(r3);
1279        let found = db.query_range("dt", 0.005, 0.2);
1280        assert_eq!(found.len(), 2);
1281    }
1282
1283    #[test]
1284    fn test_db_query_time_range() {
1285        let path = std::env::temp_dir().join("test_simdb_tr.csv");
1286        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1287        for i in 0_u64..5 {
1288            db.add_record(SimulationRecord::new(format!("r{i}"), 1000 + i * 100));
1289        }
1290        let found = db.query_time_range(1100, 1300);
1291        assert_eq!(found.len(), 3); // ts 1100, 1200, 1300
1292    }
1293
1294    #[test]
1295    fn test_db_save_and_load_roundtrip() {
1296        let path = std::env::temp_dir().join("test_simdb_rt.csv");
1297        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1298        let mut r = SimulationRecord::new("run42", 999);
1299        r.set_param("Re", 1000.0);
1300        r.set_meta("solver", "rk4");
1301        db.add_record(r);
1302        let csv = db.save_to_csv();
1303        let mut db2 = SimulationDatabase::new(path.to_str().unwrap_or(""));
1304        db2.load_from_csv(&csv);
1305        assert_eq!(db2.records.len(), 1);
1306        assert_eq!(db2.records[0].id, "run42");
1307        assert_eq!(db2.records[0].timestamp, 999);
1308        assert!((db2.records[0].parameters["Re"] - 1000.0).abs() < 1e-9);
1309    }
1310
1311    #[test]
1312    fn test_db_statistics_basic() {
1313        let path = std::env::temp_dir().join("test_simdb_stats.csv");
1314        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1315        for (i, v) in [1.0_f64, 2.0, 3.0, 4.0, 5.0].iter().enumerate() {
1316            let mut r = SimulationRecord::new(format!("r{i}"), 0);
1317            r.set_param("x", *v);
1318            db.add_record(r);
1319        }
1320        let (min, max, mean) = db.statistics("x");
1321        assert!((min - 1.0).abs() < 1e-9);
1322        assert!((max - 5.0).abs() < 1e-9);
1323        assert!((mean - 3.0).abs() < 1e-9);
1324    }
1325
1326    #[test]
1327    fn test_db_export_json_contains_id() {
1328        let path = std::env::temp_dir().join("test_simdb_json.csv");
1329        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1330        db.add_record(SimulationRecord::new("sim-1", 0));
1331        let json = db.export_json();
1332        assert!(json.contains("\"sim-1\""));
1333    }
1334
1335    // --- RLE helpers ---
1336
1337    #[test]
1338    fn test_rle_encode_all_same() {
1339        let data = vec![3.125; 100];
1340        let enc = rle_encode(&data);
1341        assert_eq!(enc.len(), 1);
1342        assert_eq!(enc[0].1, 100);
1343    }
1344
1345    #[test]
1346    fn test_rle_roundtrip() {
1347        let data = vec![1.0, 1.0, 2.0, 3.0, 3.0, 3.0, 4.0];
1348        let enc = rle_encode(&data);
1349        let dec = rle_decode(&enc);
1350        assert_eq!(dec, data);
1351    }
1352
1353    #[test]
1354    fn test_rle_empty() {
1355        let enc = rle_encode(&[]);
1356        assert!(enc.is_empty());
1357        let dec = rle_decode(&[]);
1358        assert!(dec.is_empty());
1359    }
1360
1361    #[test]
1362    fn test_rle_compression_ratio() {
1363        let data = vec![0.0_f64; 50];
1364        let enc = rle_encode(&data);
1365        let ratio = rle_compression_ratio(data.len(), &enc);
1366        assert!(ratio > 1.0);
1367    }
1368
1369    #[test]
1370    fn test_rle_no_compression_all_different() {
1371        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
1372        let enc = rle_encode(&data);
1373        let ratio = rle_compression_ratio(data.len(), &enc);
1374        // Each run has length 1, so ratio == 1
1375        assert!((ratio - 1.0).abs() < 1e-9);
1376    }
1377
1378    // --- SnapshotTable ---
1379
1380    #[test]
1381    fn test_snapshot_insert_sorted() {
1382        let mut table = SnapshotTable::new();
1383        table.insert(Snapshot::new(3.0));
1384        table.insert(Snapshot::new(1.0));
1385        table.insert(Snapshot::new(2.0));
1386        let times: Vec<f64> = table.snapshots.iter().map(|s| s.time).collect();
1387        assert_eq!(times, vec![1.0, 2.0, 3.0]);
1388    }
1389
1390    #[test]
1391    fn test_snapshot_query_time_range() {
1392        let mut table = SnapshotTable::new();
1393        for t in [0.0, 1.0, 2.0, 3.0, 4.0] {
1394            table.insert(Snapshot::new(t));
1395        }
1396        let found = table.query_time_range(1.0, 3.0);
1397        assert_eq!(found.len(), 3);
1398    }
1399
1400    #[test]
1401    fn test_snapshot_nearest() {
1402        let mut table = SnapshotTable::new();
1403        for t in [0.0, 1.0, 2.0] {
1404            table.insert(Snapshot::new(t));
1405        }
1406        let near = table.nearest(1.4).unwrap();
1407        assert!((near.time - 1.0).abs() < 1e-9);
1408    }
1409
1410    #[test]
1411    fn test_snapshot_compress_decompress() {
1412        let mut table = SnapshotTable::new();
1413        let mut snap = Snapshot::new(0.0);
1414        snap.set_field("pressure", vec![1.0, 1.0, 1.0, 2.0]);
1415        table.insert(snap);
1416        table.compress_field(0, "pressure");
1417        let dec = table.decompress_field(0, "pressure").unwrap();
1418        assert_eq!(dec, vec![1.0, 1.0, 1.0, 2.0]);
1419    }
1420
1421    #[test]
1422    fn test_snapshot_diff_changes_counted() {
1423        let mut table = SnapshotTable::new();
1424        let mut s0 = Snapshot::new(0.0);
1425        s0.set_field("u", vec![1.0, 2.0, 3.0]);
1426        let mut s1 = Snapshot::new(1.0);
1427        s1.set_field("u", vec![1.0, 9.0, 3.0]); // index 1 changed
1428        table.insert(s0);
1429        table.insert(s1);
1430        let changed = table.compute_diff("u", 1);
1431        assert_eq!(changed, 1);
1432        assert_eq!(table.diffs[0].indices, vec![1]);
1433        assert!((table.diffs[0].new_values[0] - 9.0).abs() < 1e-9);
1434    }
1435
1436    #[test]
1437    fn test_snapshot_apply_diff() {
1438        let diff = DiffEntry {
1439            time: 1.0,
1440            field: "u".to_string(),
1441            indices: vec![0, 2],
1442            new_values: vec![10.0, 30.0],
1443        };
1444        let mut base = vec![1.0, 2.0, 3.0];
1445        SnapshotTable::apply_diff(&mut base, &diff);
1446        assert!((base[0] - 10.0).abs() < 1e-9);
1447        assert!((base[1] - 2.0).abs() < 1e-9);
1448        assert!((base[2] - 30.0).abs() < 1e-9);
1449    }
1450
1451    // --- EventLog ---
1452
1453    #[test]
1454    fn test_event_log_basic() {
1455        let mut log = EventLog::new();
1456        log.info(1.0, "solver", "step started");
1457        assert_eq!(log.len(), 1);
1458        assert!(!log.is_empty());
1459    }
1460
1461    #[test]
1462    fn test_event_log_filter_level() {
1463        let mut log = EventLog::new();
1464        log.info(0.0, "a", "msg");
1465        log.warn(1.0, "b", "warn");
1466        log.error(2.0, "c", "err");
1467        let warns_and_above = log.filter_level(EventLevel::Warning);
1468        assert_eq!(warns_and_above.len(), 2);
1469    }
1470
1471    #[test]
1472    fn test_event_log_filter_category() {
1473        let mut log = EventLog::new();
1474        log.info(0.0, "io", "read file");
1475        log.info(0.5, "solver", "step 1");
1476        log.info(1.0, "io", "write file");
1477        let io_events = log.filter_category("io");
1478        assert_eq!(io_events.len(), 2);
1479    }
1480
1481    #[test]
1482    fn test_event_log_filter_sim_time() {
1483        let mut log = EventLog::new();
1484        for t in [0.0, 1.0, 2.0, 3.0, 4.0_f64] {
1485            log.info(t, "x", "msg");
1486        }
1487        let found = log.filter_sim_time(1.0, 3.0);
1488        assert_eq!(found.len(), 3);
1489    }
1490
1491    #[test]
1492    fn test_event_log_to_csv() {
1493        let mut log = EventLog::new();
1494        log.info(1.0, "solver", "done");
1495        let csv = log.to_csv();
1496        assert!(csv.contains("INFO"));
1497        assert!(csv.contains("solver"));
1498        assert!(csv.contains("done"));
1499    }
1500
1501    #[test]
1502    fn test_event_level_ordering() {
1503        assert!(EventLevel::Critical > EventLevel::Error);
1504        assert!(EventLevel::Error > EventLevel::Warning);
1505        assert!(EventLevel::Warning > EventLevel::Info);
1506        assert!(EventLevel::Info > EventLevel::Debug);
1507    }
1508
1509    // --- ResultAggregator ---
1510
1511    #[test]
1512    fn test_agg_stats_basic() {
1513        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1514        let stats = AggStats::from_slice(&data).unwrap();
1515        assert!((stats.min - 1.0).abs() < 1e-9);
1516        assert!((stats.max - 5.0).abs() < 1e-9);
1517        assert!((stats.mean - 3.0).abs() < 1e-9);
1518        // std of [1,2,3,4,5]: variance = 2.0, std = sqrt(2)
1519        assert!((stats.std - 2_f64.sqrt()).abs() < 1e-9);
1520    }
1521
1522    #[test]
1523    fn test_agg_stats_empty() {
1524        assert!(AggStats::from_slice(&[]).is_none());
1525    }
1526
1527    #[test]
1528    fn test_result_aggregator_push_compute() {
1529        let mut agg = ResultAggregator::new();
1530        agg.push(10.0);
1531        agg.push(20.0);
1532        agg.push(30.0);
1533        let stats = agg.compute().unwrap();
1534        assert!((stats.mean - 20.0).abs() < 1e-9);
1535    }
1536
1537    #[test]
1538    fn test_result_aggregator_add_snapshot_mean() {
1539        let mut agg = ResultAggregator::new();
1540        let mut snap = Snapshot::new(0.0);
1541        snap.set_field("v", vec![2.0, 4.0, 6.0]);
1542        agg.add_snapshot_mean(&snap, "v");
1543        let stats = agg.compute().unwrap();
1544        assert!((stats.mean - 4.0).abs() < 1e-9);
1545    }
1546
1547    #[test]
1548    fn test_result_aggregator_reset() {
1549        let mut agg = ResultAggregator::new();
1550        agg.push(1.0);
1551        agg.reset();
1552        assert!(agg.compute().is_none());
1553    }
1554
1555    // --- MetadataStore ---
1556
1557    #[test]
1558    fn test_metadata_store_set_get() {
1559        let mut store = MetadataStore::new();
1560        store.set("git_hash", "abc123");
1561        assert_eq!(store.get("git_hash"), Some("abc123"));
1562        assert_eq!(store.get("missing"), None);
1563    }
1564
1565    #[test]
1566    fn test_metadata_store_roundtrip() {
1567        let mut store = MetadataStore::new();
1568        store.set("a", "1");
1569        store.set("b", "hello world");
1570        let props = store.to_properties();
1571        let loaded = MetadataStore::from_properties(&props);
1572        assert_eq!(loaded.get("a"), Some("1"));
1573        assert_eq!(loaded.get("b"), Some("hello world"));
1574    }
1575
1576    #[test]
1577    fn test_metadata_store_merge() {
1578        let mut a = MetadataStore::new();
1579        a.set("x", "1");
1580        let mut b = MetadataStore::new();
1581        b.set("y", "2");
1582        b.set("x", "overwritten");
1583        a.merge(&b);
1584        assert_eq!(a.get("x"), Some("overwritten"));
1585        assert_eq!(a.get("y"), Some("2"));
1586    }
1587
1588    // --- ProvenanceTracker ---
1589
1590    #[test]
1591    fn test_prov_add_computation() {
1592        let mut tracker = ProvenanceTracker::new();
1593        tracker.add_computation("comp1", vec!["data_in".into()], vec!["data_out".into()]);
1594        assert_eq!(tracker.graph.len(), 1);
1595        assert_eq!(tracker.graph[0].type_, ProvenanceType::Computation);
1596    }
1597
1598    #[test]
1599    fn test_prov_trace_lineage_chain() {
1600        let mut tracker = ProvenanceTracker::new();
1601        tracker.add_data_source("raw", vec!["raw".into()]);
1602        tracker.add_computation("step1", vec!["raw".into()], vec!["processed".into()]);
1603        tracker.add_computation("step2", vec!["processed".into()], vec!["result".into()]);
1604        let lineage = tracker.trace_lineage("result");
1605        assert!(lineage.contains(&"result".to_string()));
1606        assert!(lineage.contains(&"step2".to_string()));
1607    }
1608
1609    // --- CheckpointManager ---
1610
1611    #[test]
1612    fn test_checkpoint_save_and_load() {
1613        let path = std::env::temp_dir().join("test_simdb_checkpoints");
1614        let mut mgr = CheckpointManager::new(path.to_str().unwrap_or(""), 5);
1615        mgr.save_checkpoint(0, &[1.0, 2.0, 3.0]);
1616        let data = mgr.load_checkpoint(0).unwrap();
1617        assert_eq!(data, vec![1.0, 2.0, 3.0]);
1618    }
1619
1620    #[test]
1621    fn test_checkpoint_cleanup_old() {
1622        let path = std::env::temp_dir().join("test_simdb_ckpt");
1623        let mut mgr = CheckpointManager::new(path.to_str().unwrap_or(""), 3);
1624        for step in 0..6_usize {
1625            mgr.save_checkpoint(step, &[step as f64]);
1626        }
1627        assert!(mgr.list_checkpoints().len() <= 3);
1628    }
1629
1630    // --- ParameterSweep ---
1631
1632    #[test]
1633    fn test_sweep_cartesian_product() {
1634        let mut sweep = ParameterSweep::new();
1635        sweep.add_param("dt", vec![0.01, 0.1]);
1636        sweep.add_param("Re", vec![100.0, 500.0, 1000.0]);
1637        assert_eq!(sweep.count(), 6);
1638        let product = sweep.cartesian_product();
1639        assert_eq!(product.len(), 6);
1640    }
1641
1642    #[test]
1643    fn test_sweep_latin_hypercube() {
1644        let mut sweep = ParameterSweep::new();
1645        sweep.add_param("x", vec![0.0, 1.0, 2.0, 3.0]);
1646        sweep.add_param("y", vec![10.0, 20.0, 30.0]);
1647        let samples = sweep.latin_hypercube_sample(5);
1648        assert_eq!(samples.len(), 5);
1649    }
1650
1651    // --- QueryBuilder ---
1652
1653    #[test]
1654    fn test_query_builder_param_range() {
1655        let path = std::env::temp_dir().join("test_simdb_qbpr.csv");
1656        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1657        for i in 0..5_usize {
1658            let mut r = SimulationRecord::new(format!("r{i}"), 0);
1659            r.set_param("v", i as f64);
1660            db.add_record(r);
1661        }
1662        let binding = QueryBuilder::from(&db).param_range("v", 1.0, 3.0);
1663        let results = binding.execute();
1664        assert_eq!(results.len(), 3);
1665    }
1666
1667    #[test]
1668    fn test_query_builder_time_range() {
1669        let path = std::env::temp_dir().join("test_simdb_qbtr.csv");
1670        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1671        for i in 0_u64..5 {
1672            db.add_record(SimulationRecord::new(format!("t{i}"), 1000 + i * 100));
1673        }
1674        let binding = QueryBuilder::from(&db).time_range(1100, 1300);
1675        let results = binding.execute();
1676        assert_eq!(results.len(), 3);
1677    }
1678
1679    #[test]
1680    fn test_query_builder_meta_eq() {
1681        let path = std::env::temp_dir().join("test_simdb_qbme.csv");
1682        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1683        let mut r1 = SimulationRecord::new("a", 0);
1684        r1.set_meta("solver", "rk4");
1685        let mut r2 = SimulationRecord::new("b", 0);
1686        r2.set_meta("solver", "euler");
1687        db.add_record(r1);
1688        db.add_record(r2);
1689        let binding = QueryBuilder::from(&db).meta_eq("solver", "rk4");
1690        let results = binding.execute();
1691        assert_eq!(results.len(), 1);
1692        assert_eq!(results[0].id, "a");
1693    }
1694
1695    #[test]
1696    fn test_query_builder_combined_filters() {
1697        let path = std::env::temp_dir().join("test_simdb_qbcf.csv");
1698        let mut db = SimulationDatabase::new(path.to_str().unwrap_or(""));
1699        for i in 0_u64..6 {
1700            let mut r = SimulationRecord::new(format!("r{i}"), 1000 + i * 100);
1701            r.set_param("Re", (i as f64) * 100.0);
1702            r.set_meta("solver", if i % 2 == 0 { "rk4" } else { "euler" });
1703            db.add_record(r);
1704        }
1705        let binding = QueryBuilder::from(&db)
1706            .param_range("Re", 100.0, 400.0)
1707            .meta_eq("solver", "euler");
1708        let results = binding.execute();
1709        // Re in [100,400]: i=1(100),2(200),3(300),4(400)
1710        // euler: i=1,3,5 → intersection: i=1(100),3(300)
1711        assert_eq!(results.len(), 2);
1712    }
1713}