Skip to main content

lean_ctx/cli/
ocla_cmd.rs

1use anyhow::{Result, anyhow, bail};
2use clap::{ArgMatches, Command};
3
4use crate::core::savings_ledger::event::SavingsEvent;
5use crate::core::{ocla::OclaService, ocla_bus, savings_ledger};
6
7// ── Status (Agent 07) ────────────────────────────────────────────────────────
8
9type TraitEntry = (
10    &'static str,
11    for<'a> fn(&'a crate::core::ocla::OclaRegistry) -> &'a dyn OclaService,
12);
13
14const TRAITS: [TraitEntry; 14] = [
15    ("observation_hook", |r| r.observation_hook.as_ref()),
16    ("usage_sink", |r| r.usage_sink.as_ref()),
17    ("metrics_exporter", |r| r.metrics_exporter.as_ref()),
18    ("savings_ledger", |r| r.savings_ledger.as_ref()),
19    ("intent_classifier", |r| r.intent_classifier.as_ref()),
20    ("outcome_tracker", |r| r.outcome_tracker.as_ref()),
21    ("compression_provider", |r| r.compression_provider.as_ref()),
22    ("response_optimizer", |r| r.response_optimizer.as_ref()),
23    ("model_router", |r| r.model_router.as_ref()),
24    ("efficiency_analyzer", |r| r.efficiency_analyzer.as_ref()),
25    ("config_tuner", |r| r.config_tuner.as_ref()),
26    ("experiment_runner", |r| r.experiment_runner.as_ref()),
27    ("connector_scheduler", |r| r.connector_scheduler.as_ref()),
28    ("agent_gateway", |r| r.agent_gateway.as_ref()),
29];
30
31pub fn register(app: Command) -> Command {
32    app.subcommand(
33        Command::new("ocla")
34            .about("Inspect Open Context & Token Lifecycle Architecture state")
35            .subcommand(Command::new("status").about("Show OCLA status and ledger coverage"))
36            .subcommand(
37                Command::new("reconcile")
38                    .about("Reconcile legacy and unified savings ledgers")
39                    .arg(
40                        clap::Arg::new("json")
41                            .long("json")
42                            .action(clap::ArgAction::SetTrue),
43                    ),
44            )
45            .subcommand(
46                Command::new("ledger")
47                    .about("Inspect the savings ledger")
48                    .subcommand(Command::new("summary").about("Per-mechanism breakdown"))
49                    .subcommand(Command::new("verify").about("Verify hash chain integrity"))
50                    .subcommand(
51                        Command::new("query")
52                            .about("List events by mechanism")
53                            .arg(clap::Arg::new("mechanism").long("mechanism").required(true))
54                            .arg(
55                                clap::Arg::new("limit")
56                                    .long("limit")
57                                    .default_value("10")
58                                    .value_parser(clap::value_parser!(usize)),
59                            ),
60                    )
61                    .subcommand(
62                        Command::new("p5-coverage").about("Show P5 field population stats"),
63                    ),
64            ),
65    )
66}
67
68pub fn handle(matches: &ArgMatches) -> Result<()> {
69    match matches.subcommand() {
70        Some(("ocla", nested)) => return handle(nested),
71        Some(("status", _)) | None => print_status(),
72        Some(("reconcile", nested)) => return handle_reconcile(nested),
73        Some(("ledger", nested)) => return handle_ledger(nested),
74        Some((name, _)) => bail!("unknown ocla subcommand: {name}"),
75    }
76    Ok(())
77}
78
79fn handle_reconcile(matches: &ArgMatches) -> Result<()> {
80    let ledger = crate::core::ocla::unified_ledger::FileUnifiedLedger::from_data_dir()
81        .map_err(|error| anyhow!(error.to_string()))?;
82    let report = ledger
83        .reconcile()
84        .map_err(|error| anyhow!(error.to_string()))?;
85
86    if matches.get_flag("json") {
87        println!("{}", serde_json::to_string_pretty(&report)?);
88    } else {
89        println!("Reconciliation report");
90        println!("  Metric               Value");
91        println!("  -------------------  -----");
92        println!("  Matched              {}", report.matched);
93        println!("  Unmatched legacy     {}", report.unmatched_legacy);
94        println!("  Unmatched unified    {}", report.unmatched_unified);
95        println!("  Token drift          {}", report.token_drift);
96        println!("  Double-bookings      {}", report.double_bookings.len());
97        for hash in &report.double_bookings {
98            println!("    {hash}");
99        }
100    }
101
102    Ok(())
103}
104
105fn print_status() {
106    let registry = crate::core::ocla::OclaRegistry::global();
107    println!("OCLA traits:");
108    for (name, service) in TRAITS {
109        let capability = service(registry).capability();
110        println!("  {name}: builtin ({:?})", capability.status);
111    }
112
113    println!(
114        "OclaBus: {} (total events emitted: {})",
115        if ocla_bus::is_enabled() {
116            "enabled"
117        } else {
118            "disabled"
119        },
120        ocla_bus::total_emitted()
121    );
122
123    let path = savings_ledger::store::default_path();
124    let summary = path
125        .as_deref()
126        .map(savings_ledger::store::summarize)
127        .unwrap_or_default();
128    println!(
129        "Ledger: total events={}, saved tokens={}, saved USD={:.6}",
130        summary.total_events, summary.saved_tokens, summary.saved_usd
131    );
132
133    let events = path
134        .as_deref()
135        .map(savings_ledger::store::load)
136        .unwrap_or_default();
137    println!("P5 field coverage (with / without):");
138    println!(
139        "  measurement_method: {} / {}",
140        events
141            .iter()
142            .filter(|e| e.measurement_method.is_some())
143            .count(),
144        events
145            .iter()
146            .filter(|e| e.measurement_method.is_none())
147            .count()
148    );
149    println!(
150        "  evidence_class: {} / {}",
151        events.iter().filter(|e| e.evidence_class.is_some()).count(),
152        events.iter().filter(|e| e.evidence_class.is_none()).count()
153    );
154    println!(
155        "  attribution_id: {} / {}",
156        events.iter().filter(|e| e.attribution_id.is_some()).count(),
157        events.iter().filter(|e| e.attribution_id.is_none()).count()
158    );
159}
160
161/// Adapter for the existing argument-vector dispatcher.
162pub fn cmd_ocla(args: &[String]) {
163    let mut argv = vec!["ocla".to_string()];
164    argv.extend(args.iter().cloned());
165    let matches = register(Command::new("lean-ctx"))
166        .try_get_matches_from(argv)
167        .unwrap_or_else(|error| error.exit());
168    handle(&matches).unwrap_or_else(|error| {
169        eprintln!("ocla: {error}");
170        std::process::exit(2);
171    });
172}
173
174// ── Ledger (Agent 08) ────────────────────────────────────────────────────────
175
176const MECHANISMS: [&str; 3] = ["compression", "routing", "caching"];
177const P5_FIELDS: [&str; 19] = [
178    "intent_tag",
179    "outcome",
180    "model_original",
181    "model_routed",
182    "routing_savings",
183    "response_original_tokens",
184    "response_delivered_tokens",
185    "agent_chain_id",
186    "chain_depth",
187    "measurement_method",
188    "evidence_class",
189    "confidence",
190    "quality_signal",
191    "attribution_group",
192    "attribution_id",
193    "baseline_ref",
194    "price_version",
195    "customer_approval",
196    "settlement_status",
197];
198
199fn handle_ledger(matches: &ArgMatches) -> Result<()> {
200    let path =
201        savings_ledger::store::default_path().ok_or_else(|| anyhow!("ledger path unavailable"))?;
202    let action = matches.subcommand_name().unwrap_or("summary");
203
204    match action {
205        "summary" => {
206            let summary = savings_ledger::store::summarize(&path);
207            let events = savings_ledger::store::load(&path);
208            println!("Ledger events: {}", summary.total_events);
209            for (mechanism, count, tokens, usd) in mechanism_breakdown(&events, &summary) {
210                println!("{mechanism}: {count} events, {tokens} tokens, ${usd:.6}");
211            }
212        }
213        "verify" => {
214            let result = savings_ledger::store::verify(&path);
215            if result.valid {
216                println!("Ledger valid: {} events", result.total);
217            } else {
218                println!(
219                    "Ledger invalid at event {} ({} events read)",
220                    result.first_invalid_at.unwrap_or(result.total),
221                    result.total
222                );
223            }
224        }
225        "query" => {
226            let mechanism = matches
227                .get_one::<String>("mechanism")
228                .ok_or_else(|| anyhow!("query requires --mechanism <M>"))?;
229            let limit = matches.get_one::<usize>("limit").copied().unwrap_or(10);
230            for event in events_for_mechanism(&savings_ledger::store::load(&path), mechanism, limit)
231            {
232                println!(
233                    "{} {} {} tokens={} usd=${:.6} hash={}",
234                    event.ts,
235                    event.mechanism,
236                    event.tool,
237                    event.saved_tokens,
238                    event.saved_usd,
239                    event.entry_hash
240                );
241            }
242        }
243        "p5-coverage" => {
244            let events = savings_ledger::store::load(&path);
245            let events_with_p5 = events
246                .iter()
247                .filter(|event| p5_presence(event).iter().any(|populated| *populated))
248                .count();
249            println!("P5 coverage: {} events", events.len());
250            println!(
251                "Events with any P5 field: {events_with_p5}/{}",
252                events.len()
253            );
254            for (field, populated) in P5_FIELDS.iter().zip(p5_counts(&events)) {
255                println!("{field}: {populated}/{}", events.len());
256            }
257        }
258        other => return Err(anyhow!("unknown ledger subcommand: {other}")),
259    }
260    Ok(())
261}
262
263fn mechanism_breakdown(
264    events: &[SavingsEvent],
265    summary: &savings_ledger::store::LedgerSummary,
266) -> Vec<(&'static str, usize, u64, f64)> {
267    MECHANISMS
268        .iter()
269        .map(|mechanism| {
270            let count = events
271                .iter()
272                .filter(|event| event.mechanism == *mechanism)
273                .count();
274            let (tokens, usd) = summary
275                .by_mechanism
276                .iter()
277                .find(|row| row.0 == *mechanism)
278                .map_or((0, 0.0), |row| (row.1, row.2));
279            (*mechanism, count, tokens, usd)
280        })
281        .collect()
282}
283
284fn events_for_mechanism<'a>(
285    events: &'a [SavingsEvent],
286    mechanism: &str,
287    limit: usize,
288) -> impl Iterator<Item = &'a SavingsEvent> {
289    events
290        .iter()
291        .filter(move |event| event.mechanism == mechanism)
292        .rev()
293        .take(limit)
294}
295
296fn p5_counts(events: &[SavingsEvent]) -> [usize; 19] {
297    let mut counts = [0; 19];
298    for event in events {
299        let populated = p5_presence(event);
300        for (count, is_populated) in counts.iter_mut().zip(populated) {
301            *count += usize::from(is_populated);
302        }
303    }
304    counts
305}
306
307fn p5_presence(event: &SavingsEvent) -> [bool; 19] {
308    [
309        event.intent_tag.is_some(),
310        event.outcome.is_some(),
311        event.model_original.is_some(),
312        event.model_routed.is_some(),
313        event.routing_savings.is_some(),
314        event.response_original_tokens.is_some(),
315        event.response_delivered_tokens.is_some(),
316        event.agent_chain_id.is_some(),
317        event.chain_depth.is_some(),
318        event.measurement_method.is_some(),
319        event.evidence_class.is_some(),
320        event.confidence.is_some(),
321        event.quality_signal.is_some(),
322        event.attribution_group.is_some(),
323        event.attribution_id.is_some(),
324        event.baseline_ref.is_some(),
325        event.price_version.is_some(),
326        event.customer_approval.is_some(),
327        event.settlement_status.is_some(),
328    ]
329}
330
331// ── Tests ────────────────────────────────────────────────────────────────────
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::core::savings_ledger::event::MECHANISM_COMPRESSION;
337
338    #[test]
339    fn register_accepts_status() {
340        let matches = register(Command::new("lean-ctx"))
341            .try_get_matches_from(["lean-ctx", "ocla", "status"])
342            .expect("status should parse");
343        let (_, ocla) = matches.subcommand().expect("ocla subcommand");
344        assert!(matches!(ocla.subcommand_name(), Some("status")));
345    }
346
347    #[test]
348    fn register_accepts_reconcile_json() {
349        let matches = register(Command::new("lean-ctx"))
350            .try_get_matches_from(["lean-ctx", "ocla", "reconcile", "--json"])
351            .expect("reconcile should parse");
352        let (_, ocla) = matches.subcommand().expect("ocla subcommand");
353        let (_, reconcile) = ocla.subcommand().expect("reconcile subcommand");
354        assert!(reconcile.get_flag("json"));
355    }
356
357    fn event(mechanism: &str, saved_tokens: u64) -> SavingsEvent {
358        serde_json::from_value(serde_json::json!({
359            "ts": "2026-07-20T00:00:00Z",
360            "tool": "ctx_read",
361            "mechanism": mechanism,
362            "model_id": "test",
363            "tokenizer": "o200k_base",
364            "baseline_tokens": saved_tokens + 10,
365            "actual_tokens": 10,
366            "saved_tokens": saved_tokens,
367            "bounce_adjustment": 0,
368            "unit_price_per_m_usd": 1.0,
369            "saved_usd": 0.001,
370            "repo_hash": "repo",
371            "agent_id": "agent",
372            "prev_hash": "genesis",
373            "entry_hash": "hash",
374            "version": "5"
375        }))
376        .expect("valid test event")
377    }
378
379    #[test]
380    fn p5_counts_only_populated_fields() {
381        let mut populated = event(MECHANISM_COMPRESSION, 10);
382        populated.intent_tag = Some("coding".into());
383        populated.confidence = Some(0.9);
384        let counts = p5_counts(&[populated, event("routing", 0)]);
385        assert_eq!(counts[0], 1);
386        assert_eq!(counts[11], 1);
387        assert!(counts.iter().skip(1).take(10).all(|count| *count == 0));
388    }
389
390    #[test]
391    fn query_returns_newest_matching_events_and_honors_limit() {
392        let events = vec![
393            event(MECHANISM_COMPRESSION, 1),
394            event("routing", 2),
395            event(MECHANISM_COMPRESSION, 3),
396        ];
397        let result: Vec<u64> = events_for_mechanism(&events, MECHANISM_COMPRESSION, 1)
398            .map(|event| event.saved_tokens)
399            .collect();
400        assert_eq!(result, vec![3]);
401    }
402}