Skip to main content

lean_ctx/core/ocla/
unified_ledger.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, ErrorKind, Seek, SeekFrom, Write};
5use std::path::PathBuf;
6
7use fs2::FileExt;
8
9use super::types::{OclaError, OclaRequestContext, OclaResult};
10use crate::core::savings_ledger::SavingsEvent;
11
12/// Unified P5 savings event combining the legacy chain fields with
13/// cross-capability attribution and analysis metadata.
14#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15pub struct UnifiedSavingsEventV2 {
16    pub tool_name: String,
17    pub mode: String,
18    pub original_tokens: u64,
19    pub compressed_tokens: u64,
20    pub saved_tokens: u64,
21    pub content_hash: String,
22    pub timestamp_epoch_ms: u64,
23    pub prev_hash: String,
24    pub event_hash: String,
25    pub intent: Option<String>,
26    pub outcome: Option<String>,
27    pub routing_decision: Option<String>,
28    pub agent_id: Option<String>,
29    pub efficiency_etpao: Option<u64>,
30    pub attribution_id: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub trace_id: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub request_id: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub session_id: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub quality_ref: Option<String>,
39}
40
41/// Comparison of the legacy and unified savings ledgers.
42#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
43pub struct ReconciliationReport {
44    pub matched: usize,
45    pub unmatched_legacy: usize,
46    pub unmatched_unified: usize,
47    pub token_drift: i64,
48    pub double_bookings: Vec<String>,
49}
50
51/// Formats a reconciliation report for human-readable CLI output.
52pub fn format_reconciliation_report(report: &ReconciliationReport) -> String {
53    let mut out = String::new();
54    out.push_str(&format!("Matched events: {}\n", report.matched));
55    out.push_str(&format!("Unmatched legacy: {}\n", report.unmatched_legacy));
56    out.push_str(&format!(
57        "Unmatched unified: {}\n",
58        report.unmatched_unified
59    ));
60    out.push_str(&format!("Token drift: {}\n", report.token_drift));
61    out.push_str(&format!(
62        "Double bookings: {}\n",
63        report.double_bookings.len()
64    ));
65    if report.token_drift == 0 && report.double_bookings.is_empty() {
66        out.push_str("Status: PASS\n");
67    } else {
68        out.push_str("Status: FAIL\n");
69    }
70    out
71}
72
73/// Unified ledger contract for P5 migration and eventual legacy replacement.
74///
75/// Migration plan:
76/// - Phase 1: introduce this schema alongside the legacy schema (dual-write).
77/// - Phase 2: migrate existing events into unified events.
78/// - Phase 3: deactivate the legacy schema after migration verification.
79pub trait UnifiedLedger: Send + Sync {
80    fn record_unified(&self, event: UnifiedSavingsEventV2) -> OclaResult<String>;
81    fn verify_chain(&self) -> OclaResult<bool>;
82    fn query_by_attribution(&self, id: &str) -> OclaResult<Option<UnifiedSavingsEventV2>>;
83}
84
85/// File-backed implementation used during the P5 dual-write migration.
86/// File-backed implementation of the unified savings ledger.
87pub struct FileUnifiedLedger {
88    path: PathBuf,
89}
90
91impl FileUnifiedLedger {
92    pub(crate) fn from_data_dir() -> OclaResult<Self> {
93        let data_dir = crate::core::data_dir::lean_ctx_data_dir()
94            .map_err(|error| OclaError::InvalidRequest(error.clone()))?;
95        Ok(Self::new(data_dir.join("savings/unified_ledger.jsonl")))
96    }
97
98    pub(crate) fn new(path: PathBuf) -> Self {
99        Self { path }
100    }
101
102    fn io_error(error: impl std::fmt::Display) -> OclaError {
103        OclaError::InvalidRequest(format!("unified ledger I/O failed: {error}"))
104    }
105
106    fn read_events(&self) -> OclaResult<Vec<UnifiedSavingsEventV2>> {
107        let file = match File::open(&self.path) {
108            Ok(file) => file,
109            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
110            Err(error) => return Err(Self::io_error(error)),
111        };
112        file.lock_shared().map_err(Self::io_error)?;
113        let result = BufReader::new(&file)
114            .lines()
115            .map(|line| {
116                let line = line.map_err(Self::io_error)?;
117                serde_json::from_str(&line).map_err(Self::io_error)
118            })
119            .collect();
120        let _ = file.unlock();
121        result
122    }
123
124    fn read_legacy_events(&self) -> Vec<SavingsEvent> {
125        let events_path = self.path.with_file_name("events.jsonl");
126        if events_path.exists() {
127            crate::core::savings_ledger::store::load(&events_path)
128        } else {
129            crate::core::savings_ledger::store::load(&self.path.with_file_name("ledger.jsonl"))
130        }
131    }
132
133    /// Compares legacy and unified entries by hash and reports accounting drift.
134    pub(crate) fn reconcile(&self) -> OclaResult<ReconciliationReport> {
135        let legacy = self.read_legacy_events();
136        let unified = self.read_events()?;
137        let mut counts: BTreeMap<String, (usize, usize)> = BTreeMap::new();
138
139        let legacy_tokens: u64 = legacy.iter().map(|event| event.saved_tokens).sum();
140        for event in &legacy {
141            counts.entry(event.entry_hash.clone()).or_default().0 += 1;
142        }
143
144        let unified_tokens: u64 = unified.iter().map(|event| event.saved_tokens).sum();
145        for event in &unified {
146            counts.entry(event.event_hash.clone()).or_default().1 += 1;
147        }
148
149        let matched = counts
150            .values()
151            .map(|(legacy_count, unified_count)| (*legacy_count).min(*unified_count))
152            .sum();
153        let double_bookings = counts
154            .into_iter()
155            .filter_map(|(hash, (legacy_count, unified_count))| {
156                (legacy_count > 1 || unified_count > 1).then_some(hash)
157            })
158            .collect();
159
160        let token_delta = i128::from(unified_tokens) - i128::from(legacy_tokens);
161        let token_drift = i64::try_from(token_delta).unwrap_or_else(|_| {
162            if token_delta.is_negative() {
163                i64::MIN
164            } else {
165                i64::MAX
166            }
167        });
168
169        Ok(ReconciliationReport {
170            matched,
171            unmatched_legacy: legacy.len() - matched,
172            unmatched_unified: unified.len() - matched,
173            token_drift,
174            double_bookings,
175        })
176    }
177
178    /// Returns unified events associated with the supplied trace identifier.
179    ///
180    /// Consumed by the P5 unified-ledger query surface in E14 phase 3.
181    #[allow(dead_code)]
182    pub(crate) fn query_by_trace(&self, trace_id: &str) -> Vec<UnifiedSavingsEventV2> {
183        self.read_events()
184            .unwrap_or_default()
185            .into_iter()
186            .filter(|event| event.trace_id.as_deref() == Some(trace_id))
187            .collect()
188    }
189
190    /// Strict reconciliation for CI gates: returns an error on drift or double-booking.
191    pub fn reconcile_strict(&self) -> OclaResult<ReconciliationReport> {
192        let report = self.reconcile()?;
193        if report.token_drift != 0 || !report.double_bookings.is_empty() {
194            return Err(OclaError::InvalidRequest(format!(
195                "reconciliation drift: {} tokens, {} double-bookings",
196                report.token_drift,
197                report.double_bookings.len()
198            )));
199        }
200        Ok(report)
201    }
202
203    /// Returns the percentage of legacy events that have a matching unified event.
204    pub fn reconciliation_coverage(&self) -> f64 {
205        let Ok(report) = self.reconcile() else {
206            return 0.0;
207        };
208        let total = report.matched + report.unmatched_legacy;
209        if total == 0 {
210            return 100.0;
211        }
212        (report.matched as f64 / total as f64) * 100.0
213    }
214
215    pub(crate) fn from_savings_event(event: &SavingsEvent) -> OclaResult<UnifiedSavingsEventV2> {
216        let timestamp_epoch_ms = chrono::DateTime::parse_from_rfc3339(&event.ts)
217            .map_err(Self::io_error)?
218            .timestamp_millis();
219        let timestamp_epoch_ms = u64::try_from(timestamp_epoch_ms)
220            .map_err(|error| Self::io_error(format!("invalid event timestamp: {error}")))?;
221
222        Ok(UnifiedSavingsEventV2 {
223            tool_name: event.tool.clone(),
224            mode: event.mechanism.clone(),
225            original_tokens: event.baseline_tokens,
226            compressed_tokens: event.actual_tokens,
227            saved_tokens: event.saved_tokens,
228            content_hash: event.repo_hash.clone(),
229            timestamp_epoch_ms,
230            prev_hash: event.prev_hash.clone(),
231            event_hash: event.entry_hash.clone(),
232            intent: event.intent_tag.clone(),
233            outcome: event.outcome.clone(),
234            routing_decision: event.model_routed.clone(),
235            agent_id: Some(event.agent_id.clone()),
236            efficiency_etpao: None,
237            attribution_id: event
238                .attribution_id
239                .clone()
240                .unwrap_or_else(|| event.repo_hash.clone()),
241            trace_id: OclaRequestContext::current_trace_id(),
242            request_id: OclaRequestContext::current_request_id(),
243            session_id: OclaRequestContext::current_session_id(),
244            quality_ref: None,
245        })
246    }
247}
248
249impl UnifiedLedger for FileUnifiedLedger {
250    fn record_unified(&self, event: UnifiedSavingsEventV2) -> OclaResult<String> {
251        if let Some(parent) = self.path.parent() {
252            fs::create_dir_all(parent).map_err(Self::io_error)?;
253        }
254        let mut file = OpenOptions::new()
255            .create(true)
256            .read(true)
257            .append(true)
258            .open(&self.path)
259            .map_err(Self::io_error)?;
260        file.lock_exclusive().map_err(Self::io_error)?;
261        let result = (|| {
262            file.seek(SeekFrom::Start(0)).map_err(Self::io_error)?;
263            let mut last_hash = None;
264            for line in BufReader::new(&file).lines() {
265                let line = line.map_err(Self::io_error)?;
266                let previous: UnifiedSavingsEventV2 =
267                    serde_json::from_str(&line).map_err(Self::io_error)?;
268                last_hash = Some(previous.event_hash);
269            }
270            if event.prev_hash != last_hash.as_deref().unwrap_or("genesis") {
271                // Self-heal: when the unified ledger is empty or its tip
272                // diverged from the savings chain (file deleted, reset, or
273                // concurrent truncation), re-anchor the incoming event as a
274                // new genesis rather than permanently rejecting all future
275                // writes. The per-session ledger remains the source of truth;
276                // the unified ledger is a best-effort mirror for OCLA.
277                let mut healed = event.clone();
278                healed.prev_hash = last_hash.as_deref().unwrap_or("genesis").to_string();
279                let line = serde_json::to_string(&healed).map_err(Self::io_error)?;
280                file.seek(SeekFrom::End(0)).map_err(Self::io_error)?;
281                writeln!(file, "{line}").map_err(Self::io_error)?;
282                return Ok(healed.event_hash.clone());
283            }
284            let line = serde_json::to_string(&event).map_err(Self::io_error)?;
285            file.seek(SeekFrom::End(0)).map_err(Self::io_error)?;
286            writeln!(file, "{line}").map_err(Self::io_error)?;
287            Ok(event.event_hash.clone())
288        })();
289        let _ = file.unlock();
290        result
291    }
292
293    fn verify_chain(&self) -> OclaResult<bool> {
294        let events = self.read_events()?;
295        Ok(events.iter().enumerate().all(|(index, event)| {
296            event.prev_hash
297                == if index == 0 {
298                    "genesis"
299                } else {
300                    events[index - 1].event_hash.as_str()
301                }
302        }))
303    }
304
305    fn query_by_attribution(&self, id: &str) -> OclaResult<Option<UnifiedSavingsEventV2>> {
306        Ok(self
307            .read_events()?
308            .into_iter()
309            .find(|event| event.attribution_id == id))
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn savings_event() -> SavingsEvent {
318        SavingsEvent {
319            ts: "2026-01-01T00:00:00Z".into(),
320            tool: "ctx_read".into(),
321            mechanism: "compression".into(),
322            model_id: "model".into(),
323            tokenizer: "tokenizer".into(),
324            baseline_tokens: 100,
325            actual_tokens: 40,
326            saved_tokens: 60,
327            bounce_adjustment: 0,
328            unit_price_per_m_usd: 1.0,
329            saved_usd: 0.00006,
330            repo_hash: "repo".into(),
331            agent_id: "agent".into(),
332            prev_hash: "genesis".into(),
333            entry_hash: "event".into(),
334            version: "version".into(),
335            intent_tag: None,
336            outcome: None,
337            model_original: None,
338            model_routed: None,
339            routing_savings: None,
340            response_original_tokens: None,
341            response_delivered_tokens: None,
342            agent_chain_id: None,
343            chain_depth: None,
344            measurement_method: None,
345            evidence_class: None,
346            confidence: None,
347            quality_signal: None,
348            attribution_group: None,
349            attribution_id: None,
350            baseline_ref: None,
351            price_version: None,
352            customer_approval: None,
353            settlement_status: None,
354            is_first_inject: None,
355            cache_read_per_m_usd: None,
356            cache_write_per_m_usd: None,
357        }
358    }
359
360    #[test]
361    fn schema_instantiates_legacy_and_p5_fields() {
362        let event = UnifiedSavingsEventV2 {
363            tool_name: "context_read".into(),
364            mode: "compressed".into(),
365            original_tokens: 1_000,
366            compressed_tokens: 400,
367            saved_tokens: 600,
368            content_hash: "blake3:content".into(),
369            timestamp_epoch_ms: 1_700_000_000_000,
370            prev_hash: "blake3:previous".into(),
371            event_hash: "blake3:event".into(),
372            intent: Some("summarize".into()),
373            outcome: Some("accepted".into()),
374            routing_decision: Some("local".into()),
375            agent_id: Some("agent-test".into()),
376            efficiency_etpao: Some(750),
377            attribution_id: "attribution:test".into(),
378            trace_id: Some("tr-test".into()),
379            request_id: Some("request-test".into()),
380            session_id: Some("session-test".into()),
381            quality_ref: None,
382        };
383
384        assert_eq!(event.saved_tokens, 600);
385        assert_eq!(event.attribution_id, "attribution:test");
386        assert_eq!(event.intent.as_deref(), Some("summarize"));
387    }
388
389    fn request_context() -> OclaRequestContext {
390        OclaRequestContext {
391            request_id: "request".into(),
392            session_id: "session".into(),
393            agent_id: "agent".into(),
394            content_ref: "content".into(),
395            tenant_id: None,
396            trace_id: "tr-request".into(),
397        }
398    }
399
400    #[test]
401    fn request_context_trace_id_reaches_unified_event() {
402        let context = request_context();
403        let unified = context.scope(|| {
404            FileUnifiedLedger::from_savings_event(&savings_event()).expect("legacy event converts")
405        });
406        assert_eq!(unified.trace_id.as_deref(), Some("tr-request"));
407    }
408
409    #[test]
410    fn test_unified_event_carries_request_id() {
411        let context = request_context();
412        let unified = context.scope(|| {
413            FileUnifiedLedger::from_savings_event(&savings_event()).expect("legacy event converts")
414        });
415        assert_eq!(unified.request_id.as_deref(), Some("request"));
416    }
417
418    #[test]
419    fn test_unified_event_carries_session_id() {
420        let context = request_context();
421        let unified = context.scope(|| {
422            FileUnifiedLedger::from_savings_event(&savings_event()).expect("legacy event converts")
423        });
424        assert_eq!(unified.session_id.as_deref(), Some("session"));
425    }
426
427    fn trace_event(trace_id: &str) -> UnifiedSavingsEventV2 {
428        UnifiedSavingsEventV2 {
429            tool_name: "ctx_read".into(),
430            mode: "compression".into(),
431            original_tokens: 100,
432            compressed_tokens: 40,
433            saved_tokens: 60,
434            content_hash: "repo".into(),
435            timestamp_epoch_ms: 1,
436            prev_hash: "genesis".into(),
437            event_hash: "event-1".into(),
438            intent: None,
439            outcome: None,
440            routing_decision: None,
441            agent_id: Some("agent".into()),
442            efficiency_etpao: None,
443            attribution_id: "attr".into(),
444            trace_id: Some(trace_id.into()),
445            request_id: None,
446            session_id: None,
447            quality_ref: None,
448        }
449    }
450
451    #[test]
452    fn test_query_by_trace_returns_matching() {
453        let dir = tempfile::tempdir().expect("temporary directory");
454        let ledger = FileUnifiedLedger::new(dir.path().join("unified.jsonl"));
455        ledger
456            .record_unified(trace_event("trace-match"))
457            .expect("event records");
458
459        assert_eq!(ledger.query_by_trace("trace-match").len(), 1);
460    }
461
462    #[test]
463    fn test_query_by_trace_empty_on_mismatch() {
464        let dir = tempfile::tempdir().expect("temporary directory");
465        let ledger = FileUnifiedLedger::new(dir.path().join("unified.jsonl"));
466        ledger
467            .record_unified(trace_event("trace-match"))
468            .expect("event records");
469
470        assert!(ledger.query_by_trace("trace-missing").is_empty());
471    }
472
473    #[test]
474    fn file_ledger_records_verifies_and_queries_events() {
475        let path = std::env::temp_dir().join(format!(
476            "lean-ctx-unified-ledger-{}.jsonl",
477            std::process::id()
478        ));
479        let _ = fs::remove_file(&path);
480        let ledger = FileUnifiedLedger::new(path.clone());
481        let event = UnifiedSavingsEventV2 {
482            tool_name: "ctx_read".into(),
483            mode: "compression".into(),
484            original_tokens: 100,
485            compressed_tokens: 40,
486            saved_tokens: 60,
487            content_hash: "repo".into(),
488            timestamp_epoch_ms: 1,
489            prev_hash: "genesis".into(),
490            event_hash: "event-1".into(),
491            intent: None,
492            outcome: None,
493            routing_decision: None,
494            agent_id: Some("agent".into()),
495            efficiency_etpao: None,
496            attribution_id: "attr".into(),
497            trace_id: None,
498            request_id: None,
499            session_id: None,
500            quality_ref: None,
501        };
502        assert_eq!(ledger.record_unified(event).unwrap(), "event-1");
503        assert!(ledger.verify_chain().unwrap());
504        assert_eq!(
505            ledger
506                .query_by_attribution("attr")
507                .unwrap()
508                .unwrap()
509                .saved_tokens,
510            60
511        );
512        let _ = fs::remove_file(path);
513    }
514
515    fn legacy_event(saved_tokens: u64) -> SavingsEvent {
516        serde_json::from_value(serde_json::json!({
517            "ts": "2026-06-01T00:00:00+00:00",
518            "tool": "ctx_read",
519            "mechanism": "compression",
520            "model_id": "test-model",
521            "tokenizer": "o200k_base",
522            "baseline_tokens": 100,
523            "actual_tokens": 100 - saved_tokens,
524            "saved_tokens": saved_tokens,
525            "bounce_adjustment": 0,
526            "unit_price_per_m_usd": 2.5,
527            "saved_usd": saved_tokens as f64 * 2.5 / 1_000_000.0,
528            "repo_hash": "repo",
529            "agent_id": "agent",
530            "prev_hash": "",
531            "entry_hash": "",
532            "version": "test"
533        }))
534        .unwrap()
535    }
536
537    #[test]
538    fn reconcile_matches_legacy_and_unified_events() {
539        let dir = tempfile::tempdir().unwrap();
540        let savings = dir.path().join("savings");
541        let legacy_path = savings.join("events.jsonl");
542        let unified_path = savings.join("unified_ledger.jsonl");
543        let legacy =
544            crate::core::savings_ledger::store::append(&legacy_path, legacy_event(60)).unwrap();
545        let ledger = FileUnifiedLedger::new(unified_path);
546        ledger
547            .record_unified(FileUnifiedLedger::from_savings_event(&legacy).unwrap())
548            .unwrap();
549
550        assert_eq!(
551            ledger.reconcile().unwrap(),
552            ReconciliationReport {
553                matched: 1,
554                unmatched_legacy: 0,
555                unmatched_unified: 0,
556                token_drift: 0,
557                double_bookings: Vec::new(),
558            }
559        );
560    }
561
562    #[test]
563    fn reconcile_reports_drift_and_double_bookings() {
564        let dir = tempfile::tempdir().unwrap();
565        let savings = dir.path().join("savings");
566        let legacy_path = savings.join("events.jsonl");
567        let unified_path = savings.join("unified_ledger.jsonl");
568        let legacy =
569            crate::core::savings_ledger::store::append(&legacy_path, legacy_event(60)).unwrap();
570        let ledger = FileUnifiedLedger::new(unified_path);
571        let unified = FileUnifiedLedger::from_savings_event(&legacy).unwrap();
572        ledger.record_unified(unified.clone()).unwrap();
573        let mut duplicate = unified;
574        duplicate.prev_hash = duplicate.event_hash.clone();
575        ledger.record_unified(duplicate).unwrap();
576
577        let report = ledger.reconcile().unwrap();
578        assert_eq!(report.matched, 1);
579        assert_eq!(report.unmatched_legacy, 0);
580        assert_eq!(report.unmatched_unified, 1);
581        assert_eq!(report.token_drift, 60);
582        assert_eq!(report.double_bookings, vec![legacy.entry_hash]);
583    }
584
585    #[test]
586    fn test_reconcile_strict_passes_on_clean_dual_write() {
587        let dir = tempfile::tempdir().unwrap();
588        let savings = dir.path().join("savings");
589        let legacy_path = savings.join("events.jsonl");
590        let unified_path = savings.join("unified_ledger.jsonl");
591        let legacy =
592            crate::core::savings_ledger::store::append(&legacy_path, legacy_event(60)).unwrap();
593        let ledger = FileUnifiedLedger::new(unified_path);
594        ledger
595            .record_unified(FileUnifiedLedger::from_savings_event(&legacy).unwrap())
596            .unwrap();
597
598        assert!(ledger.reconcile_strict().is_ok());
599    }
600
601    #[test]
602    fn test_reconcile_strict_fails_on_drift() {
603        let dir = tempfile::tempdir().unwrap();
604        let savings = dir.path().join("savings");
605        let legacy_path = savings.join("events.jsonl");
606        let unified_path = savings.join("unified_ledger.jsonl");
607        let legacy =
608            crate::core::savings_ledger::store::append(&legacy_path, legacy_event(60)).unwrap();
609        let ledger = FileUnifiedLedger::new(unified_path);
610        let mut unified = FileUnifiedLedger::from_savings_event(&legacy).unwrap();
611        unified.saved_tokens = 59;
612        ledger.record_unified(unified).unwrap();
613
614        assert!(ledger.reconcile_strict().is_err());
615    }
616
617    #[test]
618    fn test_format_reconciliation_report_pass() {
619        let report = ReconciliationReport {
620            matched: 1,
621            unmatched_legacy: 0,
622            unmatched_unified: 0,
623            token_drift: 0,
624            double_bookings: Vec::new(),
625        };
626
627        assert!(format_reconciliation_report(&report).contains("Status: PASS"));
628    }
629
630    #[test]
631    fn test_format_reconciliation_report_fail() {
632        let report = ReconciliationReport {
633            matched: 1,
634            unmatched_legacy: 2,
635            unmatched_unified: 3,
636            token_drift: 4,
637            double_bookings: vec!["event".into()],
638        };
639        let formatted = format_reconciliation_report(&report);
640
641        assert!(formatted.contains("Unmatched legacy: 2"));
642        assert!(formatted.contains("Unmatched unified: 3"));
643        assert!(formatted.contains("Token drift: 4"));
644        assert!(formatted.contains("Double bookings: 1"));
645        assert!(formatted.contains("Status: FAIL"));
646    }
647
648    #[test]
649    fn test_reconciliation_coverage_100_on_match() {
650        let dir = tempfile::tempdir().unwrap();
651        let savings = dir.path().join("savings");
652        let legacy_path = savings.join("events.jsonl");
653        let unified_path = savings.join("unified_ledger.jsonl");
654        let legacy =
655            crate::core::savings_ledger::store::append(&legacy_path, legacy_event(60)).unwrap();
656        let ledger = FileUnifiedLedger::new(unified_path);
657        ledger
658            .record_unified(FileUnifiedLedger::from_savings_event(&legacy).unwrap())
659            .unwrap();
660
661        assert_eq!(ledger.reconciliation_coverage(), 100.0);
662    }
663
664    #[test]
665    fn test_reconciliation_coverage_0_on_empty() {
666        let dir = tempfile::tempdir().unwrap();
667        let ledger = FileUnifiedLedger::new(dir.path().join("savings/unified_ledger.jsonl"));
668
669        assert_eq!(ledger.reconciliation_coverage(), 100.0);
670    }
671}