Skip to main content

mati_core/store/
extraction.rs

1//! Extraction-outcome tracking for `/mati-enrich`'s closed feedback loop
2//! (Proposal D, Phase D3).
3//!
4//! When the slash flow writes a candidate gotcha during enrichment, this
5//! module captures provenance (depth tier, source file, timestamp) into
6//! `analytics:extraction:<gotcha_slug>` with `outcome = Pending`. When the
7//! developer later confirms or tombstones the gotcha,
8//! [`mark_outcome`] flips the outcome and records when. `mati doctor` reads
9//! these records to surface per-tier accuracy ("Deep tier: 14 extractions,
10//! 50% confirmed → worth investigating"), the metric that lets us prove
11//! the adaptive triage is doing real work.
12//!
13//! Detection rule: a gotcha write is treated as an extraction iff its
14//! record tags contain `"enriched"`. Optional `"depth:<tier>"` tag carries
15//! the tier the agent extracted at. Both come from the D2-γ prompt updates.
16//! Records without `"enriched"` (manual `mati gotcha add`, MCP `mem_set`
17//! without enrichment context) are NOT tracked — keeps the analytics
18//! clean to the enrichment pipeline.
19//!
20//! Reference: `ENRICH_QUALITY.md` Section 8 (Feedback loop).
21
22use anyhow::Result;
23use serde::{Deserialize, Serialize};
24
25use super::record::{
26    Category, ConfidenceScore, Priority, QualityScore, Record, RecordLifecycle, RecordSource,
27    RecordVersion, StalenessScore,
28};
29use super::session::now_secs;
30use super::Store;
31use crate::health::enrichment::EnrichmentDepth;
32
33/// Key prefix for extraction tracking records.
34pub const EXTRACTION_PREFIX: &str = "analytics:extraction:";
35
36/// Tag that signals "this gotcha was written by `/mati-enrich`".
37pub const ENRICHED_TAG: &str = "enriched";
38
39/// Tag-prefix that carries the depth tier (e.g. `"depth:deep"`).
40pub const DEPTH_TAG_PREFIX: &str = "depth:";
41
42/// Tag-prefix carrying the channel that first surfaced this candidate's
43/// line: `"signal-source:ast"` (an `extract-signals` seed) or
44/// `"signal-source:llm"` (found only by the Stage 2 file scan). Absent on
45/// older records — treated as `Llm` for compatibility.
46pub const SIGNAL_SOURCE_TAG_PREFIX: &str = "signal-source:";
47
48/// Tag flag set when the Deep-tier prompt actually included negative
49/// exemplars in Stage 2. Present = true; absent = false.
50pub const NEG_EXEMPLAR_TAG: &str = "with-neg-exemplars";
51
52/// Lifecycle outcome for an enrichment-produced candidate.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum ExtractionOutcome {
56    /// Written but not yet confirmed or tombstoned.
57    Pending,
58    /// Developer confirmed via `mati gotcha confirm` (or MCP equivalent).
59    Confirmed,
60    /// Developer tombstoned via `mati gotcha delete` (or MCP equivalent).
61    Tombstoned,
62}
63
64/// Which channel first surfaced a candidate's line. Attribution for
65/// tuning the extractor, not a controlled comparison: Stage 2 sees the
66/// AST seeds while scanning, so `Llm` is not an uncontaminated arm.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum SignalSource {
70    /// Tree-sitter-driven signal extraction via `mati extract-signals`
71    /// (SOTA-α/β path).
72    Ast,
73    /// LLM-driven file scanning (D2-γ / pre-SOTA path).
74    Llm,
75}
76
77impl SignalSource {
78    pub fn as_str(self) -> &'static str {
79        match self {
80            SignalSource::Ast => "ast",
81            SignalSource::Llm => "llm",
82        }
83    }
84}
85
86/// Per-extraction configuration parsed from gotcha tags. Powers the
87/// per-config breakdown in `mati doctor`'s extraction-quality section.
88///
89/// Backward-compat defaults: `signal_source = Llm`, `with_negative_exemplars
90/// = false` — matches pre-SOTA behavior so older records bucket sensibly.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ExtractionConfig {
93    pub signal_source: SignalSource,
94    pub with_negative_exemplars: bool,
95}
96
97impl Default for ExtractionConfig {
98    fn default() -> Self {
99        Self {
100            signal_source: SignalSource::Llm,
101            with_negative_exemplars: false,
102        }
103    }
104}
105
106impl ExtractionConfig {
107    /// Stable label for grouping (`"llm+no_neg"`, `"ast+neg"`, etc.).
108    /// Used as the HashMap key in PerConfigStats so reports are
109    /// reproducible across runs.
110    pub fn label(&self) -> String {
111        format!(
112            "{}+{}",
113            self.signal_source.as_str(),
114            if self.with_negative_exemplars {
115                "neg"
116            } else {
117                "no_neg"
118            }
119        )
120    }
121}
122
123/// Per-extraction provenance + outcome. One record per enrichment-produced
124/// gotcha, keyed by `analytics:extraction:<slug>` (slug = the part after
125/// `gotcha:`).
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
127pub struct ExtractionRecord {
128    pub gotcha_key: String,
129    /// Depth tier the agent used during extraction. `None` when the agent
130    /// didn't tag a depth (e.g. older pre-D2 prompt, or a third-party flow).
131    pub depth: Option<EnrichmentDepth>,
132    /// First affected file (used for directory-scoped aggregation in
133    /// `mati doctor`). Empty when the gotcha had no affected_files.
134    pub file_path: String,
135    pub created_at: u64,
136    pub outcome: ExtractionOutcome,
137    /// Unix secs when outcome transitioned from Pending. `None` while Pending.
138    pub outcome_at: Option<u64>,
139    /// SOTA-γ: which pipeline configuration produced this candidate.
140    /// `Default::default()` (= llm + no_neg) for backward compat with
141    /// records written before this field was added.
142    #[serde(default)]
143    pub config: ExtractionConfig,
144}
145
146impl ExtractionRecord {
147    /// Days between creation and outcome. `None` while Pending.
148    pub fn days_to_outcome(&self) -> Option<i64> {
149        self.outcome_at.map(|t| {
150            let delta = t.saturating_sub(self.created_at);
151            (delta / 86_400) as i64
152        })
153    }
154}
155
156/// Compute the storage key for a gotcha's extraction record.
157pub fn key_for(gotcha_key: &str) -> String {
158    let slug = gotcha_key.strip_prefix("gotcha:").unwrap_or(gotcha_key);
159    format!("{EXTRACTION_PREFIX}{slug}")
160}
161
162/// Parsed classification of a gotcha's enrichment tags.
163#[derive(Debug, Clone, Copy, PartialEq)]
164pub struct TagClassification {
165    pub is_enriched: bool,
166    pub depth: Option<EnrichmentDepth>,
167    pub config: ExtractionConfig,
168}
169
170/// Inspect a gotcha record's tags and return the full parsed
171/// classification:
172/// - `is_enriched`: true iff the `enriched` tag is present
173/// - `depth`: Some(tier) if a `depth:<tier>` tag is present and valid
174/// - `config`: parsed [`ExtractionConfig`] — defaults to `(Llm, false)`
175///   when the corresponding tags aren't present, preserving pre-SOTA-γ
176///   behavior for records that don't carry the new tags.
177pub fn classify_tags(tags: &[String]) -> TagClassification {
178    let mut is_enriched = false;
179    let mut depth = None;
180    let mut config = ExtractionConfig::default();
181    for tag in tags {
182        if tag == ENRICHED_TAG {
183            is_enriched = true;
184        } else if tag == NEG_EXEMPLAR_TAG {
185            config.with_negative_exemplars = true;
186        } else if let Some(rest) = tag.strip_prefix(DEPTH_TAG_PREFIX) {
187            depth = match rest {
188                "fast" => Some(EnrichmentDepth::Fast),
189                "standard" => Some(EnrichmentDepth::Standard),
190                "deep" => Some(EnrichmentDepth::Deep),
191                _ => None,
192            };
193        } else if let Some(rest) = tag.strip_prefix(SIGNAL_SOURCE_TAG_PREFIX) {
194            config.signal_source = match rest {
195                "ast" => SignalSource::Ast,
196                "llm" => SignalSource::Llm,
197                _ => config.signal_source,
198            };
199        }
200    }
201    TagClassification {
202        is_enriched,
203        depth,
204        config,
205    }
206}
207
208/// Write an ExtractionRecord on gotcha creation (only if the `enriched`
209/// tag is present). Best-effort — failure is logged via `tracing::warn`
210/// and does not block the gotcha write.
211///
212/// `affected_files` may be empty; we record `""` in that case so the
213/// record still exists for outcome tracking.
214pub async fn write_on_extraction(
215    store: &Store,
216    gotcha_key: &str,
217    tags: &[String],
218    affected_files: &[String],
219) -> Result<bool> {
220    let TagClassification {
221        is_enriched,
222        depth,
223        config,
224    } = classify_tags(tags);
225    if !is_enriched {
226        return Ok(false);
227    }
228    let file_path = affected_files.first().cloned().unwrap_or_default();
229    let ts = now_secs();
230    let extraction = ExtractionRecord {
231        gotcha_key: gotcha_key.to_string(),
232        depth,
233        file_path,
234        created_at: ts,
235        outcome: ExtractionOutcome::Pending,
236        outcome_at: None,
237        config,
238    };
239    let key = key_for(gotcha_key);
240    let record = analytics_record(&key, &extraction, ts);
241    match store.put(&key, &record).await {
242        Ok(()) => Ok(true),
243        Err(e) => {
244            tracing::warn!("extraction: write failed for {gotcha_key}: {e}");
245            Ok(false)
246        }
247    }
248}
249
250/// Mark an existing ExtractionRecord with the given outcome. No-op if the
251/// record doesn't exist (e.g. the gotcha was written by a non-enrichment
252/// path, or by an older binary before D3 shipped).
253///
254/// Best-effort — failure is logged but never propagated.
255pub async fn mark_outcome(
256    store: &Store,
257    gotcha_key: &str,
258    outcome: ExtractionOutcome,
259) -> Result<bool> {
260    let key = key_for(gotcha_key);
261    let Some(existing) = store.get(&key).await? else {
262        return Ok(false);
263    };
264    let Some(payload) = existing.payload.clone() else {
265        return Ok(false);
266    };
267    let Ok(mut extraction) = serde_json::from_value::<ExtractionRecord>(payload) else {
268        tracing::warn!("extraction: payload deserialize failed for {gotcha_key}");
269        return Ok(false);
270    };
271    // Idempotent — if the outcome is already set, only update the timestamp
272    // when the new outcome differs (terminal-state transitions).
273    if extraction.outcome == outcome {
274        return Ok(false);
275    }
276    extraction.outcome = outcome;
277    extraction.outcome_at = Some(now_secs());
278    let record = analytics_record(&key, &extraction, extraction.created_at);
279    match store.put(&key, &record).await {
280        Ok(()) => Ok(true),
281        Err(e) => {
282            tracing::warn!("extraction: outcome write failed for {gotcha_key}: {e}");
283            Ok(false)
284        }
285    }
286}
287
288/// Aggregate counts for `mati doctor`'s extraction-accuracy section.
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290pub struct ExtractionStats {
291    pub total: u64,
292    pub confirmed: u64,
293    pub tombstoned: u64,
294    pub pending: u64,
295    /// Pending records older than 90 days. Computed dynamically; not a
296    /// persisted lifecycle state.
297    pub expired: u64,
298    pub per_tier: PerTierStats,
299    /// SOTA-δ: per-config A/B breakdown. Each entry keyed by
300    /// `ExtractionConfig::label()` (`"ast+neg"`, `"llm+no_neg"`, …).
301    /// Lets reviewers prove the SOTA pipeline produces better-quality
302    /// candidates than the legacy LLM-driven scan.
303    #[serde(default)]
304    pub per_config: std::collections::BTreeMap<String, TierStats>,
305}
306
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct PerTierStats {
309    pub fast: TierStats,
310    pub standard: TierStats,
311    pub deep: TierStats,
312    /// Records whose tags didn't include a `depth:<tier>` entry.
313    pub unknown: TierStats,
314}
315
316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
317pub struct TierStats {
318    pub total: u64,
319    pub confirmed: u64,
320    pub tombstoned: u64,
321    pub pending: u64,
322}
323
324impl TierStats {
325    /// Confirmed rate (0.0–1.0), or `None` when total is 0.
326    pub fn confirmed_rate(&self) -> Option<f64> {
327        if self.total == 0 {
328            None
329        } else {
330            Some(self.confirmed as f64 / self.total as f64)
331        }
332    }
333}
334
335/// Walk all extraction records via direct `Store` and compute aggregate
336/// stats. Convenience wrapper around [`aggregate_stats`] for callers that
337/// hold a `&Store`. Callers using `StoreProxy` should scan_prefix
338/// themselves and call `aggregate_stats` directly.
339///
340/// `since_secs` filters to extractions created at or after the given
341/// unix timestamp. Pass `0` for "all time".
342pub async fn compute_stats(store: &Store, since_secs: u64) -> Result<ExtractionStats> {
343    let records = store
344        .scan_prefix(EXTRACTION_PREFIX)
345        .await
346        .unwrap_or_default();
347    let extractions: Vec<ExtractionRecord> = records
348        .into_iter()
349        .filter_map(|r| r.payload.and_then(|p| serde_json::from_value(p).ok()))
350        .collect();
351    Ok(aggregate_stats(&extractions, since_secs, now_secs()))
352}
353
354/// Pure aggregator — no I/O. Takes a slice of already-deserialized
355/// ExtractionRecord-s and computes the stats.
356///
357/// `since_secs` filters by `created_at`; `now` is the wall clock used to
358/// compute the 90-day expiry cutoff. Splitting I/O from aggregation lets
359/// callers reuse the math from either `&Store` (compute_stats) or
360/// `&StoreProxy` (which has its own scan_prefix path).
361pub fn aggregate_stats(
362    extractions: &[ExtractionRecord],
363    since_secs: u64,
364    now: u64,
365) -> ExtractionStats {
366    let expiry_cutoff = now.saturating_sub(90 * 86_400);
367
368    let mut stats = ExtractionStats::default();
369    for e in extractions {
370        if e.created_at < since_secs {
371            continue;
372        }
373        stats.total += 1;
374        let tier_stats: &mut TierStats = match e.depth {
375            Some(EnrichmentDepth::Fast) => &mut stats.per_tier.fast,
376            Some(EnrichmentDepth::Standard) => &mut stats.per_tier.standard,
377            Some(EnrichmentDepth::Deep) => &mut stats.per_tier.deep,
378            None => &mut stats.per_tier.unknown,
379        };
380        tier_stats.total += 1;
381        // Per-config bucket lookup. Use BTreeMap::entry to lazy-initialize
382        // so missing configs don't appear with 0/0/0 noise.
383        let config_label = e.config.label();
384        let config_stats: &mut TierStats = stats.per_config.entry(config_label).or_default();
385        config_stats.total += 1;
386
387        match e.outcome {
388            ExtractionOutcome::Confirmed => {
389                stats.confirmed += 1;
390                tier_stats.confirmed += 1;
391                config_stats.confirmed += 1;
392            }
393            ExtractionOutcome::Tombstoned => {
394                stats.tombstoned += 1;
395                tier_stats.tombstoned += 1;
396                config_stats.tombstoned += 1;
397            }
398            ExtractionOutcome::Pending => {
399                if e.created_at < expiry_cutoff {
400                    stats.expired += 1;
401                } else {
402                    stats.pending += 1;
403                    tier_stats.pending += 1;
404                    config_stats.pending += 1;
405                }
406            }
407        }
408    }
409    stats
410}
411
412fn analytics_record(key: &str, payload: &ExtractionRecord, created_at: u64) -> Record {
413    let value = format!(
414        "{:?} ({})",
415        payload.outcome,
416        payload.depth.map(|d| d.as_str()).unwrap_or("unknown")
417    );
418    Record {
419        key: key.to_string(),
420        value,
421        payload: serde_json::to_value(payload).ok(),
422        category: Category::Analytics,
423        priority: Priority::Normal,
424        tags: vec![],
425        created_at,
426        updated_at: now_secs(),
427        ref_url: None,
428        staleness: StalenessScore::fresh(),
429        lifecycle: RecordLifecycle::Active,
430        version: RecordVersion {
431            device_id: crate::store::stable_device_id(),
432            logical_clock: 1,
433            wall_clock: now_secs(),
434        },
435        quality: QualityScore::layer0_default(),
436        access_count: 0,
437        last_accessed: 0,
438        source: RecordSource::StaticAnalysis,
439        confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
440        gap_analysis_score: 0.0,
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use tempfile::TempDir;
448
449    async fn fresh_store() -> Store {
450        let dir = TempDir::new().unwrap();
451        let path = Box::leak(Box::new(dir)).path().to_path_buf();
452        Store::open(&path).await.unwrap()
453    }
454
455    #[test]
456    fn classify_tags_detects_enriched_and_depth() {
457        let c = classify_tags(&["enriched".into(), "depth:deep".into()]);
458        assert!(c.is_enriched);
459        assert_eq!(c.depth, Some(EnrichmentDepth::Deep));
460        // No signal-source / neg-exemplars tag → defaults to Llm + false.
461        assert_eq!(c.config.signal_source, SignalSource::Llm);
462        assert!(!c.config.with_negative_exemplars);
463    }
464
465    #[test]
466    fn classify_tags_no_enriched_is_skipped() {
467        let c = classify_tags(&["test".into(), "depth:fast".into()]);
468        assert!(!c.is_enriched);
469        assert_eq!(c.depth, Some(EnrichmentDepth::Fast));
470    }
471
472    #[test]
473    fn classify_tags_unknown_depth_value_yields_none() {
474        let c = classify_tags(&["enriched".into(), "depth:bogus".into()]);
475        assert!(c.is_enriched);
476        assert!(c.depth.is_none());
477    }
478
479    #[test]
480    fn classify_tags_no_depth_tag_yields_none() {
481        let c = classify_tags(&["enriched".into(), "other".into()]);
482        assert!(c.is_enriched);
483        assert!(c.depth.is_none());
484    }
485
486    #[test]
487    fn classify_tags_picks_up_signal_source_ast_and_neg_exemplars() {
488        let c = classify_tags(&[
489            "enriched".into(),
490            "depth:deep".into(),
491            "signal-source:ast".into(),
492            "with-neg-exemplars".into(),
493        ]);
494        assert!(c.is_enriched);
495        assert_eq!(c.depth, Some(EnrichmentDepth::Deep));
496        assert_eq!(c.config.signal_source, SignalSource::Ast);
497        assert!(c.config.with_negative_exemplars);
498    }
499
500    #[test]
501    fn classify_tags_invalid_signal_source_keeps_default() {
502        let c = classify_tags(&["enriched".into(), "signal-source:bogus".into()]);
503        assert_eq!(c.config.signal_source, SignalSource::Llm);
504    }
505
506    #[test]
507    fn extraction_config_label_stable_for_all_combos() {
508        let combos = [
509            (SignalSource::Llm, false, "llm+no_neg"),
510            (SignalSource::Llm, true, "llm+neg"),
511            (SignalSource::Ast, false, "ast+no_neg"),
512            (SignalSource::Ast, true, "ast+neg"),
513        ];
514        for (src, neg, expected) in combos {
515            let cfg = ExtractionConfig {
516                signal_source: src,
517                with_negative_exemplars: neg,
518            };
519            assert_eq!(cfg.label(), expected, "{cfg:?}");
520        }
521    }
522
523    #[test]
524    fn key_for_strips_gotcha_prefix() {
525        assert_eq!(key_for("gotcha:foo"), "analytics:extraction:foo");
526        assert_eq!(key_for("gotcha:foo:bar"), "analytics:extraction:foo:bar");
527        assert_eq!(key_for("foo"), "analytics:extraction:foo");
528    }
529
530    #[tokio::test]
531    async fn write_on_extraction_skips_when_not_enriched() {
532        let store = fresh_store().await;
533        let written = write_on_extraction(
534            &store,
535            "gotcha:manual-add",
536            &["test".into()], // no "enriched"
537            &["src/foo.rs".into()],
538        )
539        .await
540        .unwrap();
541        assert!(!written);
542        // Verify nothing was persisted.
543        assert!(store
544            .get("analytics:extraction:manual-add")
545            .await
546            .unwrap()
547            .is_none());
548    }
549
550    #[tokio::test]
551    async fn write_on_extraction_writes_pending_with_depth() {
552        let store = fresh_store().await;
553        let written = write_on_extraction(
554            &store,
555            "gotcha:r1",
556            &["enriched".into(), "depth:deep".into()],
557            &["src/cli/repair.rs".into()],
558        )
559        .await
560        .unwrap();
561        assert!(written);
562
563        let rec = store
564            .get("analytics:extraction:r1")
565            .await
566            .unwrap()
567            .expect("written");
568        let extraction: ExtractionRecord =
569            serde_json::from_value(rec.payload.expect("payload")).unwrap();
570        assert_eq!(extraction.gotcha_key, "gotcha:r1");
571        assert_eq!(extraction.depth, Some(EnrichmentDepth::Deep));
572        assert_eq!(extraction.file_path, "src/cli/repair.rs");
573        assert_eq!(extraction.outcome, ExtractionOutcome::Pending);
574        assert!(extraction.outcome_at.is_none());
575    }
576
577    #[tokio::test]
578    async fn mark_outcome_flips_pending_to_confirmed() {
579        let store = fresh_store().await;
580        write_on_extraction(
581            &store,
582            "gotcha:r2",
583            &["enriched".into(), "depth:fast".into()],
584            &["src/foo.rs".into()],
585        )
586        .await
587        .unwrap();
588
589        let updated = mark_outcome(&store, "gotcha:r2", ExtractionOutcome::Confirmed)
590            .await
591            .unwrap();
592        assert!(updated);
593
594        let rec = store
595            .get("analytics:extraction:r2")
596            .await
597            .unwrap()
598            .expect("present");
599        let extraction: ExtractionRecord =
600            serde_json::from_value(rec.payload.expect("payload")).unwrap();
601        assert_eq!(extraction.outcome, ExtractionOutcome::Confirmed);
602        assert!(extraction.outcome_at.is_some());
603    }
604
605    #[tokio::test]
606    async fn mark_outcome_is_idempotent() {
607        let store = fresh_store().await;
608        write_on_extraction(
609            &store,
610            "gotcha:r3",
611            &["enriched".into()],
612            &["src/x.rs".into()],
613        )
614        .await
615        .unwrap();
616        mark_outcome(&store, "gotcha:r3", ExtractionOutcome::Tombstoned)
617            .await
618            .unwrap();
619        // Second call with the same outcome → no-op (returns false).
620        let updated = mark_outcome(&store, "gotcha:r3", ExtractionOutcome::Tombstoned)
621            .await
622            .unwrap();
623        assert!(
624            !updated,
625            "second mark_outcome with same outcome must be no-op"
626        );
627    }
628
629    #[tokio::test]
630    async fn mark_outcome_missing_record_returns_false() {
631        let store = fresh_store().await;
632        let updated = mark_outcome(&store, "gotcha:nonexistent", ExtractionOutcome::Confirmed)
633            .await
634            .unwrap();
635        assert!(!updated);
636    }
637
638    #[tokio::test]
639    async fn compute_stats_per_tier_breakdown() {
640        let store = fresh_store().await;
641
642        // Write 4 enrichment records across tiers, then mark outcomes.
643        let cases = [
644            ("gotcha:f1", "fast", ExtractionOutcome::Confirmed),
645            ("gotcha:f2", "fast", ExtractionOutcome::Tombstoned),
646            ("gotcha:s1", "standard", ExtractionOutcome::Confirmed),
647            ("gotcha:d1", "deep", ExtractionOutcome::Confirmed),
648        ];
649        for (gk, depth, outcome) in &cases {
650            write_on_extraction(
651                &store,
652                gk,
653                &["enriched".into(), format!("depth:{depth}")],
654                &["src/x.rs".into()],
655            )
656            .await
657            .unwrap();
658            mark_outcome(&store, gk, *outcome).await.unwrap();
659        }
660
661        let stats = compute_stats(&store, 0).await.unwrap();
662        assert_eq!(stats.total, 4);
663        assert_eq!(stats.confirmed, 3);
664        assert_eq!(stats.tombstoned, 1);
665        assert_eq!(stats.per_tier.fast.total, 2);
666        assert_eq!(stats.per_tier.fast.confirmed, 1);
667        assert_eq!(stats.per_tier.fast.tombstoned, 1);
668        assert_eq!(stats.per_tier.standard.total, 1);
669        assert_eq!(stats.per_tier.standard.confirmed, 1);
670        assert_eq!(stats.per_tier.deep.total, 1);
671        assert_eq!(stats.per_tier.deep.confirmed, 1);
672
673        // Rate calculations.
674        assert_eq!(stats.per_tier.fast.confirmed_rate(), Some(0.5));
675        assert_eq!(stats.per_tier.standard.confirmed_rate(), Some(1.0));
676        assert_eq!(stats.per_tier.unknown.confirmed_rate(), None);
677    }
678
679    #[tokio::test]
680    async fn compute_stats_respects_since_secs() {
681        let store = fresh_store().await;
682        write_on_extraction(
683            &store,
684            "gotcha:r",
685            &["enriched".into()],
686            &["src/x.rs".into()],
687        )
688        .await
689        .unwrap();
690        // since_secs in the future → no records.
691        let stats = compute_stats(&store, u64::MAX).await.unwrap();
692        assert_eq!(stats.total, 0);
693    }
694
695    #[test]
696    fn days_to_outcome_computed_from_timestamps() {
697        let extraction = ExtractionRecord {
698            gotcha_key: "gotcha:t".into(),
699            depth: None,
700            file_path: String::new(),
701            created_at: 1_000_000,
702            outcome: ExtractionOutcome::Confirmed,
703            outcome_at: Some(1_000_000 + 2 * 86_400),
704            config: ExtractionConfig::default(),
705        };
706        assert_eq!(extraction.days_to_outcome(), Some(2));
707
708        let pending = ExtractionRecord {
709            gotcha_key: "gotcha:p".into(),
710            depth: None,
711            file_path: String::new(),
712            created_at: 1_000_000,
713            outcome: ExtractionOutcome::Pending,
714            outcome_at: None,
715            config: ExtractionConfig::default(),
716        };
717        assert_eq!(pending.days_to_outcome(), None);
718    }
719
720    #[tokio::test]
721    async fn per_config_breakdown_aggregates_correctly() {
722        let store = fresh_store().await;
723
724        // Write 4 records across the 4 (signal_source × neg_exemplars)
725        // configs. Three confirmed, one tombstoned — proves per-config
726        // accuracy aggregation works.
727        let cases = [
728            (
729                "gotcha:a",
730                vec!["enriched", "signal-source:ast", "with-neg-exemplars"],
731                ExtractionOutcome::Confirmed,
732            ),
733            (
734                "gotcha:b",
735                vec!["enriched", "signal-source:ast"],
736                ExtractionOutcome::Confirmed,
737            ),
738            (
739                "gotcha:c",
740                vec!["enriched", "signal-source:llm", "with-neg-exemplars"],
741                ExtractionOutcome::Tombstoned,
742            ),
743            ("gotcha:d", vec!["enriched"], ExtractionOutcome::Confirmed),
744        ];
745        for (key, tags, outcome) in &cases {
746            let owned: Vec<String> = tags.iter().map(|s| s.to_string()).collect();
747            write_on_extraction(&store, key, &owned, &["src/x.rs".into()])
748                .await
749                .unwrap();
750            mark_outcome(&store, key, *outcome).await.unwrap();
751        }
752
753        let stats = compute_stats(&store, 0).await.unwrap();
754        assert_eq!(stats.total, 4);
755        assert_eq!(stats.confirmed, 3);
756        assert_eq!(stats.tombstoned, 1);
757
758        // Per-config buckets exist for each combination written.
759        assert_eq!(stats.per_config.get("ast+neg").unwrap().total, 1);
760        assert_eq!(stats.per_config.get("ast+no_neg").unwrap().total, 1);
761        assert_eq!(stats.per_config.get("llm+neg").unwrap().total, 1);
762        assert_eq!(stats.per_config.get("llm+no_neg").unwrap().total, 1);
763
764        // ast+* configs both confirmed.
765        assert_eq!(stats.per_config.get("ast+neg").unwrap().confirmed, 1);
766        assert_eq!(stats.per_config.get("ast+no_neg").unwrap().confirmed, 1);
767        // llm+neg got tombstoned.
768        assert_eq!(stats.per_config.get("llm+neg").unwrap().tombstoned, 1);
769        // llm+no_neg confirmed.
770        assert_eq!(stats.per_config.get("llm+no_neg").unwrap().confirmed, 1);
771    }
772}