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#[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#[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
51pub 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
73pub 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
85pub 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 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 #[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 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 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 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}