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