1use std::path::Path;
12
13use kimetsu_core::KimetsuResult;
14use rusqlite::{OptionalExtension, params};
15use serde::Serialize;
16
17#[derive(Debug, Clone)]
22pub struct InsightsOptions {
23 pub last_n_runs: u32,
26 pub since: Option<String>,
29 pub top_n: u32,
32}
33
34impl Default for InsightsOptions {
35 fn default() -> Self {
36 Self {
37 last_n_runs: 50,
38 since: None,
39 top_n: 10,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct InsightsReport {
46 pub retrieval: RetrievalStats,
47 pub citation: CitationStats,
48 pub proposals: ProposalStats,
49 pub usefulness: UsefulnessTrend,
50 pub harvest: HarvestStats,
51 pub corpus: CorpusHealth,
52 pub token_economy: TokenEconomy,
53}
54
55#[derive(Debug, Clone, Serialize)]
57pub struct MemoryRef {
58 pub memory_id: String,
59 pub text_preview: String,
60 pub usefulness_score: f32,
61 pub use_count: u32,
62}
63
64#[derive(Debug, Clone, Serialize)]
67pub struct RetrievalStats {
68 pub served: u64,
70 pub with_hit: u64,
72 pub hit_rate: Option<f64>,
74 pub avg_top_score: Option<f64>,
76}
77
78#[derive(Debug, Clone, Serialize)]
81pub struct CitationStats {
82 pub runs_considered: u32,
84 pub retrieved_total: u64,
87 pub cited_total: u64,
89 pub citation_rate: Option<f64>,
91}
92
93#[derive(Debug, Clone, Serialize)]
95pub struct ProposalStats {
96 pub accepted: u64,
97 pub rejected: u64,
98 pub pending: u64,
99 pub acceptance_rate: Option<f64>,
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct UsefulnessTrend {
106 pub sum_usefulness: f64,
108 pub avg_ratio: Option<f64>,
111 pub window_finished: u64,
113 pub window_failed_nongate: u64,
115 pub window_net: i64,
117}
118
119#[derive(Debug, Clone, Serialize)]
121pub struct HarvestStats {
122 pub created_in_window: u64,
124 pub by_source: Vec<(String, u64)>,
126 pub yield_per_run: Option<f64>,
128}
129
130#[derive(Debug, Clone, Serialize)]
132pub struct CorpusHealth {
133 pub active: u64,
134 pub invalidated: u64,
135 pub by_scope: Vec<(String, u64)>,
136 pub by_kind: Vec<(String, u64)>,
137 pub top_useful: Vec<MemoryRef>,
138 pub prune_candidates: Vec<MemoryRef>,
139 pub open_conflicts: u64,
140 pub pending_proposals: u64,
141 pub invalidations_by_reason: Vec<(String, u64)>,
144 pub regret_flagged_count: u64,
147}
148
149#[derive(Debug, Clone, Serialize)]
151pub struct TokenEconomy {
152 pub avg_injected_tokens: Option<f64>,
155 pub avg_capsules: Option<f64>,
158 pub skip_rate: Option<f64>,
160 pub overhead_ratio: Option<f64>,
171}
172
173pub fn compute_insights(start: &Path, opts: InsightsOptions) -> KimetsuResult<InsightsReport> {
178 let (_paths, _config, conn) = crate::project::load_project(start)?;
179
180 let last_n = if opts.last_n_runs == 0 {
181 50u32
182 } else {
183 opts.last_n_runs
184 };
185 let top_n = if opts.top_n == 0 { 10u32 } else { opts.top_n };
186
187 let window_since: Option<String> = if let Some(ref ts) = opts.since {
190 Some(ts.clone())
191 } else {
192 conn.query_row(
193 "SELECT started_at FROM runs ORDER BY started_at DESC LIMIT 1 OFFSET ?1",
194 params![last_n as i64 - 1],
195 |row| row.get::<_, String>(0),
196 )
197 .optional()?
198 };
199
200 let retrieval = {
210 let served: u64 = match &window_since {
212 Some(ts) => conn.query_row(
213 "SELECT COUNT(*) FROM events \
214 WHERE kind = 'context.served' AND ts >= ?1",
215 params![ts],
216 |row| row.get(0),
217 )?,
218 None => conn.query_row(
219 "SELECT COUNT(*) FROM events WHERE kind = 'context.served'",
220 [],
221 |row| row.get(0),
222 )?,
223 };
224
225 let with_hit: u64 = match &window_since {
229 Some(ts) => conn.query_row(
230 "SELECT COUNT(*) FROM events \
231 WHERE kind = 'context.served' AND ts >= ?1 \
232 AND CAST(COALESCE(json_extract(payload_json,'$.capsule_count'),0) AS INTEGER) >= 1 \
233 AND COALESCE(json_extract(payload_json,'$.skipped'),'false') != 'true' \
234 AND COALESCE(json_extract(payload_json,'$.skipped'),0) != 1",
235 params![ts],
236 |row| row.get(0),
237 )?,
238 None => conn.query_row(
239 "SELECT COUNT(*) FROM events \
240 WHERE kind = 'context.served' \
241 AND CAST(COALESCE(json_extract(payload_json,'$.capsule_count'),0) AS INTEGER) >= 1 \
242 AND COALESCE(json_extract(payload_json,'$.skipped'),'false') != 'true' \
243 AND COALESCE(json_extract(payload_json,'$.skipped'),0) != 1",
244 [],
245 |row| row.get(0),
246 )?,
247 };
248
249 let hit_rate = if served > 0 {
250 Some(with_hit as f64 / served as f64)
251 } else {
252 None
253 };
254
255 let avg_top_score: Option<f64> = match &window_since {
257 Some(ts) => conn
258 .query_row(
259 "SELECT AVG(CAST(json_extract(payload_json,'$.top_score') AS REAL)) \
260 FROM events \
261 WHERE kind = 'context.served' AND ts >= ?1 \
262 AND CAST(COALESCE(json_extract(payload_json,'$.capsule_count'),0) AS INTEGER) >= 1",
263 params![ts],
264 |row| row.get::<_, Option<f64>>(0),
265 )
266 .optional()?
267 .flatten(),
268 None => conn
269 .query_row(
270 "SELECT AVG(CAST(json_extract(payload_json,'$.top_score') AS REAL)) \
271 FROM events \
272 WHERE kind = 'context.served' \
273 AND CAST(COALESCE(json_extract(payload_json,'$.capsule_count'),0) AS INTEGER) >= 1",
274 [],
275 |row| row.get::<_, Option<f64>>(0),
276 )
277 .optional()?
278 .flatten(),
279 };
280
281 RetrievalStats {
282 served,
283 with_hit,
284 hit_rate,
285 avg_top_score,
286 }
287 };
288
289 let proposals = {
293 let mut accepted: u64 = 0;
294 let mut rejected: u64 = 0;
295 let mut pending: u64 = 0;
296 let mut stmt =
297 conn.prepare("SELECT status, COUNT(*) FROM memory_proposals GROUP BY status")?;
298 let rows = stmt.query_map([], |row| {
299 Ok((row.get::<_, String>(0)?, row.get::<_, u64>(1)?))
300 })?;
301 for row in rows {
302 let (status, count) = row?;
303 match status.as_str() {
304 "accepted" => accepted = count,
305 "rejected" => rejected = count,
306 "pending" => pending = count,
307 _ => {}
308 }
309 }
310 let acceptance_rate = if accepted + rejected > 0 {
311 Some(accepted as f64 / (accepted + rejected) as f64)
312 } else {
313 None
314 };
315 ProposalStats {
316 accepted,
317 rejected,
318 pending,
319 acceptance_rate,
320 }
321 };
322
323 let corpus = {
327 let active: u64 = conn.query_row(
332 "SELECT COUNT(*) FROM memories \
333 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
334 [],
335 |row| row.get(0),
336 )?;
337 let invalidated: u64 = conn.query_row(
338 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NOT NULL",
339 [],
340 |row| row.get(0),
341 )?;
342
343 let by_scope: Vec<(String, u64)> = {
345 let mut stmt = conn.prepare(
346 "SELECT scope, COUNT(*) FROM memories \
347 WHERE invalidated_at IS NULL AND superseded_by IS NULL \
348 GROUP BY scope ORDER BY COUNT(*) DESC",
349 )?;
350 let rows = stmt.query_map([], |row| {
351 Ok((row.get::<_, String>(0)?, row.get::<_, u64>(1)?))
352 })?;
353 rows.collect::<Result<Vec<_>, _>>()?
354 };
355
356 let by_kind: Vec<(String, u64)> = {
358 let mut stmt = conn.prepare(
359 "SELECT kind, COUNT(*) FROM memories \
360 WHERE invalidated_at IS NULL AND superseded_by IS NULL \
361 GROUP BY kind ORDER BY COUNT(*) DESC",
362 )?;
363 let rows = stmt.query_map([], |row| {
364 Ok((row.get::<_, String>(0)?, row.get::<_, u64>(1)?))
365 })?;
366 rows.collect::<Result<Vec<_>, _>>()?
367 };
368
369 let top_useful: Vec<MemoryRef> = {
373 let limit = top_n as i64;
374 let mut stmt = conn.prepare(
375 "
376 SELECT memory_id, text, usefulness_score, use_count
377 FROM memories
378 WHERE invalidated_at IS NULL
379 AND superseded_by IS NULL
380 AND use_count >= 1
381 ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
382 LIMIT ?1
383 ",
384 )?;
385 let rows = stmt.query_map(params![limit], |row| {
386 let text: String = row.get(1)?;
387 let preview = text_preview(&text, 120);
388 Ok(MemoryRef {
389 memory_id: row.get(0)?,
390 text_preview: preview,
391 usefulness_score: row.get::<_, f64>(2)? as f32,
392 use_count: row.get(3)?,
393 })
394 })?;
395 rows.collect::<Result<Vec<_>, _>>()?
396 };
397
398 let prune_candidates: Vec<MemoryRef> = {
400 let limit = top_n as i64;
401 let mut stmt = conn.prepare(
402 "
403 SELECT memory_id, text, usefulness_score, use_count
404 FROM memories
405 WHERE invalidated_at IS NULL
406 AND superseded_by IS NULL
407 AND use_count >= 3
408 AND (usefulness_score / CAST(use_count AS REAL)) <= -0.2
409 ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
410 LIMIT ?1
411 ",
412 )?;
413 let rows = stmt.query_map(params![limit], |row| {
414 let text: String = row.get(1)?;
415 let preview = text_preview(&text, 120);
416 Ok(MemoryRef {
417 memory_id: row.get(0)?,
418 text_preview: preview,
419 usefulness_score: row.get::<_, f64>(2)? as f32,
420 use_count: row.get(3)?,
421 })
422 })?;
423 rows.collect::<Result<Vec<_>, _>>()?
424 };
425
426 let open_conflicts: u64 = conn.query_row(
432 "SELECT COUNT(*) FROM memory_conflicts WHERE resolved_at IS NULL",
433 [],
434 |row| row.get(0),
435 )?;
436
437 let pending_proposals: u64 = conn.query_row(
439 "SELECT COUNT(*) FROM memory_proposals WHERE status = 'pending'",
440 [],
441 |row| row.get(0),
442 )?;
443
444 let invalidations_by_reason: Vec<(String, u64)> =
446 crate::lifecycle::invalidations_by_reason(&conn)
447 .unwrap_or_default()
448 .into_iter()
449 .map(|r| (r.reason, r.count))
450 .collect();
451
452 let regret_flag_threshold = 5u64;
456 let regret_flagged_count =
457 crate::lifecycle::regret_flagged_memories(&conn, regret_flag_threshold)
458 .map(|v| v.len() as u64)
459 .unwrap_or(0);
460
461 CorpusHealth {
462 active,
463 invalidated,
464 by_scope,
465 by_kind,
466 top_useful,
467 prune_candidates,
468 open_conflicts,
469 pending_proposals,
470 invalidations_by_reason,
471 regret_flagged_count,
472 }
473 };
474
475 let harvest = {
479 let created_in_window: u64 = match &window_since {
481 Some(ts) => conn.query_row(
482 "SELECT COUNT(*) FROM memories WHERE created_at >= ?1",
483 params![ts],
484 |row| row.get(0),
485 )?,
486 None => conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?,
487 };
488
489 let by_source: Vec<(String, u64)> = {
491 let sql = match &window_since {
492 Some(ts) => {
493 format!(
494 "SELECT COALESCE(json_extract(provenance_snapshot_json,'$.source'),'unknown'), COUNT(*) \
495 FROM memories WHERE created_at >= '{}' \
496 GROUP BY json_extract(provenance_snapshot_json,'$.source') \
497 ORDER BY COUNT(*) DESC",
498 ts.replace('\'', "''")
499 )
500 }
501 None => "SELECT COALESCE(json_extract(provenance_snapshot_json,'$.source'),'unknown'), COUNT(*) \
502 FROM memories \
503 GROUP BY json_extract(provenance_snapshot_json,'$.source') \
504 ORDER BY COUNT(*) DESC"
505 .to_string(),
506 };
507 let mut stmt = conn.prepare(&sql)?;
508 let rows = stmt.query_map([], |row| {
509 Ok((row.get::<_, String>(0)?, row.get::<_, u64>(1)?))
510 })?;
511 rows.collect::<Result<Vec<_>, _>>()?
512 };
513
514 let distinct_runs_in_window: u64 = match &window_since {
516 Some(ts) => conn.query_row(
517 "SELECT COUNT(*) FROM runs WHERE started_at >= ?1",
518 params![ts],
519 |row| row.get(0),
520 )?,
521 None => conn.query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))?,
522 };
523
524 let yield_per_run = if distinct_runs_in_window > 0 {
525 Some(created_in_window as f64 / distinct_runs_in_window as f64)
526 } else {
527 None
528 };
529
530 HarvestStats {
531 created_in_window,
532 by_source,
533 yield_per_run,
534 }
535 };
536
537 let usefulness = {
541 let sum_usefulness: f64 = conn.query_row(
544 "SELECT COALESCE(SUM(usefulness_score), 0.0) FROM memories \
545 WHERE invalidated_at IS NULL AND superseded_by IS NULL",
546 [],
547 |row| row.get(0),
548 )?;
549
550 let avg_ratio: Option<f64> = conn
551 .query_row(
552 "SELECT AVG(usefulness_score / CAST(use_count AS REAL)) \
553 FROM memories \
554 WHERE invalidated_at IS NULL AND superseded_by IS NULL AND use_count > 0",
555 [],
556 |row| row.get::<_, Option<f64>>(0),
557 )
558 .optional()?
559 .flatten();
560
561 let (window_finished, window_failed_nongate): (u64, u64) = match &window_since {
563 Some(ts) => {
564 let finished: u64 = conn.query_row(
565 "SELECT COUNT(*) FROM events \
566 WHERE kind = 'run.finished' AND ts >= ?1",
567 params![ts],
568 |row| row.get(0),
569 )?;
570 let failed_nongate: u64 = conn.query_row(
572 "SELECT COUNT(*) FROM events \
573 WHERE kind = 'run.failed' AND ts >= ?1 \
574 AND COALESCE(json_extract(payload_json,'$.category'),'') != 'Gate'",
575 params![ts],
576 |row| row.get(0),
577 )?;
578 (finished, failed_nongate)
579 }
580 None => {
581 let finished: u64 = conn.query_row(
582 "SELECT COUNT(*) FROM events WHERE kind = 'run.finished'",
583 [],
584 |row| row.get(0),
585 )?;
586 let failed_nongate: u64 = conn.query_row(
587 "SELECT COUNT(*) FROM events \
588 WHERE kind = 'run.failed' \
589 AND COALESCE(json_extract(payload_json,'$.category'),'') != 'Gate'",
590 [],
591 |row| row.get(0),
592 )?;
593 (finished, failed_nongate)
594 }
595 };
596
597 let window_net = window_finished as i64 - window_failed_nongate as i64;
598
599 UsefulnessTrend {
600 sum_usefulness,
601 avg_ratio,
602 window_finished,
603 window_failed_nongate,
604 window_net,
605 }
606 };
607
608 let citation = {
612 let run_ids_with_injection: Vec<String> = match &window_since {
614 Some(ts) => {
615 let mut stmt = conn.prepare(
616 "SELECT DISTINCT run_id FROM events \
617 WHERE kind = 'context.injected' AND ts >= ?1",
618 )?;
619 let rows = stmt.query_map(params![ts], |row| row.get::<_, String>(0))?;
620 rows.collect::<Result<Vec<_>, _>>()?
621 }
622 None => {
623 let mut stmt = conn.prepare(
624 "SELECT DISTINCT run_id FROM events WHERE kind = 'context.injected'",
625 )?;
626 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
627 rows.collect::<Result<Vec<_>, _>>()?
628 }
629 };
630
631 let runs_considered = run_ids_with_injection.len() as u32;
632
633 let mut retrieved_set = std::collections::BTreeSet::new();
635 for run_id in &run_ids_with_injection {
636 let mut stmt = conn.prepare(
637 "SELECT payload_json FROM events \
638 WHERE run_id = ?1 AND kind = 'context.injected'",
639 )?;
640 let rows = stmt.query_map(params![run_id], |row| row.get::<_, String>(0))?;
641 for row in rows {
642 let payload_json = row?;
643 let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
644 if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
645 for id in ids {
646 if let Some(s) = id.as_str() {
647 if !s.is_empty() {
648 retrieved_set.insert(s.to_string());
649 }
650 }
651 }
652 }
653 }
654 }
655 let retrieved_total = retrieved_set.len() as u64;
656
657 let mut cited_set = std::collections::BTreeSet::new();
659 for run_id in &run_ids_with_injection {
660 let mut stmt =
661 conn.prepare("SELECT DISTINCT memory_id FROM memory_citations WHERE run_id = ?1")?;
662 let rows = stmt.query_map(params![run_id], |row| row.get::<_, String>(0))?;
663 for row in rows {
664 cited_set.insert(row?);
665 }
666 }
667 let cited_total = cited_set.len() as u64;
668
669 let citation_rate = if retrieved_total > 0 {
670 Some(cited_total as f64 / retrieved_total as f64)
671 } else {
672 None
673 };
674
675 CitationStats {
676 runs_considered,
677 retrieved_total,
678 cited_total,
679 citation_rate,
680 }
681 };
682
683 let token_economy = {
687 let injected_payloads: Vec<String> = match &window_since {
690 Some(ts) => {
691 let mut stmt = conn.prepare(
692 "SELECT payload_json FROM events \
693 WHERE kind = 'context.injected' AND ts >= ?1",
694 )?;
695 let rows = stmt.query_map(params![ts], |row| row.get::<_, String>(0))?;
696 rows.collect::<Result<Vec<_>, _>>()?
697 }
698 None => {
699 let mut stmt = conn
700 .prepare("SELECT payload_json FROM events WHERE kind = 'context.injected'")?;
701 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
702 rows.collect::<Result<Vec<_>, _>>()?
703 }
704 };
705
706 let mut token_sum: f64 = 0.0;
707 let mut token_count: u64 = 0;
708 let mut capsule_sum: f64 = 0.0;
709 let mut capsule_count: u64 = 0;
710
711 for payload_json in &injected_payloads {
712 let payload: serde_json::Value = serde_json::from_str(payload_json)?;
713 if let Some(t) = payload.get("used_tokens").and_then(|v| v.as_f64()) {
714 token_sum += t;
715 token_count += 1;
716 }
717 if let Some(c) = payload.get("capsule_count").and_then(|v| v.as_f64()) {
718 capsule_sum += c;
719 capsule_count += 1;
720 }
721 }
722
723 let avg_injected_tokens = if token_count > 0 {
724 Some(token_sum / token_count as f64)
725 } else {
726 None
727 };
728
729 let avg_capsules = if capsule_count > 0 {
730 Some(capsule_sum / capsule_count as f64)
731 } else {
732 None
733 };
734
735 let skipped_count: u64 = match &window_since {
738 Some(ts) => conn.query_row(
739 "SELECT COUNT(*) FROM events \
740 WHERE kind = 'context.served' AND ts >= ?1 \
741 AND (json_extract(payload_json,'$.skipped') = 1 \
742 OR json_extract(payload_json,'$.skipped') = 'true')",
743 params![ts],
744 |row| row.get(0),
745 )?,
746 None => conn.query_row(
747 "SELECT COUNT(*) FROM events \
748 WHERE kind = 'context.served' \
749 AND (json_extract(payload_json,'$.skipped') = 1 \
750 OR json_extract(payload_json,'$.skipped') = 'true')",
751 [],
752 |row| row.get(0),
753 )?,
754 };
755 let skip_rate = if retrieval.served > 0 {
756 Some(skipped_count as f64 / retrieval.served as f64)
757 } else {
758 None
759 };
760
761 TokenEconomy {
762 avg_injected_tokens,
763 avg_capsules,
764 skip_rate,
765 overhead_ratio: None,
768 }
769 };
770
771 Ok(InsightsReport {
772 retrieval,
773 citation,
774 proposals,
775 usefulness,
776 harvest,
777 corpus,
778 token_economy,
779 })
780}
781
782fn text_preview(text: &str, max_chars: usize) -> String {
787 let trimmed = text.trim();
788 if trimmed.chars().count() <= max_chars {
789 trimmed.to_string()
790 } else {
791 let head: String = trimmed.chars().take(max_chars).collect();
792 format!("{head}…")
793 }
794}
795
796#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::{
804 project::{
805 AcceptOverrides, accept_proposal, add_memory, init_project, propose_memory,
806 reject_proposal,
807 },
808 projector,
809 user_brain::with_user_brain_disabled,
810 };
811 use kimetsu_core::{
812 event::Event,
813 ids::RunId,
814 memory::{MemoryKind, MemoryScope},
815 };
816 use ulid::Ulid;
817
818 fn test_root() -> std::path::PathBuf {
819 let root = std::env::temp_dir().join(format!("kimetsu-analytics-test-{}", Ulid::new()));
820 kimetsu_core::paths::git_init_boundary(&root);
821 root
822 }
823
824 #[test]
829 fn proposal_stats_acceptance_rate_and_pending() {
830 with_user_brain_disabled(|| {
831 let root = test_root();
832 init_project(&root, false).expect("init");
833
834 let p1 = propose_memory(
836 &root,
837 MemoryScope::Project,
838 MemoryKind::Fact,
839 "alpha fact",
840 0.5,
841 "r1",
842 )
843 .expect("propose 1");
844 let p2 = propose_memory(
845 &root,
846 MemoryScope::Project,
847 MemoryKind::Fact,
848 "beta fact",
849 0.5,
850 "r2",
851 )
852 .expect("propose 2");
853 let p3 = propose_memory(
854 &root,
855 MemoryScope::Project,
856 MemoryKind::Fact,
857 "gamma fact",
858 0.5,
859 "r3",
860 )
861 .expect("propose 3");
862 let _p4 = propose_memory(
863 &root,
864 MemoryScope::Project,
865 MemoryKind::Fact,
866 "delta fact",
867 0.5,
868 "r4",
869 )
870 .expect("propose 4");
871
872 accept_proposal(&root, &p1, AcceptOverrides::default()).expect("accept p1");
873 accept_proposal(&root, &p2, AcceptOverrides::default()).expect("accept p2");
874 reject_proposal(&root, &p3, Some("not useful")).expect("reject p3");
875 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
878 let ps = &report.proposals;
879 assert_eq!(ps.accepted, 2, "accepted count");
880 assert_eq!(ps.rejected, 1, "rejected count");
881 assert_eq!(ps.pending, 1, "pending count");
882 let rate = ps.acceptance_rate.expect("acceptance_rate must be Some");
883 let expected = 2.0 / 3.0;
884 assert!(
885 (rate - expected).abs() < 1e-9,
886 "acceptance_rate expected {expected}, got {rate}"
887 );
888 });
889 }
890
891 #[test]
896 fn corpus_health_counts_active_vs_invalidated() {
897 with_user_brain_disabled(|| {
898 let root = test_root();
899 init_project(&root, false).expect("init");
900
901 let _m1 = add_memory(
902 &root,
903 MemoryScope::Project,
904 MemoryKind::Fact,
905 "active fact one",
906 )
907 .expect("m1");
908 let _m2 = add_memory(
909 &root,
910 MemoryScope::Project,
911 MemoryKind::Command,
912 "active command",
913 )
914 .expect("m2");
915 let m3 = add_memory(
916 &root,
917 MemoryScope::Repo,
918 MemoryKind::Convention,
919 "repo convention",
920 )
921 .expect("m3");
922 crate::project::invalidate_memory(&root, &m3, Some("test")).expect("invalidate");
924
925 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
926 let ch = &report.corpus;
927 assert_eq!(ch.active, 2, "active count");
928 assert_eq!(ch.invalidated, 1, "invalidated count");
929
930 let project_scope = ch.by_scope.iter().find(|(s, _)| s == "project");
932 assert!(project_scope.is_some(), "project scope missing");
933 assert_eq!(project_scope.unwrap().1, 2);
934
935 let fact_kind = ch.by_kind.iter().find(|(k, _)| k == "fact");
937 assert!(fact_kind.is_some(), "fact kind missing");
938 assert_eq!(fact_kind.unwrap().1, 1);
939
940 let _ = &ch.top_useful;
942 });
943 }
944
945 #[test]
950 fn harvest_stats_by_source_and_yield() {
951 with_user_brain_disabled(|| {
952 let root = test_root();
953 init_project(&root, false).expect("init");
954
955 add_memory(
957 &root,
958 MemoryScope::Project,
959 MemoryKind::Fact,
960 "harvest fact A",
961 )
962 .expect("A");
963 add_memory(
964 &root,
965 MemoryScope::Project,
966 MemoryKind::Fact,
967 "harvest fact B",
968 )
969 .expect("B");
970
971 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
972 let hs = &report.harvest;
973 assert!(
974 hs.created_in_window >= 2,
975 "created_in_window must be >= 2; got {}",
976 hs.created_in_window
977 );
978 let manual = hs.by_source.iter().find(|(s, _)| s == "manual_cli");
980 assert!(manual.is_some(), "manual_cli source missing");
981 assert!(manual.unwrap().1 >= 2);
982 assert!(hs.yield_per_run.is_some(), "yield_per_run must be Some");
984 });
985 }
986
987 #[test]
992 fn usefulness_trend_gate_failure_excluded_from_window_net() {
993 with_user_brain_disabled(|| {
994 let root = test_root();
995 init_project(&root, false).expect("init");
996
997 let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
998 let run_id1 = RunId::new();
999 let run_id2 = RunId::new();
1000 let run_id3 = RunId::new();
1001
1002 let started1 = Event::new(
1004 run_id1,
1005 "run.started",
1006 serde_json::json!({"mode":"agent","task":"t","project_id":"test","repo_root":root.to_string_lossy(),"model":null,"platform":"test","kimetsu_version":"0","config_hash":"0"}),
1007 );
1008 let finished1 = Event::new(
1009 run_id1,
1010 "run.finished",
1011 serde_json::json!({"status":"success","total_cost_usd":0,"total_tool_calls":0}),
1012 );
1013 let started2 = Event::new(
1015 run_id2,
1016 "run.started",
1017 serde_json::json!({"mode":"agent","task":"t","project_id":"test","repo_root":root.to_string_lossy(),"model":null,"platform":"test","kimetsu_version":"0","config_hash":"0"}),
1018 );
1019 let failed_gate = Event::new(
1020 run_id2,
1021 "run.failed",
1022 serde_json::json!({"category":"Gate","total_cost_usd":0}),
1023 );
1024 let started3 = Event::new(
1026 run_id3,
1027 "run.started",
1028 serde_json::json!({"mode":"agent","task":"t","project_id":"test","repo_root":root.to_string_lossy(),"model":null,"platform":"test","kimetsu_version":"0","config_hash":"0"}),
1029 );
1030 let failed_impl = Event::new(
1031 run_id3,
1032 "run.failed",
1033 serde_json::json!({"category":"Implementation","total_cost_usd":0}),
1034 );
1035
1036 projector::apply_events(&conn, &[started1, finished1]).expect("apply run1");
1037 projector::apply_events(&conn, &[started2, failed_gate]).expect("apply run2");
1038 projector::apply_events(&conn, &[started3, failed_impl]).expect("apply run3");
1039
1040 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1041 let ut = &report.usefulness;
1042 assert_eq!(ut.window_finished, 1, "window_finished");
1043 assert_eq!(
1044 ut.window_failed_nongate, 1,
1045 "window_failed_nongate (Gate excluded)"
1046 );
1047 assert_eq!(ut.window_net, 0, "window_net = 1 - 1 = 0");
1048 });
1049 }
1050
1051 #[test]
1056 fn citation_stats_rate_correct() {
1057 with_user_brain_disabled(|| {
1058 let root = test_root();
1059 init_project(&root, false).expect("init");
1060
1061 let m1 = add_memory(
1062 &root,
1063 MemoryScope::Project,
1064 MemoryKind::Fact,
1065 "citation fact A",
1066 )
1067 .expect("m1");
1068 let m2 = add_memory(
1069 &root,
1070 MemoryScope::Project,
1071 MemoryKind::Fact,
1072 "citation fact B",
1073 )
1074 .expect("m2");
1075
1076 let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1077 let run_id = RunId::new();
1078
1079 let injected = Event::new(
1081 run_id,
1082 "context.injected",
1083 serde_json::json!({
1084 "stage": "localization",
1085 "memory_ids": [&m1, &m2],
1086 }),
1087 );
1088 let cited = Event::new(
1090 run_id,
1091 "memory.cited",
1092 serde_json::json!({
1093 "memory_id": &m1,
1094 "turn": 1,
1095 }),
1096 );
1097 let finished = Event::new(
1098 run_id,
1099 "run.finished",
1100 serde_json::json!({
1101 "status": "success",
1102 "total_cost_usd": 0,
1103 "total_tool_calls": 0,
1104 }),
1105 );
1106 projector::apply_events(&conn, &[injected, cited, finished]).expect("project");
1107
1108 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1109 let cs = &report.citation;
1110 assert_eq!(cs.retrieved_total, 2, "retrieved_total");
1111 assert_eq!(cs.cited_total, 1, "cited_total");
1112 let rate = cs.citation_rate.expect("citation_rate must be Some");
1113 assert!(
1114 (rate - 0.5).abs() < 1e-9,
1115 "citation_rate expected 0.5, got {rate}"
1116 );
1117 });
1118 }
1119
1120 #[test]
1125 fn token_economy_averages_new_events_and_tolerates_old_events() {
1126 with_user_brain_disabled(|| {
1127 let root = test_root();
1128 init_project(&root, false).expect("init");
1129
1130 let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1131 let run_id1 = RunId::new();
1132 let run_id2 = RunId::new();
1133
1134 let new_event = Event::new(
1136 run_id1,
1137 "context.injected",
1138 serde_json::json!({
1139 "stage": "localization",
1140 "memory_ids": [],
1141 "used_tokens": 400,
1142 "capsule_count": 3,
1143 }),
1144 );
1145 let old_event = Event::new(
1147 run_id2,
1148 "context.injected",
1149 serde_json::json!({
1150 "stage": "localization",
1151 "memory_ids": [],
1152 }),
1153 );
1154
1155 projector::apply_events(&conn, &[new_event]).expect("apply new");
1156 projector::apply_events(&conn, &[old_event]).expect("apply old");
1157
1158 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1159 let te = &report.token_economy;
1160
1161 let avg_tok = te
1162 .avg_injected_tokens
1163 .expect("avg_injected_tokens must be Some (one event has it)");
1164 assert!(
1165 (avg_tok - 400.0).abs() < 1e-6,
1166 "avg_injected_tokens expected 400.0, got {avg_tok}"
1167 );
1168 let avg_cap = te.avg_capsules.expect("avg_capsules must be Some");
1169 assert!(
1170 (avg_cap - 3.0).abs() < 1e-6,
1171 "avg_capsules expected 3.0, got {avg_cap}"
1172 );
1173 assert!(
1175 te.skip_rate.is_none(),
1176 "skip_rate must be None when no context.served events exist"
1177 );
1178 });
1179 }
1180
1181 #[test]
1182 fn token_economy_all_old_events_returns_none() {
1183 with_user_brain_disabled(|| {
1184 let root = test_root();
1185 init_project(&root, false).expect("init");
1186
1187 let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1188 let run_id = RunId::new();
1189
1190 let old_event = Event::new(
1192 run_id,
1193 "context.injected",
1194 serde_json::json!({
1195 "stage": "localization",
1196 "memory_ids": [],
1197 }),
1198 );
1199 projector::apply_events(&conn, &[old_event]).expect("apply");
1200
1201 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1202 let te = &report.token_economy;
1203 assert!(
1204 te.avg_injected_tokens.is_none(),
1205 "must be None when no event has used_tokens"
1206 );
1207 assert!(
1208 te.avg_capsules.is_none(),
1209 "must be None when no event has capsule_count"
1210 );
1211 });
1212 }
1213
1214 fn seed_context_served(
1220 conn: &rusqlite::Connection,
1221 capsule_count: u64,
1222 top_score: f32,
1223 skipped: bool,
1224 ) {
1225 let run_id = RunId::new();
1226 let event = Event::new(
1227 run_id,
1228 "context.served",
1229 serde_json::json!({
1230 "query_hash": "testhash",
1231 "capsule_count": capsule_count,
1232 "top_score": top_score,
1233 "skipped": skipped,
1234 "stage": "localization",
1235 }),
1236 );
1237 projector::apply_events(conn, &[event]).expect("seed context.served");
1238 }
1239
1240 #[test]
1241 fn retrieval_stats_counts_hits_and_misses() {
1242 with_user_brain_disabled(|| {
1243 let root = test_root();
1244 init_project(&root, false).expect("init");
1245
1246 let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1247
1248 seed_context_served(&conn, 3, 0.85, false);
1250 seed_context_served(&conn, 1, 0.60, false);
1251 seed_context_served(&conn, 0, 0.0, true);
1253 seed_context_served(&conn, 0, 0.10, true);
1255
1256 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1257 let rs = &report.retrieval;
1258
1259 assert_eq!(
1260 rs.served, 4,
1261 "served should count all context.served events"
1262 );
1263 assert_eq!(
1264 rs.with_hit, 2,
1265 "with_hit should count events with capsule_count>=1 and skipped=false"
1266 );
1267 let hr = rs.hit_rate.expect("hit_rate must be Some when served>0");
1268 assert!(
1269 (hr - 0.5).abs() < 1e-9,
1270 "hit_rate should be 2/4 = 0.5; got {hr}"
1271 );
1272
1273 let avg = rs
1275 .avg_top_score
1276 .expect("avg_top_score must be Some when hits exist");
1277 assert!(
1278 (avg - 0.725).abs() < 0.001,
1279 "avg_top_score expected ~0.725; got {avg}"
1280 );
1281
1282 let sr = report
1284 .token_economy
1285 .skip_rate
1286 .expect("skip_rate must be Some when served>0");
1287 assert!(
1288 (sr - 0.5).abs() < 1e-9,
1289 "skip_rate should be 2/4 = 0.5; got {sr}"
1290 );
1291 });
1292 }
1293
1294 #[test]
1295 fn retrieval_stats_no_context_served_events_returns_none() {
1296 with_user_brain_disabled(|| {
1297 let root = test_root();
1298 init_project(&root, false).expect("init");
1299
1300 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1302 let rs = &report.retrieval;
1303
1304 assert_eq!(rs.served, 0, "served must be 0 with no events");
1305 assert_eq!(rs.with_hit, 0, "with_hit must be 0 with no events");
1306 assert!(
1307 rs.hit_rate.is_none(),
1308 "hit_rate must be None when served==0"
1309 );
1310 assert!(
1311 rs.avg_top_score.is_none(),
1312 "avg_top_score must be None when no hits"
1313 );
1314 assert!(
1315 report.token_economy.skip_rate.is_none(),
1316 "skip_rate must be None when served==0"
1317 );
1318 });
1319 }
1320
1321 #[test]
1322 fn retrieval_stats_all_hits_skip_rate_zero() {
1323 with_user_brain_disabled(|| {
1324 let root = test_root();
1325 init_project(&root, false).expect("init");
1326
1327 let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1328
1329 seed_context_served(&conn, 2, 0.90, false);
1331 seed_context_served(&conn, 5, 0.75, false);
1332 seed_context_served(&conn, 1, 0.55, false);
1333
1334 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1335 let rs = &report.retrieval;
1336
1337 assert_eq!(rs.served, 3);
1338 assert_eq!(rs.with_hit, 3);
1339 let hr = rs.hit_rate.expect("hit_rate");
1340 assert!(
1341 (hr - 1.0).abs() < 1e-9,
1342 "all hits → hit_rate = 1.0; got {hr}"
1343 );
1344
1345 let sr = report
1346 .token_economy
1347 .skip_rate
1348 .expect("skip_rate must be Some");
1349 assert!(
1350 (sr - 0.0).abs() < 1e-9,
1351 "no skips → skip_rate = 0.0; got {sr}"
1352 );
1353 });
1354 }
1355
1356 #[test]
1357 fn log_telemetry_event_writes_context_served_to_db() {
1358 with_user_brain_disabled(|| {
1359 let root = test_root();
1360 init_project(&root, false).expect("init");
1361
1362 crate::project::log_telemetry_event(
1364 &root,
1365 "context.served",
1366 serde_json::json!({
1367 "query_hash": "abc123",
1368 "capsule_count": 0,
1369 "top_score": 0.0,
1370 "skipped": true,
1371 "stage": "localization",
1372 }),
1373 )
1374 .expect("log_telemetry_event must succeed");
1375
1376 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1377 let rs = &report.retrieval;
1378 assert_eq!(rs.served, 1, "log_telemetry_event event must be counted");
1379 assert_eq!(rs.with_hit, 0, "skipped event is not a hit");
1380 assert!(rs.hit_rate.is_some());
1381 assert!(
1382 (rs.hit_rate.unwrap() - 0.0).abs() < 1e-9,
1383 "0 hits / 1 served = 0.0"
1384 );
1385 });
1386 }
1387
1388 #[test]
1399 fn superseded_memory_excluded_from_active_count() {
1400 with_user_brain_disabled(|| {
1401 let root = test_root();
1402 init_project(&root, false).expect("init");
1403
1404 let _m1 = add_memory(
1406 &root,
1407 MemoryScope::Project,
1408 MemoryKind::Fact,
1409 "active fact stays",
1410 )
1411 .expect("m1");
1412 let m2 = add_memory(
1413 &root,
1414 MemoryScope::Project,
1415 MemoryKind::Fact,
1416 "superseded fact goes",
1417 )
1418 .expect("m2");
1419
1420 let (_paths, _config, conn) = crate::project::load_project(&root).expect("load");
1423 conn.execute(
1424 "UPDATE memories SET superseded_by = ?1 WHERE memory_id = ?2",
1425 rusqlite::params![_m1, m2],
1426 )
1427 .expect("stamp superseded_by");
1428
1429 let report = compute_insights(&root, InsightsOptions::default()).expect("insights");
1430 let ch = &report.corpus;
1431
1432 assert_eq!(
1434 ch.active, 1,
1435 "active count must exclude superseded memories; got {}",
1436 ch.active
1437 );
1438 let project_scope = ch.by_scope.iter().find(|(s, _)| s == "project");
1440 assert_eq!(
1441 project_scope.map(|(_, n)| *n),
1442 Some(1),
1443 "by_scope[project] must be 1 (superseded excluded)"
1444 );
1445 let fact_kind = ch.by_kind.iter().find(|(k, _)| k == "fact");
1447 assert_eq!(
1448 fact_kind.map(|(_, n)| *n),
1449 Some(1),
1450 "by_kind[fact] must be 1 (superseded excluded)"
1451 );
1452 });
1453 }
1454}