Skip to main content

kimetsu_brain/
analytics.rs

1//! C1–C4: read-only proof-of-value analytics.
2//!
3//! `compute_insights` opens the project brain (via `load_project`, which
4//! migrates on the way through) and runs a set of read-only SQL queries to
5//! produce an `InsightsReport`. No writes are performed.
6//!
7//! Metrics left as `None` pending C7 (`context.served` events):
8//!   - `RetrievalStats::hit_rate` / `with_hit` / `avg_top_score`
9//!   - `TokenEconomy::skip_rate`
10
11use std::path::Path;
12
13use kimetsu_core::KimetsuResult;
14use rusqlite::{OptionalExtension, params};
15use serde::Serialize;
16
17// ---------------------------------------------------------------------------
18// Public option / report types
19// ---------------------------------------------------------------------------
20
21#[derive(Debug, Clone)]
22pub struct InsightsOptions {
23    /// Number of most-recent runs to include in the rolling window.
24    /// Default 50 when set to 0.
25    pub last_n_runs: u32,
26    /// ISO-8601 lower bound on `runs.started_at`. When set, overrides
27    /// `last_n_runs`.
28    pub since: Option<String>,
29    /// How many items to include in ranked lists (top_useful,
30    /// prune_candidates). Default 10 when set to 0.
31    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/// Lightweight memory reference for ranked lists.
56#[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/// C1 — retrieval hit-rate. Populated once C7 lands `context.served` events;
65/// all fields are stub values / `None` for now.
66#[derive(Debug, Clone, Serialize)]
67pub struct RetrievalStats {
68    /// Total `context.served` events in the window (C7).
69    pub served: u64,
70    /// Served events whose bundle contained ≥1 capsule (C7).
71    pub with_hit: u64,
72    /// `with_hit / served`; `None` until C7 populates served.
73    pub hit_rate: Option<f64>,
74    /// Average `top_score` across served events (C7).
75    pub avg_top_score: Option<f64>,
76}
77
78/// C4 — citation signal: what fraction of retrieved memories were actually
79/// cited by the model?
80#[derive(Debug, Clone, Serialize)]
81pub struct CitationStats {
82    /// Runs in the window with at least one `context.injected` event.
83    pub runs_considered: u32,
84    /// Distinct memory_ids surfaced across all `context.injected` events in
85    /// those runs (from the `memory_ids` JSON array).
86    pub retrieved_total: u64,
87    /// Distinct memory_ids cited via `memory.cited` events in those runs.
88    pub cited_total: u64,
89    /// `cited_total / retrieved_total`; `None` when retrieved_total == 0.
90    pub citation_rate: Option<f64>,
91}
92
93/// C2 — proposal acceptance funnel.
94#[derive(Debug, Clone, Serialize)]
95pub struct ProposalStats {
96    pub accepted: u64,
97    pub rejected: u64,
98    pub pending: u64,
99    /// `accepted / (accepted + rejected)`; `None` when denom == 0.
100    pub acceptance_rate: Option<f64>,
101}
102
103/// C3 — memory usefulness distribution and run-outcome trend.
104#[derive(Debug, Clone, Serialize)]
105pub struct UsefulnessTrend {
106    /// `SUM(usefulness_score)` across all active memories.
107    pub sum_usefulness: f64,
108    /// `AVG(usefulness_score / use_count)` over active rows with
109    /// `use_count > 0`; `None` when no such rows exist.
110    pub avg_ratio: Option<f64>,
111    /// `run.finished` events in the window.
112    pub window_finished: u64,
113    /// `run.failed` events in the window whose payload `category != "Gate"`.
114    pub window_failed_nongate: u64,
115    /// `window_finished − window_failed_nongate`.
116    pub window_net: i64,
117}
118
119/// C3 — memory harvest yield.
120#[derive(Debug, Clone, Serialize)]
121pub struct HarvestStats {
122    /// Memories whose `created_at` falls inside the window.
123    pub created_in_window: u64,
124    /// Breakdown by `json_extract(provenance_snapshot_json, '$.source')`.
125    pub by_source: Vec<(String, u64)>,
126    /// `created_in_window / distinct_runs_in_window`; `None` when 0 runs.
127    pub yield_per_run: Option<f64>,
128}
129
130/// C2 — corpus health snapshot.
131#[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    /// F3 Story 3.4: invalidations grouped by structured reason.
142    /// Empty when no memories have been invalidated.
143    pub invalidations_by_reason: Vec<(String, u64)>,
144    /// F3 Story 3.2: memories flagged for review due to repeated retrieval regrets.
145    /// Only populated when there are memories above the regret threshold.
146    pub regret_flagged_count: u64,
147}
148
149/// C4 — token economy from `context.injected` events.
150#[derive(Debug, Clone, Serialize)]
151pub struct TokenEconomy {
152    /// Average `used_tokens` across `context.injected` events in the window
153    /// that carry the field. `None` when no event carries it (old-style).
154    pub avg_injected_tokens: Option<f64>,
155    /// Average `capsule_count` across events in the window that carry the
156    /// field. `None` when no event carries it.
157    pub avg_capsules: Option<f64>,
158    /// Skip-rate (skipped / served); `None` until C7 adds `context.served`.
159    pub skip_rate: Option<f64>,
160    /// F3 — overhead ratio: brain-injected tokens ÷ total run tokens, averaged
161    /// over runs in the window that carry both `context.injected` `used_tokens`
162    /// AND `run.finished` `total_prompt_tokens` (or equivalent).
163    ///
164    /// Currently `None` because the pipeline does not yet record
165    /// `total_prompt_tokens` in `run.finished` events. When that field is
166    /// added, this metric will be computed from it. The fixture-level
167    /// guarantee (brain overhead ratio falls on larger tasks) is proven in
168    /// the `f3_overhead_ratio_falls_on_larger_task` unit test in
169    /// `kimetsu_agent::pipeline` using `adaptive_budget` + simulated totals.
170    pub overhead_ratio: Option<f64>,
171}
172
173// ---------------------------------------------------------------------------
174// Entry point
175// ---------------------------------------------------------------------------
176
177pub 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    // Determine the window boundary. When `since` is set use that timestamp;
188    // otherwise derive it from the N most-recent runs by started_at DESC.
189    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    // -----------------------------------------------------------------------
201    // C1/C7 — RetrievalStats from context.served events.
202    //
203    // Hook-emitted events have run_id = all-zero ULID (sentinel "hook"),
204    // so we do NOT filter by run_id-in-window. Instead we filter by the
205    // event's ts column, which is always the real wall-clock time of the
206    // hook call. Pipeline events are also included (their ts falls in the
207    // same window). The window_since bound applies uniformly.
208    // -----------------------------------------------------------------------
209    let retrieval = {
210        // Total context.served events in the window.
211        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        // with_hit: capsule_count >= 1 AND skipped = false (JSON values).
226        // Treat missing/null fields defensively (old-style events fall through
227        // to 0/null → excluded from with_hit, which is correct).
228        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        // avg_top_score over events where capsule_count >= 1 (hits only).
256        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    // -----------------------------------------------------------------------
290    // C2 — ProposalStats
291    // -----------------------------------------------------------------------
292    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    // -----------------------------------------------------------------------
324    // C2 — CorpusHealth
325    // -----------------------------------------------------------------------
326    let corpus = {
327        // active vs invalidated.
328        // "Active" = not invalidated AND not superseded (superseded rows are
329        // retired by consolidation and excluded from retrieval, so including
330        // them in health counts would disagree with what users actually see).
331        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        // by_scope — active only (same superseded_by filter)
344        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        // by_kind — active only
357        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        // top_useful: reuse list_memories_top (it opens its own project conn).
370        // We already have `conn`, so run the same SQL directly to avoid a
371        // second load_project call.
372        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        // prune_candidates: use the same SQL as prune_low_usefulness dry-run.
399        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        // open_conflicts via list_conflicts (counts project + user brain)
427        // We only have the project conn here; call list_conflicts which opens
428        // both. Since list_conflicts needs `start`, we use a count query on
429        // the project conn (user-brain conflicts are rare; analytics is
430        // best-effort here).
431        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        // pending_proposals
438        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        // F3 Story 3.4: invalidations by structured reason.
445        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        // F3 Story 3.2: count regret-flagged memories.
453        // Use the default threshold from config (5); analytics always uses the
454        // default since it reads from DB and doesn't take a lifecycle config arg.
455        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    // -----------------------------------------------------------------------
476    // C3 — HarvestStats
477    // -----------------------------------------------------------------------
478    let harvest = {
479        // Memories created in the window.
480        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        // by_source — group by provenance_snapshot_json $.source
490        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        // distinct runs in window
515        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    // -----------------------------------------------------------------------
538    // C3 — UsefulnessTrend
539    // -----------------------------------------------------------------------
540    let usefulness = {
541        // S4.1: exclude superseded rows from usefulness aggregates so that
542        // the numbers align with what retrieval actually surfaces.
543        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        // window_finished / window_failed_nongate
562        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                // run.failed events whose payload category != 'Gate'
571                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    // -----------------------------------------------------------------------
609    // C4 — CitationStats
610    // -----------------------------------------------------------------------
611    let citation = {
612        // Collect run_ids in window that have at least one context.injected.
613        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        // retrieved_total: distinct memory_ids across all context.injected payloads.
634        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        // cited_total: distinct memory_ids from memory_citations for these runs.
658        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    // -----------------------------------------------------------------------
684    // C4 — TokenEconomy
685    // -----------------------------------------------------------------------
686    let token_economy = {
687        // Collect used_tokens and capsule_count values from context.injected
688        // events in the window that carry those fields.
689        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        // C7: skip_rate = count(context.served where skipped=true) / served.
736        // Reuse the `served` count already computed above.
737        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            // F3: overhead_ratio requires total_prompt_tokens from run.finished
766            // events, which the pipeline does not yet record. See field doc.
767            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
782// ---------------------------------------------------------------------------
783// Helpers
784// ---------------------------------------------------------------------------
785
786fn 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// ---------------------------------------------------------------------------
797// Tests
798// ---------------------------------------------------------------------------
799
800#[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    // -----------------------------------------------------------------------
825    // 1. ProposalStats
826    // -----------------------------------------------------------------------
827
828    #[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            // Seed 2 accepted + 1 rejected + 1 pending.
835            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            // p4 stays pending.
876
877            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    // -----------------------------------------------------------------------
892    // 2. CorpusHealth
893    // -----------------------------------------------------------------------
894
895    #[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            // Invalidate m3.
923            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            // by_scope must include "project" with count 2.
931            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            // by_kind must include "fact" with count 1 (m3 was repo, invalidated).
936            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            // top_useful may be empty (use_count < 1) but must not error.
941            let _ = &ch.top_useful;
942        });
943    }
944
945    // -----------------------------------------------------------------------
946    // 3. HarvestStats
947    // -----------------------------------------------------------------------
948
949    #[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 uses source "manual_cli" in provenance.
956            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            // Both from "manual_cli"
979            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            // yield_per_run must be Some (runs were created by add_memory).
983            assert!(hs.yield_per_run.is_some(), "yield_per_run must be Some");
984        });
985    }
986
987    // -----------------------------------------------------------------------
988    // 4. UsefulnessTrend — Gate-excluded run.failed
989    // -----------------------------------------------------------------------
990
991    #[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            // run.finished
1003            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            // run.failed category=Gate (excluded)
1014            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            // run.failed category=Implementation (non-Gate, counts)
1025            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    // -----------------------------------------------------------------------
1052    // 5. CitationStats
1053    // -----------------------------------------------------------------------
1054
1055    #[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            // context.injected with both memory_ids
1080            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            // memory.cited for m1 only
1089            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    // -----------------------------------------------------------------------
1121    // 6. TokenEconomy — with and without used_tokens/capsule_count
1122    // -----------------------------------------------------------------------
1123
1124    #[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            // New-style event WITH used_tokens + capsule_count
1135            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            // Old-style event WITHOUT those fields — must not crash; excluded from average
1146            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            // No context.served events seeded → served==0 → skip_rate is None.
1174            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            // Only old-style events
1191            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    // -----------------------------------------------------------------------
1215    // 7. C7 — RetrievalStats from context.served events
1216    // -----------------------------------------------------------------------
1217
1218    /// Helper: seed a `context.served` event directly into the DB.
1219    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            // 2 hits (capsule_count >= 1, skipped=false)
1249            seed_context_served(&conn, 3, 0.85, false);
1250            seed_context_served(&conn, 1, 0.60, false);
1251            // 1 miss (capsule_count == 0, skipped=true)
1252            seed_context_served(&conn, 0, 0.0, true);
1253            // 1 explicit skip (skipped=true but some top_score)
1254            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            // avg_top_score over hits only (0.85 + 0.60) / 2 = 0.725
1274            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            // skip_rate: 2 skipped / 4 served = 0.5
1283            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            // No context.served events — old-DB case.
1301            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            // 3 hits, no skips
1330            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            // Write via log_telemetry_event (the helper used by the hook).
1363            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    // -----------------------------------------------------------------------
1389    // S4.1 — superseded memories must be excluded from active counts
1390    // -----------------------------------------------------------------------
1391
1392    /// A memory that has been superseded (its `superseded_by` column is set to
1393    /// a survivor memory_id) is RETIRED by consolidation — retrieval already
1394    /// excludes it.  The analytics `active` count, `by_scope`, `by_kind`, and
1395    /// `UsefulnessTrend` aggregates must agree with retrieval and exclude
1396    /// superseded rows, so the health dashboard shows the same corpus the user
1397    /// actually gets back when they run a query.
1398    #[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            // Add two memories.
1405            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            // Mark m2 as superseded by m1 (simulates what the consolidation
1421            // projector does when it merges two contradicting memories).
1422            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            // Only the non-superseded memory must count as active.
1433            assert_eq!(
1434                ch.active, 1,
1435                "active count must exclude superseded memories; got {}",
1436                ch.active
1437            );
1438            // by_scope must also reflect only 1 active project-scope memory.
1439            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            // by_kind must reflect only 1 active fact.
1446            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}