1use 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
33pub const EXTRACTION_PREFIX: &str = "analytics:extraction:";
35
36pub const ENRICHED_TAG: &str = "enriched";
38
39pub const DEPTH_TAG_PREFIX: &str = "depth:";
41
42pub const SIGNAL_SOURCE_TAG_PREFIX: &str = "signal-source:";
47
48pub const NEG_EXEMPLAR_TAG: &str = "with-neg-exemplars";
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum ExtractionOutcome {
56 Pending,
58 Confirmed,
60 Tombstoned,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum SignalSource {
70 Ast,
73 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#[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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
127pub struct ExtractionRecord {
128 pub gotcha_key: String,
129 pub depth: Option<EnrichmentDepth>,
132 pub file_path: String,
135 pub created_at: u64,
136 pub outcome: ExtractionOutcome,
137 pub outcome_at: Option<u64>,
139 #[serde(default)]
143 pub config: ExtractionConfig,
144}
145
146impl ExtractionRecord {
147 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
156pub 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#[derive(Debug, Clone, Copy, PartialEq)]
164pub struct TagClassification {
165 pub is_enriched: bool,
166 pub depth: Option<EnrichmentDepth>,
167 pub config: ExtractionConfig,
168}
169
170pub 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
208pub 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
250pub 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 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#[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 pub expired: u64,
298 pub per_tier: PerTierStats,
299 #[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 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 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
335pub 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
354pub 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 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 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()], &["src/foo.rs".into()],
538 )
539 .await
540 .unwrap();
541 assert!(!written);
542 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 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 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 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 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 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 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 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 assert_eq!(stats.per_config.get("llm+neg").unwrap().tombstoned, 1);
769 assert_eq!(stats.per_config.get("llm+no_neg").unwrap().confirmed, 1);
771 }
772}