1use sqlx::SqlitePool;
2use uuid::Uuid;
3
4#[derive(Debug, Clone, Copy)]
5pub struct FixOutcomeInput<'a> {
6 pub rule_id: Option<&'a str>,
7 pub rule_name: &'a str,
8 pub file_path: Option<&'a str>,
9 pub repo_full_name: Option<&'a str>,
10 pub pr_number: Option<i64>,
11 pub diff_signature: Option<&'a str>,
12 pub accepted: bool,
13 pub applied_ok: bool,
14 pub failed_reason: Option<&'a str>,
15}
16
17pub async fn resolve_rule_id_by_name(
42 pool: &SqlitePool,
43 name: &str,
44 as_of: Option<&str>,
45) -> Option<String> {
46 let trimmed = name.trim();
47 if trimmed.is_empty() {
48 return None;
49 }
50
51 let as_of_bind = as_of.unwrap_or("");
56
57 let exact: Result<Option<String>, _> = sqlx::query_scalar(
63 "SELECT id FROM skills \
64 WHERE name = ?1 AND status = 'active' \
65 ORDER BY \
66 CASE WHEN installed_at <= ?2 THEN 0 ELSE 1 END, \
67 confidence_score DESC, \
68 installed_at DESC \
69 LIMIT 1",
70 )
71 .bind(trimmed)
72 .bind(as_of_bind)
73 .fetch_optional(pool)
74 .await;
75 if let Ok(Some(id)) = exact
76 && !id.is_empty()
77 {
78 return Some(id);
79 }
80
81 let prefix: Result<Option<String>, _> = sqlx::query_scalar(
86 "SELECT id FROM skills \
87 WHERE LOWER(name) LIKE LOWER(?1) || '%' AND status = 'active' \
88 ORDER BY \
89 CASE WHEN installed_at <= ?2 THEN 0 ELSE 1 END, \
90 confidence_score DESC, \
91 installed_at DESC \
92 LIMIT 1",
93 )
94 .bind(trimmed)
95 .bind(as_of_bind)
96 .fetch_optional(pool)
97 .await;
98 if let Ok(Some(id)) = prefix
99 && !id.is_empty()
100 {
101 return Some(id);
102 }
103
104 None
105}
106
107#[derive(Debug, Clone, sqlx::FromRow)]
108pub struct FixOutcomeSummary {
109 pub applied: i64,
110 pub failed: i64,
111 pub rejected: i64,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::FromRow)]
121pub struct FixOutcomeSplitSummary {
122 pub accepted_and_applied: i64,
123 pub accepted_but_failed: i64,
124}
125
126#[derive(Debug, Clone, sqlx::FromRow)]
127pub struct FixOutcomeDaily {
128 pub day: String,
129 pub applied: i64,
130 pub failed: i64,
131 pub rejected: i64,
132}
133
134pub async fn record_many(pool: &SqlitePool, inputs: &[FixOutcomeInput<'_>]) -> crate::Result<()> {
135 if inputs.is_empty() {
136 return Ok(());
137 }
138
139 let mut resolved_ids: Vec<Option<String>> = Vec::with_capacity(inputs.len());
150 for input in inputs {
151 let needs_resolve =
152 input.rule_id.is_none_or(|s| s.trim().is_empty()) && !input.rule_name.trim().is_empty();
153 if needs_resolve {
154 resolved_ids.push(resolve_rule_id_by_name(pool, input.rule_name, None).await);
160 } else {
161 resolved_ids.push(None);
162 }
163 }
164
165 let mut tx = pool.begin().await?;
166 for (input, resolved) in inputs.iter().zip(resolved_ids.iter()) {
167 let id = Uuid::new_v4().to_string();
168 let accepted = i64::from(input.accepted);
169 let applied_ok = i64::from(input.applied_ok);
170
171 let rule_id_to_bind: Option<&str> = match resolved.as_deref() {
172 Some(id) => Some(id),
173 None => input.rule_id,
174 };
175
176 sqlx::query(
177 "INSERT INTO fix_outcomes
178 (id, rule_id, rule_name, file_path, repo_full_name, pr_number, diff_signature,
179 accepted, applied_ok, failed_reason)
180 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
181 )
182 .bind(id)
183 .bind(rule_id_to_bind)
184 .bind(input.rule_name)
185 .bind(input.file_path)
186 .bind(input.repo_full_name)
187 .bind(input.pr_number)
188 .bind(input.diff_signature)
189 .bind(accepted)
190 .bind(applied_ok)
191 .bind(input.failed_reason)
192 .execute(&mut *tx)
193 .await?;
194 }
195 tx.commit().await?;
196 Ok(())
197}
198
199pub async fn summary(pool: &SqlitePool, days: i64) -> crate::Result<FixOutcomeSummary> {
200 let days = days.max(1);
201 let window = format!("-{days} days");
202 let row = sqlx::query_as!(
203 FixOutcomeSummary,
204 r#"SELECT
205 COALESCE(SUM(CASE WHEN accepted = 1 AND applied_ok = 1 THEN 1 ELSE 0 END), 0) AS "applied!: i64",
206 COALESCE(SUM(CASE WHEN accepted = 1 AND applied_ok = 0 THEN 1 ELSE 0 END), 0) AS "failed!: i64",
207 COALESCE(SUM(CASE WHEN accepted = 0 THEN 1 ELSE 0 END), 0) AS "rejected!: i64"
208 FROM fix_outcomes
209 WHERE datetime(created_at) >= datetime('now', ?1)"#,
210 window
211 )
212 .fetch_one(pool)
213 .await?;
214 Ok(row)
215}
216
217pub async fn split_summary(pool: &SqlitePool, days: i64) -> crate::Result<FixOutcomeSplitSummary> {
225 let days = days.max(1);
226 let window = format!("-{days} days");
227 let row = sqlx::query_as::<_, FixOutcomeSplitSummary>(
230 r"SELECT
231 COALESCE(SUM(CASE WHEN accepted = 1 AND COALESCE(applied_ok, 1) = 1 THEN 1 ELSE 0 END), 0) AS accepted_and_applied,
232 COALESCE(SUM(CASE WHEN accepted = 1 AND applied_ok = 0 THEN 1 ELSE 0 END), 0) AS accepted_but_failed
233 FROM fix_outcomes
234 WHERE datetime(created_at) >= datetime('now', ?1)",
235 )
236 .bind(window)
237 .fetch_one(pool)
238 .await?;
239 Ok(row)
240}
241
242#[derive(Debug, Clone, sqlx::FromRow)]
247pub struct FixOutcomeReason {
248 pub reason: String,
249 pub count: i64,
250}
251
252pub async fn top_failure_reasons(
253 pool: &SqlitePool,
254 days: i64,
255 limit: i64,
256) -> crate::Result<Vec<FixOutcomeReason>> {
257 let days = days.max(1);
258 let limit = limit.max(1);
259 let window = format!("-{days} days");
260 let rows = sqlx::query_as!(
261 FixOutcomeReason,
262 r#"SELECT failed_reason AS "reason!: String", COUNT(*) AS "count!: i64"
263 FROM fix_outcomes
264 WHERE accepted = 1
265 AND applied_ok = 0
266 AND failed_reason IS NOT NULL
267 AND TRIM(failed_reason) <> ''
268 AND datetime(created_at) >= datetime('now', ?1)
269 GROUP BY failed_reason
270 ORDER BY COUNT(*) DESC, failed_reason ASC
271 LIMIT ?2"#,
272 window,
273 limit
274 )
275 .fetch_all(pool)
276 .await?;
277 Ok(rows)
278}
279
280pub async fn accepted_signature_count(pool: &SqlitePool, days: i64) -> crate::Result<i64> {
281 let days = days.max(1);
282 let window = format!("-{days} days");
283 let count = sqlx::query_scalar!(
284 r#"SELECT COUNT(DISTINCT diff_signature) AS "count!: i64"
285 FROM fix_outcomes
286 WHERE accepted = 1
287 AND applied_ok = 1
288 AND diff_signature IS NOT NULL
289 AND TRIM(diff_signature) <> ''
290 AND datetime(created_at) >= datetime('now', ?1)"#,
291 window,
292 )
293 .fetch_one(pool)
294 .await?;
295 Ok(count)
296}
297
298pub async fn daily(pool: &SqlitePool, days: i64) -> crate::Result<Vec<FixOutcomeDaily>> {
299 let days = days.max(1);
300 let window = format!("-{days} days");
301 let rows = sqlx::query_as!(
302 FixOutcomeDaily,
303 r#"SELECT
304 date(created_at) AS "day!: String",
305 COALESCE(SUM(CASE WHEN accepted = 1 AND applied_ok = 1 THEN 1 ELSE 0 END), 0) AS "applied!: i64",
306 COALESCE(SUM(CASE WHEN accepted = 1 AND applied_ok = 0 THEN 1 ELSE 0 END), 0) AS "failed!: i64",
307 COALESCE(SUM(CASE WHEN accepted = 0 THEN 1 ELSE 0 END), 0) AS "rejected!: i64"
308 FROM fix_outcomes
309 WHERE datetime(created_at) >= datetime('now', ?1)
310 GROUP BY date(created_at)
311 ORDER BY date(created_at) ASC"#,
312 window
313 )
314 .fetch_all(pool)
315 .await?;
316 Ok(rows)
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use sqlx::sqlite::SqlitePoolOptions;
323
324 async fn pool_with_fix_outcomes() -> SqlitePool {
325 let pool = SqlitePoolOptions::new()
326 .max_connections(1)
327 .connect("sqlite::memory:")
328 .await
329 .unwrap();
330 sqlx::query(
331 "CREATE TABLE fix_outcomes (
332 id TEXT PRIMARY KEY NOT NULL,
333 rule_id TEXT,
334 rule_name TEXT NOT NULL,
335 file_path TEXT,
336 repo_full_name TEXT,
337 pr_number INTEGER,
338 diff_signature TEXT,
339 accepted INTEGER NOT NULL,
340 applied_ok INTEGER NOT NULL DEFAULT 0,
341 failed_reason TEXT,
342 created_at TEXT DEFAULT (datetime('now')) NOT NULL
343 )",
344 )
345 .execute(&pool)
346 .await
347 .unwrap();
348 sqlx::query(
352 "CREATE TABLE skills (
353 id TEXT PRIMARY KEY NOT NULL,
354 name TEXT NOT NULL,
355 status TEXT NOT NULL DEFAULT 'active',
356 confidence_score REAL NOT NULL DEFAULT 0.7,
357 installed_at TEXT DEFAULT (datetime('now')) NOT NULL,
358 updated_at TEXT DEFAULT (datetime('now')) NOT NULL
359 )",
360 )
361 .execute(&pool)
362 .await
363 .unwrap();
364 pool
365 }
366
367 async fn seed_skill(pool: &SqlitePool, id: &str, name: &str, updated_at: &str) {
368 sqlx::query(
371 "INSERT INTO skills (id, name, installed_at, updated_at) \
372 VALUES (?1, ?2, ?3, ?3)",
373 )
374 .bind(id)
375 .bind(name)
376 .bind(updated_at)
377 .execute(pool)
378 .await
379 .unwrap();
380 }
381
382 async fn seed_skill_full(
383 pool: &SqlitePool,
384 id: &str,
385 name: &str,
386 status: &str,
387 confidence: f64,
388 installed_at: &str,
389 ) {
390 sqlx::query(
391 "INSERT INTO skills (id, name, status, confidence_score, installed_at, updated_at) \
392 VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
393 )
394 .bind(id)
395 .bind(name)
396 .bind(status)
397 .bind(confidence)
398 .bind(installed_at)
399 .execute(pool)
400 .await
401 .unwrap();
402 }
403
404 #[tokio::test]
405 async fn record_many_persists_local_diff_signature() {
406 let pool = pool_with_fix_outcomes().await;
407 record_many(
408 &pool,
409 &[FixOutcomeInput {
410 rule_id: Some("rule-1"),
411 rule_name: "Rule 1",
412 file_path: Some("src/lib.rs"),
413 repo_full_name: Some("acme/widgets"),
414 pr_number: Some(42),
415 diff_signature: Some("abc123"),
416 accepted: true,
417 applied_ok: true,
418 failed_reason: None,
419 }],
420 )
421 .await
422 .unwrap();
423
424 let stored: Option<String> =
425 sqlx::query_scalar!("SELECT diff_signature FROM fix_outcomes WHERE rule_id = 'rule-1'")
426 .fetch_one(&pool)
427 .await
428 .unwrap();
429 assert_eq!(stored.as_deref(), Some("abc123"));
430
431 let target: (Option<String>, Option<i64>) =
432 sqlx::query_as("SELECT repo_full_name, pr_number FROM fix_outcomes WHERE rule_id = ?1")
433 .bind("rule-1")
434 .fetch_one(&pool)
435 .await
436 .unwrap();
437 assert_eq!(target.0.as_deref(), Some("acme/widgets"));
438 assert_eq!(target.1, Some(42));
439 }
440
441 #[tokio::test]
442 async fn accepted_signature_count_counts_unique_applied_proof_hashes() {
443 let pool = pool_with_fix_outcomes().await;
444 record_many(
445 &pool,
446 &[
447 FixOutcomeInput {
448 rule_id: Some("rule-1"),
449 rule_name: "Rule 1",
450 file_path: Some("src/lib.rs"),
451 repo_full_name: None,
452 pr_number: None,
453 diff_signature: Some("same"),
454 accepted: true,
455 applied_ok: true,
456 failed_reason: None,
457 },
458 FixOutcomeInput {
459 rule_id: Some("rule-2"),
460 rule_name: "Rule 2",
461 file_path: Some("src/lib.rs"),
462 repo_full_name: None,
463 pr_number: None,
464 diff_signature: Some("same"),
465 accepted: true,
466 applied_ok: true,
467 failed_reason: None,
468 },
469 FixOutcomeInput {
470 rule_id: Some("rule-3"),
471 rule_name: "Rule 3",
472 file_path: Some("src/other.rs"),
473 repo_full_name: None,
474 pr_number: None,
475 diff_signature: Some("failed"),
476 accepted: true,
477 applied_ok: false,
478 failed_reason: Some("no patch"),
479 },
480 ],
481 )
482 .await
483 .unwrap();
484
485 assert_eq!(accepted_signature_count(&pool, 30).await.unwrap(), 1);
486 }
487
488 #[tokio::test]
491 async fn record_many_back_fills_rule_id_via_exact_name() {
492 let pool = pool_with_fix_outcomes().await;
493 seed_skill(&pool, "skill-foo", "foo", "2026-05-20").await;
494 seed_skill(&pool, "skill-foo-variant", "foo (variant)", "2026-05-26").await;
495
496 record_many(
497 &pool,
498 &[FixOutcomeInput {
499 rule_id: Some(""),
500 rule_name: "foo",
501 file_path: None,
502 repo_full_name: None,
503 pr_number: None,
504 diff_signature: None,
505 accepted: true,
506 applied_ok: true,
507 failed_reason: None,
508 }],
509 )
510 .await
511 .unwrap();
512
513 let stored: Option<String> =
514 sqlx::query_scalar("SELECT rule_id FROM fix_outcomes WHERE rule_name = 'foo'")
515 .fetch_one(&pool)
516 .await
517 .unwrap();
518 assert_eq!(stored.as_deref(), Some("skill-foo"));
520 }
521
522 #[tokio::test]
525 async fn record_many_leaves_rule_id_empty_when_no_match() {
526 let pool = pool_with_fix_outcomes().await;
527 seed_skill(&pool, "skill-foo", "foo", "2026-05-20").await;
528
529 record_many(
530 &pool,
531 &[FixOutcomeInput {
532 rule_id: None,
533 rule_name: "bar baz",
534 file_path: None,
535 repo_full_name: None,
536 pr_number: None,
537 diff_signature: None,
538 accepted: true,
539 applied_ok: true,
540 failed_reason: None,
541 }],
542 )
543 .await
544 .unwrap();
545
546 let stored: Option<String> =
547 sqlx::query_scalar("SELECT rule_id FROM fix_outcomes WHERE rule_name = 'bar baz'")
548 .fetch_one(&pool)
549 .await
550 .unwrap();
551 assert!(stored.is_none(), "expected NULL rule_id, got {stored:?}");
553 }
554
555 #[tokio::test]
558 async fn resolve_rule_id_by_name_prefix_match_picks_latest() {
559 let pool = pool_with_fix_outcomes().await;
560 seed_skill(
561 &pool,
562 "skill-old",
563 "headChar returns wrong byte",
564 "2026-04-01",
565 )
566 .await;
567 seed_skill(
568 &pool,
569 "skill-new",
570 "headChar returns wrong byte (off-by-one)",
571 "2026-05-26",
572 )
573 .await;
574
575 let exact = resolve_rule_id_by_name(&pool, "headChar returns wrong byte", None).await;
577 assert_eq!(exact.as_deref(), Some("skill-old"));
578
579 sqlx::query("DELETE FROM skills WHERE id = 'skill-old'")
582 .execute(&pool)
583 .await
584 .unwrap();
585 let prefix = resolve_rule_id_by_name(&pool, "headChar returns wrong", None).await;
586 assert_eq!(prefix.as_deref(), Some("skill-new"));
587 }
588
589 #[tokio::test]
590 async fn resolve_rule_id_by_name_empty_input_returns_none() {
591 let pool = pool_with_fix_outcomes().await;
592 assert!(resolve_rule_id_by_name(&pool, "", None).await.is_none());
593 assert!(resolve_rule_id_by_name(&pool, " ", None).await.is_none());
594 }
595
596 #[tokio::test]
603 async fn resolve_rule_id_by_name_disambiguates_collisions_via_ranking() {
604 let pool = pool_with_fix_outcomes().await;
605
606 seed_skill_full(
607 &pool,
608 "skill-after",
609 "duplicate-rule",
610 "active",
611 0.95,
612 "2026-05-15",
613 )
614 .await;
615 seed_skill_full(
616 &pool,
617 "skill-before-lo",
618 "duplicate-rule",
619 "active",
620 0.40,
621 "2026-04-01",
622 )
623 .await;
624 seed_skill_full(
625 &pool,
626 "skill-before-hi",
627 "duplicate-rule",
628 "active",
629 0.80,
630 "2026-04-15",
631 )
632 .await;
633
634 let resolved = resolve_rule_id_by_name(&pool, "duplicate-rule", Some("2026-05-01")).await;
636 assert_eq!(
637 resolved.as_deref(),
638 Some("skill-before-hi"),
639 "ranking must pick the highest-confidence skill installed before the outcome",
640 );
641 }
642
643 #[tokio::test]
646 async fn resolve_rule_id_by_name_skips_non_active_skills() {
647 let pool = pool_with_fix_outcomes().await;
648 seed_skill_full(
649 &pool,
650 "skill-pending",
651 "pending-only",
652 "pending",
653 0.99,
654 "2026-04-01",
655 )
656 .await;
657 assert!(
658 resolve_rule_id_by_name(&pool, "pending-only", Some("2026-05-01"))
659 .await
660 .is_none()
661 );
662
663 seed_skill_full(
665 &pool,
666 "skill-active",
667 "pending-only",
668 "active",
669 0.20,
670 "2026-04-02",
671 )
672 .await;
673 let resolved = resolve_rule_id_by_name(&pool, "pending-only", Some("2026-05-01")).await;
674 assert_eq!(resolved.as_deref(), Some("skill-active"));
675 }
676
677 #[tokio::test]
681 async fn resolve_rule_id_by_name_prefix_match_disambiguates_collisions() {
682 let pool = pool_with_fix_outcomes().await;
683
684 seed_skill_full(
687 &pool,
688 "skill-after",
689 "prefix-rule (recent variant)",
690 "active",
691 0.99,
692 "2026-05-15",
693 )
694 .await;
695 seed_skill_full(
696 &pool,
697 "skill-before-lo",
698 "prefix-rule (low conf)",
699 "active",
700 0.30,
701 "2026-04-01",
702 )
703 .await;
704 seed_skill_full(
705 &pool,
706 "skill-before-hi",
707 "prefix-rule (high conf)",
708 "active",
709 0.85,
710 "2026-04-10",
711 )
712 .await;
713
714 let resolved = resolve_rule_id_by_name(&pool, "prefix-rule", Some("2026-05-01")).await;
715 assert_eq!(
716 resolved.as_deref(),
717 Some("skill-before-hi"),
718 "prefix fallback must apply the same ranking as the exact match",
719 );
720 }
721
722 #[tokio::test]
728 async fn split_summary_distinguishes_real_applies_from_phantom_accepts() {
729 let pool = pool_with_fix_outcomes().await;
730 sqlx::query(
731 "INSERT INTO fix_outcomes (id, rule_name, accepted, applied_ok) VALUES
732 ('win-1', 'r', 1, 1),
733 ('win-2', 'r', 1, 1),
734 ('win-3', 'r', 1, 1),
735 ('phantom-1', 'r', 1, 0),
736 ('phantom-2', 'r', 1, 0),
737 ('rejected', 'r', 0, 0)",
738 )
739 .execute(&pool)
740 .await
741 .unwrap();
742
743 let split = split_summary(&pool, 30).await.expect("split summary");
744 assert_eq!(
745 split.accepted_and_applied, 3,
746 "only acc=1 AND applied_ok=1 should count as a real win"
747 );
748 assert_eq!(
749 split.accepted_but_failed, 2,
750 "only acc=1 AND applied_ok=0 (explicit failure) should count as phantom"
751 );
752 }
753
754 #[tokio::test]
759 async fn split_summary_treats_null_applied_ok_as_applied_for_legacy_rows() {
760 use sqlx::sqlite::SqlitePoolOptions;
761 let pool = SqlitePoolOptions::new()
762 .max_connections(1)
763 .connect("sqlite::memory:")
764 .await
765 .unwrap();
766 sqlx::query(
768 "CREATE TABLE fix_outcomes (
769 id TEXT PRIMARY KEY NOT NULL,
770 rule_id TEXT,
771 rule_name TEXT NOT NULL,
772 file_path TEXT,
773 repo_full_name TEXT,
774 pr_number INTEGER,
775 diff_signature TEXT,
776 accepted INTEGER NOT NULL,
777 applied_ok INTEGER,
778 failed_reason TEXT,
779 created_at TEXT DEFAULT (datetime('now')) NOT NULL
780 )",
781 )
782 .execute(&pool)
783 .await
784 .unwrap();
785 sqlx::query(
786 "INSERT INTO fix_outcomes (id, rule_name, accepted, applied_ok) VALUES
787 ('legacy-1', 'r', 1, NULL),
788 ('legacy-2', 'r', 1, NULL),
789 ('phantom', 'r', 1, 0)",
790 )
791 .execute(&pool)
792 .await
793 .unwrap();
794
795 let split = split_summary(&pool, 30).await.expect("split summary");
796 assert_eq!(
797 split.accepted_and_applied, 2,
798 "legacy rows with NULL applied_ok must be charitably counted as applied",
799 );
800 assert_eq!(split.accepted_but_failed, 1);
801 }
802}