Skip to main content

difflore_core/observability/
rule_outcomes.rs

1//! Local-only rule outcome telemetry.
2//!
3//! Records when a rule is surfaced by recall (`kind = 'recalled'`) so
4//! `difflore memory` can show "what rules are actually pulling weight"
5//! and `rules show` can include a use-count line.
6//!
7//! Fix-acceptance telemetry already lives in `fix_outcomes` (richer
8//! schema with file path + applied/failed split); this table is just
9//! the recall-side ledger. Both are read together by the surfaces.
10//!
11//! Data never leaves the device. Personal usage signal is not uploaded
12//! to cloud — cluster precision is derived cloud-side over consented
13//! aggregates only.
14
15use sqlx::SqlitePool;
16
17pub const KIND_RECALLED: &str = "recalled";
18
19#[derive(Debug, Clone)]
20pub struct RuleRecallInput<'a> {
21    pub rule_id: &'a str,
22    pub session_id: Option<&'a str>,
23    pub repo_full_name: Option<&'a str>,
24    pub file_path: Option<&'a str>,
25    pub query_text: &'a str,
26    pub rank: i64,
27    pub top_k: i64,
28    pub strict_file_match: bool,
29}
30
31/// Insert one row per recalled rule. No-op when `rule_ids` is empty.
32pub async fn record_recalled(pool: &SqlitePool, rule_ids: &[String]) -> crate::Result<()> {
33    if rule_ids.is_empty() {
34        return Ok(());
35    }
36    let mut tx = pool.begin().await?;
37    for id in rule_ids {
38        sqlx::query!(
39            "INSERT INTO rule_outcomes (rule_id, kind) VALUES (?1, ?2)",
40            id,
41            KIND_RECALLED
42        )
43        .execute(&mut *tx)
44        .await?;
45    }
46    tx.commit().await?;
47    Ok(())
48}
49
50/// Insert recall rows with enough low-sensitive context to prove a value loop.
51///
52/// We store hashes and scope, not prompt text or source code. `rank <= 3` is
53/// the key bit the buyer-value gate needs: the accepted rule was not merely
54/// somewhere in a corpus, it was one of the memories an agent would actually see.
55pub async fn record_recalled_with_context(
56    pool: &SqlitePool,
57    recalls: &[RuleRecallInput<'_>],
58) -> crate::Result<()> {
59    if recalls.is_empty() {
60        return Ok(());
61    }
62    let mut tx = pool.begin().await?;
63    for recall in recalls {
64        let query_hash = crate::mcp_rule_serves::query_hash(recall.query_text);
65        let rank = recall.rank.max(1);
66        let top_k = recall.top_k.max(1);
67        let strict = i64::from(recall.strict_file_match);
68        sqlx::query!(
69            "INSERT INTO rule_outcomes
70             (rule_id, kind, session_id, repo_full_name, file_path, query_hash,
71              rank, top_k, strict_file_match)
72             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
73            recall.rule_id,
74            KIND_RECALLED,
75            recall.session_id,
76            recall.repo_full_name,
77            recall.file_path,
78            query_hash,
79            rank,
80            top_k,
81            strict,
82        )
83        .execute(&mut *tx)
84        .await?;
85    }
86    tx.commit().await?;
87    Ok(())
88}
89
90#[derive(Debug, Clone, sqlx::FromRow)]
91pub struct RecallCount {
92    pub rule_id: String,
93    pub count: i64,
94}
95
96#[derive(Debug, Clone, sqlx::FromRow)]
97pub struct RecallSummary {
98    pub recall_events: i64,
99    pub recalled_rules: i64,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
103pub struct AcceptedFixEvidence {
104    pub file_path: Option<String>,
105    pub created_at: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
109pub struct TopRecallEvidence {
110    pub rule_id: String,
111    pub repo_full_name: Option<String>,
112    pub file_path: Option<String>,
113    pub rank: i64,
114    pub top_k: i64,
115    pub strict_file_match: bool,
116    pub recalled_at: String,
117}
118
119/// Total local recall proof over the last `days` days.
120pub async fn summary(pool: &SqlitePool, days: i64) -> crate::Result<RecallSummary> {
121    let days = days.max(1);
122    let window = format!("-{days} days");
123    let row = sqlx::query_as!(
124        RecallSummary,
125        r#"SELECT
126             COUNT(*) AS "recall_events!: i64",
127             COUNT(DISTINCT rule_id) AS "recalled_rules!: i64"
128         FROM rule_outcomes
129         WHERE kind = 'recalled'
130           AND datetime(created_at) >= datetime('now', ?1)"#,
131        window,
132    )
133    .fetch_one(pool)
134    .await?;
135    Ok(row)
136}
137
138/// Top-N rules by recall events within the last `days` days.
139pub async fn top_recalled(
140    pool: &SqlitePool,
141    days: i64,
142    limit: i64,
143) -> crate::Result<Vec<RecallCount>> {
144    let days = days.max(1);
145    let limit = limit.max(1);
146    let window = format!("-{days} days");
147    // INNER JOIN against `skills` so deleted rules do not surface as zombie
148    // rows. Without this filter, `difflore memory` would render the bare
149    // rule_id when the join fell through to fallback.
150    let rows = sqlx::query_as!(
151        RecallCount,
152        r#"SELECT o.rule_id AS "rule_id!: String", COUNT(*) AS "count!: i64"
153         FROM rule_outcomes o
154         INNER JOIN skills s ON s.id = o.rule_id
155         WHERE o.kind = 'recalled'
156           AND datetime(o.created_at) >= datetime('now', ?1)
157         GROUP BY o.rule_id
158         ORDER BY COUNT(*) DESC, o.rule_id ASC
159         LIMIT ?2"#,
160        window,
161        limit
162    )
163    .fetch_all(pool)
164    .await?;
165    Ok(rows)
166}
167
168/// Total recall count for a single rule over the last `days` days.
169pub async fn recall_count_for(pool: &SqlitePool, rule_id: &str, days: i64) -> crate::Result<i64> {
170    let days = days.max(1);
171    let window = format!("-{days} days");
172    let n: i64 = sqlx::query_scalar!(
173        r#"SELECT COUNT(*) AS "n!: i64" FROM rule_outcomes
174         WHERE kind = 'recalled' AND rule_id = ?1
175           AND datetime(created_at) >= datetime('now', ?2)"#,
176        rule_id,
177        window
178    )
179    .fetch_one(pool)
180    .await?;
181    Ok(n)
182}
183
184pub async fn latest_top3_recall_for(
185    pool: &SqlitePool,
186    rule_id: &str,
187    days: i64,
188) -> crate::Result<Option<TopRecallEvidence>> {
189    let days = days.max(1);
190    let window = format!("-{days} days");
191    let row = sqlx::query_as!(
192        TopRecallEvidence,
193        r#"SELECT rule_id AS "rule_id!: String",
194                repo_full_name,
195                file_path,
196                COALESCE(rank, 999) AS "rank!: i64",
197                COALESCE(top_k, 0) AS "top_k!: i64",
198                strict_file_match != 0 AS "strict_file_match!: bool",
199                created_at AS "recalled_at!: String"
200         FROM rule_outcomes
201         WHERE kind = 'recalled'
202           AND rule_id = ?1
203           AND rank BETWEEN 1 AND 3
204           AND datetime(created_at) >= datetime('now', ?2)
205         ORDER BY datetime(created_at) DESC, id DESC
206         LIMIT 1"#,
207        rule_id,
208        window,
209    )
210    .fetch_optional(pool)
211    .await?;
212    Ok(row)
213}
214
215/// How many `fix_outcomes` rows for this rule were `accepted = 1 AND
216/// applied_ok = 1` within the window. Read here so the memory/show
217/// surfaces have a single import path for "rule outcome" reads.
218pub async fn fix_accepted_count_for(
219    pool: &SqlitePool,
220    rule_id: &str,
221    days: i64,
222) -> crate::Result<i64> {
223    let days = days.max(1);
224    let window = format!("-{days} days");
225    let n: i64 = sqlx::query_scalar!(
226        r#"SELECT COUNT(*) AS "n!: i64" FROM fix_outcomes
227         WHERE rule_id = ?1 AND accepted = 1 AND applied_ok = 1
228           AND datetime(created_at) >= datetime('now', ?2)"#,
229        rule_id,
230        window
231    )
232    .fetch_one(pool)
233    .await?;
234    Ok(n)
235}
236
237pub async fn latest_accepted_fix_for(
238    pool: &SqlitePool,
239    rule_id: &str,
240    days: i64,
241) -> crate::Result<Option<AcceptedFixEvidence>> {
242    let days = days.max(1);
243    let window = format!("-{days} days");
244    let row = sqlx::query_as!(
245        AcceptedFixEvidence,
246        r#"SELECT file_path, created_at AS "created_at!: String"
247         FROM fix_outcomes
248         WHERE rule_id = ?1
249           AND accepted = 1
250           AND applied_ok = 1
251           AND datetime(created_at) >= datetime('now', ?2)
252         ORDER BY datetime(created_at) DESC, id DESC
253         LIMIT 1"#,
254        rule_id,
255        window,
256    )
257    .fetch_optional(pool)
258    .await?;
259    Ok(row)
260}
261
262#[cfg(test)]
263#[allow(clippy::str_to_string)] // reason: test code — failure should panic with context.
264mod tests {
265    use super::*;
266    use sqlx::sqlite::SqlitePoolOptions;
267
268    async fn setup() -> SqlitePool {
269        let pool = SqlitePoolOptions::new()
270            .max_connections(1)
271            .connect("sqlite::memory:")
272            .await
273            .expect("open pool");
274        sqlx::migrate!("./migrations")
275            .run(&pool)
276            .await
277            .expect("apply migrations");
278        pool
279    }
280
281    async fn insert_skill(pool: &SqlitePool, id: &str, name: &str) {
282        sqlx::query!(
283            "INSERT INTO skills (id, name, source, directory, version)
284             VALUES (?1, ?2, 'manual', '/tmp', '1.0.0')",
285            id,
286            name,
287        )
288        .execute(pool)
289        .await
290        .expect("insert skill");
291    }
292
293    /// Locks in the fix for the `difflore memory` zombie-rule bug:
294    /// recall events whose owning rule has been deleted from `skills`
295    /// must not surface in the Most-recalled list.
296    #[tokio::test]
297    async fn top_recalled_excludes_deleted_rules() {
298        let pool = setup().await;
299        insert_skill(&pool, "r1", "Real rule").await;
300        insert_skill(&pool, "r2", "Soon-deleted rule").await;
301
302        record_recalled(&pool, &["r1".to_owned()])
303            .await
304            .expect("record r1");
305        record_recalled(&pool, &["r2".to_owned(), "r2".to_owned()])
306            .await
307            .expect("record r2");
308
309        // Drop the rule but keep its outcome rows (idempotent design).
310        sqlx::query!("DELETE FROM skills WHERE id = 'r2'")
311            .execute(&pool)
312            .await
313            .expect("delete r2");
314
315        let rows = top_recalled(&pool, 7, 10).await.expect("top_recalled");
316        let ids: Vec<&str> = rows.iter().map(|r| r.rule_id.as_str()).collect();
317        assert!(ids.contains(&"r1"), "real rule should appear: {ids:?}");
318        assert!(
319            !ids.contains(&"r2"),
320            "deleted rule must not appear: {ids:?}"
321        );
322    }
323
324    #[tokio::test]
325    async fn summary_counts_local_recall_events_and_distinct_rules() {
326        let pool = setup().await;
327        record_recalled(&pool, &["r1".to_owned(), "r2".to_owned()])
328            .await
329            .expect("record first recall");
330        record_recalled(&pool, &["r2".to_owned()])
331            .await
332            .expect("record second recall");
333
334        let row = summary(&pool, 30).await.expect("summary");
335        assert_eq!(row.recall_events, 3);
336        assert_eq!(row.recalled_rules, 2);
337    }
338
339    #[tokio::test]
340    async fn latest_top3_recall_for_requires_ranked_recall_context() {
341        let pool = setup().await;
342        record_recalled_with_context(
343            &pool,
344            &[
345                RuleRecallInput {
346                    rule_id: "r1",
347                    session_id: Some("session-1"),
348                    repo_full_name: Some("acme/widgets"),
349                    file_path: Some("src/auth.rs"),
350                    query_text: "src/auth.rs validate auth token",
351                    rank: 4,
352                    top_k: 5,
353                    strict_file_match: true,
354                },
355                RuleRecallInput {
356                    rule_id: "r1",
357                    session_id: Some("session-1"),
358                    repo_full_name: Some("acme/widgets"),
359                    file_path: Some("src/auth.rs"),
360                    query_text: "src/auth.rs validate auth token",
361                    rank: 2,
362                    top_k: 5,
363                    strict_file_match: true,
364                },
365            ],
366        )
367        .await
368        .expect("record ranked recall");
369
370        let recall = latest_top3_recall_for(&pool, "r1", 30)
371            .await
372            .expect("latest recall")
373            .expect("top3 recall");
374
375        assert_eq!(recall.rank, 2);
376        assert_eq!(recall.top_k, 5);
377        assert_eq!(recall.repo_full_name.as_deref(), Some("acme/widgets"));
378        assert_eq!(recall.file_path.as_deref(), Some("src/auth.rs"));
379        assert!(recall.strict_file_match);
380    }
381
382    #[tokio::test]
383    async fn latest_accepted_fix_for_returns_newest_applied_fix() {
384        let pool = setup().await;
385        insert_skill(&pool, "r1", "Real rule").await;
386        sqlx::query!(
387            "INSERT INTO fix_outcomes
388             (id, rule_id, rule_name, file_path, accepted, applied_ok, created_at)
389             VALUES
390             ('f-old', 'r1', 'Real rule', 'src/old.rs', 1, 1, '2026-05-01 00:00:00'),
391             ('f-new', 'r1', 'Real rule', 'src/new.rs', 1, 1, datetime('now')),
392             ('f-rejected', 'r1', 'Real rule', 'src/rejected.rs', 0, 0, datetime('now'))",
393        )
394        .execute(&pool)
395        .await
396        .expect("insert fix outcomes");
397
398        let latest = latest_accepted_fix_for(&pool, "r1", 30)
399            .await
400            .expect("latest accepted fix")
401            .expect("some accepted fix");
402        assert_eq!(latest.file_path.as_deref(), Some("src/new.rs"));
403    }
404}