|
| 1 | +use serde::{Deserialize, Serialize}; |
| 2 | +use std::collections::BTreeMap; |
| 3 | + |
| 4 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 5 | +pub struct RawSnapshotRow { |
| 6 | + pub ledger_sequence: u64, |
| 7 | + pub corridor: String, |
| 8 | + pub source: String, |
| 9 | + pub reliability: f64, |
| 10 | + pub volume: f64, |
| 11 | + pub latency_ms: f64, |
| 12 | +} |
| 13 | + |
| 14 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 15 | +pub struct SnapshotRecord { |
| 16 | + pub corridor: String, |
| 17 | + pub source: String, |
| 18 | + pub avg_reliability: f64, |
| 19 | + pub total_volume: f64, |
| 20 | + pub avg_latency_ms: f64, |
| 21 | +} |
| 22 | + |
| 23 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 24 | +pub struct SnapshotPayload { |
| 25 | + pub model_version: String, |
| 26 | + pub records: Vec<SnapshotRecord>, |
| 27 | +} |
| 28 | + |
| 29 | +#[derive(Debug, thiserror::Error)] |
| 30 | +pub enum SnapshotError { |
| 31 | + #[error("snapshot input is empty")] |
| 32 | + EmptyInput, |
| 33 | + #[error("snapshot serialization failed: {0}")] |
| 34 | + Serialization(#[from] serde_json::Error), |
| 35 | +} |
| 36 | + |
| 37 | +/// Pure, deterministic snapshot generation. |
| 38 | +/// |
| 39 | +/// The function aggregates by corridor using a canonical BTreeMap ordering and |
| 40 | +/// sorts ledger rows before computing averages so the serialized output is |
| 41 | +/// stable across repeated replays of the same historical data. |
| 42 | +pub fn generate_snapshot(rows: &[RawSnapshotRow]) -> Result<Vec<u8>, SnapshotError> { |
| 43 | + if rows.is_empty() { |
| 44 | + return Err(SnapshotError::EmptyInput); |
| 45 | + } |
| 46 | + |
| 47 | + let mut grouped: BTreeMap<String, Vec<&RawSnapshotRow>> = BTreeMap::new(); |
| 48 | + for row in rows { |
| 49 | + grouped.entry(row.corridor.clone()).or_default().push(row); |
| 50 | + } |
| 51 | + |
| 52 | + let mut records = Vec::with_capacity(grouped.len()); |
| 53 | + for (corridor, entries) in grouped { |
| 54 | + let mut entries = entries; |
| 55 | + entries.sort_by_key(|entry| entry.ledger_sequence); |
| 56 | + |
| 57 | + let total_count = entries.len() as f64; |
| 58 | + let avg_reliability = entries.iter().map(|entry| entry.reliability).sum::<f64>() / total_count; |
| 59 | + let total_volume = entries.iter().map(|entry| entry.volume).sum::<f64>(); |
| 60 | + let avg_latency_ms = entries.iter().map(|entry| entry.latency_ms).sum::<f64>() / total_count; |
| 61 | + |
| 62 | + records.push(SnapshotRecord { |
| 63 | + corridor, |
| 64 | + source: entries[0].source.clone(), |
| 65 | + avg_reliability: avg_reliability, |
| 66 | + total_volume, |
| 67 | + avg_latency_ms, |
| 68 | + }); |
| 69 | + } |
| 70 | + |
| 71 | + let payload = SnapshotPayload { |
| 72 | + model_version: crate::snapshot::model_version::PINNED_MODEL_VERSION.to_string(), |
| 73 | + records, |
| 74 | + }; |
| 75 | + |
| 76 | + Ok(serde_json::to_vec(&payload)?) |
| 77 | +} |
0 commit comments