Skip to main content

difflore_core/observability/
fix_outcomes.rs

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
17/// Best-effort `rule_name → skills.id` lookup. Used by both the
18/// `fix_outcomes` write path (to back-fill `rule_id` at insert time when
19/// the caller only knew the human name) and by the one-shot
20/// `difflore skills backfill-attribution` CLI command.
21///
22/// Returns `None` if `name` is empty/whitespace, or if neither the exact
23/// match nor the case-insensitive prefix match resolves a single id. All
24/// SQL errors are swallowed and surface as `None` because this is a
25/// best-effort enrichment — callers must never fail the hot path on a
26/// missed attribution lookup.
27///
28/// **Collision disambiguation**: duplicate skill names exist, so a naive
29/// `LIMIT 1` picks an arbitrary row. Exact and prefix queries filter by
30/// `status = 'active'` and rank candidates by:
31///   1. skill installed at or before the outcome timestamp (so accepts
32///      attribute to a skill that actually existed when the user took
33///      the action),
34///   2. higher `confidence_score`,
35///   3. most-recent `installed_at`.
36///
37/// `as_of` is typically `fix_outcomes.created_at`. Callers that don't
38/// have one (e.g. the write path before insert) may pass `None`, which
39/// disables the "installed-before" preference and falls back to the
40/// confidence/recency ordering.
41pub 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    // Empty string makes every `installed_at <= ''` comparison false in
52    // SQLite, which collapses the first ORDER BY arm to "all
53    // candidates rank as 1" — i.e. confidence/recency wins. That's the
54    // intent when the caller has no timestamp.
55    let as_of_bind = as_of.unwrap_or("");
56
57    // 1) Exact match (case-sensitive), ranked. Covers the common case
58    //    where the rule_name was lifted verbatim from a `skills.name`
59    //    row. Active-only + installed-before-as_of + higher confidence
60    //    + most-recent install collapses N collided rows to one
61    //    deterministic pick.
62    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    // 2) Normalized prefix match, same ranking. Handles names like
82    //    "headChar returns wrong byte" matching
83    //    "headChar returns wrong byte (off-by-one)" — see the
84    //    polish-surface scan finding #1 for the real-world sample.
85    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/// Honest split of `accepted=1` rows into real wins vs accepted patches that
115/// did not apply. Reporting raw `accepted=1` conflates these cases; user-facing
116/// acceptance metrics should use `accepted_and_applied`.
117///
118/// The `COALESCE(applied_ok, 1) = 1` predicate treats NULL `applied_ok`
119/// (older rows from before the column landed on the schema) as success.
120#[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    // Resolve missing `rule_id` values from `rule_name` so dashboard rollups
140    // can join cleanly. If no skill matches, write the caller's value and never
141    // fail the hot path.
142    //
143    // We resolve BEFORE opening the transaction so the resolver doesn't
144    // contend with the tx for the single connection on pools sized at
145    // `max_connections(1)` (the in-memory test pool, and a degenerate
146    // production pool under heavy load). One query per input row is
147    // fine: callers batch small lists, and the lookup is
148    // index-friendly.
149    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            // Write path: the row's `created_at` will be `datetime('now')`
155            // at insert, so every currently-active skill satisfies
156            // `installed_at <= now`. Passing `None` lets the resolver
157            // fall through to confidence/recency ranking; there is no outcome
158            // timestamp yet to disambiguate by install time.
159            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
217/// Honest split of `accepted=1` rows into real wins vs phantom accepts.
218/// See [`FixOutcomeSplitSummary`] for why this exists. `days <= 0` is
219/// clamped to 1 to match the other window-scoped readers in this file.
220///
221/// Implementation note: this is computed in one round trip alongside an
222/// optional window filter so callers can pass the same `days` value
223/// they already use for `summary` and get directly-comparable numbers.
224pub async fn split_summary(pool: &SqlitePool, days: i64) -> crate::Result<FixOutcomeSplitSummary> {
225    let days = days.max(1);
226    let window = format!("-{days} days");
227    // Runtime `query_as` keeps this optional rollup independent of the offline
228    // SQLx cache.
229    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/// Top N most-common `failed_reason` values within the window. The
243/// reason text is used verbatim — fix.rs already strips the `CoreError`
244/// `Internal error: ` prefix before inserting, so what comes back is
245/// the user-facing sentence.
246#[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        // Minimal `skills` table for the rule_id resolver tests. The
349        // resolver reads `id`, `name`, `status`, `confidence_score`,
350        // and `installed_at`; `updated_at` is kept for recency test seeds.
351        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        // Mirror `installed_at` from `updated_at` so recency tests use one
369        // ranking signal.
370        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    // The insert path must resolve `rule_id` from `rule_name` when the caller
489    // did not have one. Exact match wins over the prefix-match fallback.
490    #[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        // Exact-name match must beat the more-recently-updated "foo (variant)".
519        assert_eq!(stored.as_deref(), Some("skill-foo"));
520    }
521
522    // Graceful-degrade: an unmatched rule_name must NOT fail the insert
523    // and must leave `rule_id` empty (the caller-supplied value).
524    #[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        // No skill matched → caller's value (None) stored as-is.
552        assert!(stored.is_none(), "expected NULL rule_id, got {stored:?}");
553    }
554
555    // Prefix fallback: when no exact match exists, use the case-insensitive
556    // prefix lookup and tiebreak on recency.
557    #[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        // Exact match still wins when present.
576        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        // With only the verbose name in the rules table, the prefix
580        // path picks the variant up.
581        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    // The resolver must deterministically prefer (1) active skills installed at
597    // or before the outcome timestamp, (2) higher confidence, (3) most recent
598    // install. Seed three colliding "duplicate-rule" skills:
599    //   - skill-after: installed AFTER the outcome (should be skipped)
600    //   - skill-before-lo: installed BEFORE, low confidence
601    //   - skill-before-hi: installed BEFORE, high confidence (winner)
602    #[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        // Outcome happened 2026-05-01 — skill-after didn't exist yet.
635        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    // Pending/inactive skills must not be returned, even if they're the
644    // only exact-name match — the resolver filters by `status='active'`.
645    #[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        // Once an active sibling exists, it wins.
664        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    // Same ranking applies to the prefix-fallback path: among multiple
678    // active prefix-matches, the resolver must prefer
679    // installed-before-as_of + higher confidence.
680    #[tokio::test]
681    async fn resolve_rule_id_by_name_prefix_match_disambiguates_collisions() {
682        let pool = pool_with_fix_outcomes().await;
683
684        // Three skills with the same prefix; the lookup will be
685        // "prefix-rule".
686        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    // split_summary must distinguish real applies from phantom accepts. Seed 3
723    // real wins (acc=1, ok=1), 2 phantom failures (acc=1, ok=0), and 1
724    // rejected (acc=0). Expected:
725    //   accepted_and_applied = 3
726    //   accepted_but_failed  = 2
727    #[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    // The COALESCE(applied_ok, 1) = 1 branch is purely defensive: the
755    // production schema has NOT NULL on `applied_ok`, so this can only fire on
756    // a hand-rolled local DB. Cover it with a separate pool that omits the NOT
757    // NULL constraint.
758    #[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        // Permissive schema: `applied_ok` nullable.
767        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}