1use serde::Serialize;
29use sqlx::SqlitePool;
30
31use crate::Result;
32
33#[derive(Debug, Clone, Copy)]
36pub struct SweepOpts {
37 pub stale_install_days: u32,
40 pub stale_serve_days: u32,
43 pub decay_factor: f32,
46 pub dry_run: bool,
48 pub min_floor: f32,
51}
52
53impl Default for SweepOpts {
54 fn default() -> Self {
55 Self {
56 stale_install_days: 14,
57 stale_serve_days: 14,
58 decay_factor: 0.5,
59 dry_run: false,
60 min_floor: 0.05,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize)]
69#[serde(rename_all = "camelCase")]
70pub struct SweepReport {
71 pub examined: u64,
73 pub decayed: u64,
75 pub skipped_because_active: u64,
78 pub skipped_because_already_at_floor: u64,
81 pub dry_run: bool,
84}
85
86pub async fn sweep_stale_skills(pool: &SqlitePool, opts: SweepOpts) -> Result<SweepReport> {
88 let install_window = format!("-{} days", opts.stale_install_days);
89 let serve_window = format!("-{} days", opts.stale_serve_days);
90
91 let stale_serve_subquery = "id IN (\
107 SELECT value FROM mcp_rule_serves, json_each(rule_ids_json) \
108 WHERE served_at > datetime('now', ?2)\
109 )";
110 let accepted_subquery =
111 "id IN (SELECT rule_id FROM fix_outcomes WHERE rule_id IS NOT NULL AND accepted = 1)";
112
113 let candidates_sql = format!(
117 "SELECT id, confidence_score FROM skills \
118 WHERE installed_at < datetime('now', ?1) \
119 AND NOT ({stale_serve_subquery}) \
120 AND NOT ({accepted_subquery})"
121 );
122
123 let examined: i64 =
124 sqlx::query_scalar("SELECT COUNT(*) FROM skills WHERE installed_at < datetime('now', ?1)")
125 .bind(&install_window)
126 .fetch_one(pool)
127 .await?;
128
129 let rows: Vec<(String, f64)> = sqlx::query_as(&candidates_sql)
130 .bind(&install_window)
131 .bind(&serve_window)
132 .fetch_all(pool)
133 .await?;
134
135 let floor = f64::from(opts.min_floor);
138 let to_decay: Vec<&(String, f64)> = rows.iter().filter(|(_, c)| *c > floor).collect();
139 let at_floor_len = rows.len() - to_decay.len();
140
141 let decayed_count = u64::try_from(to_decay.len()).unwrap_or(u64::MAX);
142 let at_floor_count = u64::try_from(at_floor_len).unwrap_or(u64::MAX);
143 let examined_u64 = u64::try_from(examined).unwrap_or(0);
144 let skipped_active = examined_u64.saturating_sub(decayed_count + at_floor_count);
146
147 let report = SweepReport {
148 examined: examined_u64,
149 decayed: decayed_count,
150 skipped_because_active: skipped_active,
151 skipped_because_already_at_floor: at_floor_count,
152 dry_run: opts.dry_run,
153 };
154
155 if opts.dry_run || to_decay.is_empty() {
156 return Ok(report);
157 }
158
159 let factor = f64::from(opts.decay_factor);
162 let mut tx = pool.begin().await?;
163 for (id, conf) in &to_decay {
164 let new_conf = (conf * factor).max(floor);
165 sqlx::query(
166 "UPDATE skills SET confidence_score = ?1, updated_at = datetime('now') WHERE id = ?2",
167 )
168 .bind(new_conf)
169 .bind(id)
170 .execute(&mut *tx)
171 .await?;
172 }
173 tx.commit().await?;
174
175 Ok(report)
176}
177
178#[derive(Debug, Clone, Serialize)]
182#[serde(rename_all = "camelCase")]
183pub struct QuarantineReport {
184 pub examined: u64,
185 pub flipped: u64,
186 pub flipped_ids: Vec<String>,
187 pub dry_run: bool,
188}
189
190pub async fn quarantine_unguided_conv_reviews(
193 pool: &SqlitePool,
194 dry_run: bool,
195) -> Result<QuarantineReport> {
196 let candidates: Vec<String> = sqlx::query_scalar(
199 "SELECT id FROM skills \
200 WHERE id LIKE 'conv-review-%' \
201 AND status = 'active' \
202 AND (file_patterns IS NULL OR file_patterns = '' OR file_patterns = '[]') \
203 AND (trigger IS NULL OR trigger = '')",
204 )
205 .fetch_all(pool)
206 .await?;
207
208 let examined_total: i64 = sqlx::query_scalar(
209 "SELECT COUNT(*) FROM skills WHERE id LIKE 'conv-review-%' AND status = 'active'",
210 )
211 .fetch_one(pool)
212 .await?;
213
214 let report = QuarantineReport {
215 examined: u64::try_from(examined_total).unwrap_or(0),
216 flipped: u64::try_from(candidates.len()).unwrap_or(u64::MAX),
217 flipped_ids: candidates.clone(),
218 dry_run,
219 };
220
221 if dry_run || candidates.is_empty() {
222 return Ok(report);
223 }
224
225 let mut tx = pool.begin().await?;
226 for id in &candidates {
227 sqlx::query(
228 "UPDATE skills SET status = 'pending', updated_at = datetime('now') WHERE id = ?1",
229 )
230 .bind(id)
231 .execute(&mut *tx)
232 .await?;
233 }
234 tx.commit().await?;
235
236 Ok(report)
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use sqlx::sqlite::SqlitePoolOptions;
243
244 async fn fresh_pool() -> SqlitePool {
245 let pool = SqlitePoolOptions::new()
246 .max_connections(1)
247 .connect("sqlite::memory:")
248 .await
249 .unwrap();
250 sqlx::query(
252 "CREATE TABLE skills (
253 id TEXT PRIMARY KEY NOT NULL,
254 name TEXT NOT NULL DEFAULT '',
255 source TEXT NOT NULL DEFAULT '',
256 directory TEXT NOT NULL DEFAULT '',
257 version TEXT NOT NULL DEFAULT '',
258 description TEXT NOT NULL DEFAULT '',
259 type TEXT NOT NULL DEFAULT 'skill',
260 engines TEXT NOT NULL DEFAULT '[]',
261 tags TEXT NOT NULL DEFAULT '[]',
262 trigger TEXT,
263 check_prompt TEXT,
264 repo_owner TEXT,
265 repo_name TEXT,
266 repo_branch TEXT,
267 readme_url TEXT,
268 source_repo TEXT,
269 enabled_for_codex INTEGER NOT NULL DEFAULT 0,
270 enabled_for_claude INTEGER NOT NULL DEFAULT 0,
271 enabled_for_gemini INTEGER NOT NULL DEFAULT 0,
272 enabled_for_cursor INTEGER NOT NULL DEFAULT 0,
273 confidence_score REAL NOT NULL DEFAULT 0.7,
274 file_patterns TEXT,
275 origin TEXT NOT NULL DEFAULT 'manual',
276 content_hash TEXT,
277 hash_created_at INTEGER,
278 status TEXT NOT NULL DEFAULT 'active',
279 installed_at TEXT NOT NULL DEFAULT (datetime('now')),
280 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
281 )",
282 )
283 .execute(&pool)
284 .await
285 .unwrap();
286 sqlx::query(
287 "CREATE TABLE mcp_rule_serves (
288 id INTEGER PRIMARY KEY AUTOINCREMENT,
289 rule_ids_json TEXT NOT NULL DEFAULT '[]',
290 served_at TEXT NOT NULL DEFAULT (datetime('now'))
291 )",
292 )
293 .execute(&pool)
294 .await
295 .unwrap();
296 sqlx::query(
297 "CREATE TABLE fix_outcomes (
298 id TEXT PRIMARY KEY NOT NULL,
299 rule_id TEXT,
300 rule_name TEXT NOT NULL DEFAULT '',
301 accepted INTEGER NOT NULL DEFAULT 0,
302 created_at TEXT NOT NULL DEFAULT (datetime('now'))
303 )",
304 )
305 .execute(&pool)
306 .await
307 .unwrap();
308 pool
309 }
310
311 async fn insert_skill(
314 pool: &SqlitePool,
315 id: &str,
316 confidence: f64,
317 age_modifier: &str,
318 file_patterns: Option<&str>,
319 trigger: Option<&str>,
320 ) {
321 sqlx::query(
322 "INSERT INTO skills (id, name, confidence_score, installed_at, file_patterns, trigger) \
323 VALUES (?1, ?1, ?2, datetime('now', ?3), ?4, ?5)",
324 )
325 .bind(id)
326 .bind(confidence)
327 .bind(age_modifier)
328 .bind(file_patterns)
329 .bind(trigger)
330 .execute(pool)
331 .await
332 .unwrap();
333 }
334
335 async fn confidence(pool: &SqlitePool, id: &str) -> f64 {
336 sqlx::query_scalar("SELECT confidence_score FROM skills WHERE id = ?1")
337 .bind(id)
338 .fetch_one(pool)
339 .await
340 .unwrap()
341 }
342
343 fn approx_eq(a: f64, b: f64) -> bool {
346 (a - b).abs() < 1e-6
347 }
348
349 async fn status(pool: &SqlitePool, id: &str) -> String {
350 sqlx::query_scalar("SELECT status FROM skills WHERE id = ?1")
351 .bind(id)
352 .fetch_one(pool)
353 .await
354 .unwrap()
355 }
356
357 fn opts() -> SweepOpts {
358 SweepOpts::default()
359 }
360
361 #[tokio::test]
362 async fn sweep_only_decays_stale_never_served_with_no_accept() {
363 let pool = fresh_pool().await;
364 insert_skill(&pool, "fresh", 0.7, "-2 days", None, None).await;
366 insert_skill(&pool, "stale-quiet", 0.7, "-20 days", None, None).await;
368 insert_skill(&pool, "stale-served", 0.7, "-20 days", None, None).await;
370 sqlx::query(
371 "INSERT INTO mcp_rule_serves (rule_ids_json, served_at) \
372 VALUES (?1, datetime('now', '-5 days'))",
373 )
374 .bind(r#"["stale-served"]"#)
375 .execute(&pool)
376 .await
377 .unwrap();
378 insert_skill(&pool, "stale-accepted", 0.7, "-20 days", None, None).await;
380 sqlx::query(
381 "INSERT INTO fix_outcomes (id, rule_id, rule_name, accepted) \
382 VALUES ('fo-1', 'stale-accepted', 'stale-accepted', 1)",
383 )
384 .execute(&pool)
385 .await
386 .unwrap();
387 insert_skill(&pool, "stale-floor", 0.05, "-20 days", None, None).await;
389
390 let report = sweep_stale_skills(&pool, opts()).await.unwrap();
391
392 assert!(approx_eq(confidence(&pool, "fresh").await, 0.7));
393 assert!(approx_eq(confidence(&pool, "stale-quiet").await, 0.35));
395 assert!(approx_eq(confidence(&pool, "stale-served").await, 0.7));
396 assert!(approx_eq(confidence(&pool, "stale-accepted").await, 0.7));
397 assert!(approx_eq(confidence(&pool, "stale-floor").await, 0.05));
398
399 assert_eq!(report.decayed, 1);
400 assert_eq!(report.skipped_because_already_at_floor, 1);
401 assert_eq!(report.examined, 4);
403 assert_eq!(report.skipped_because_active, 2);
405 assert!(!report.dry_run);
406 }
407
408 #[tokio::test]
409 async fn sweep_dry_run_does_not_commit() {
410 let pool = fresh_pool().await;
411 insert_skill(&pool, "fresh", 0.7, "-2 days", None, None).await;
412 insert_skill(&pool, "stale-quiet", 0.7, "-20 days", None, None).await;
413 insert_skill(&pool, "stale-served", 0.7, "-20 days", None, None).await;
414 sqlx::query(
415 "INSERT INTO mcp_rule_serves (rule_ids_json, served_at) \
416 VALUES (?1, datetime('now', '-5 days'))",
417 )
418 .bind(r#"["stale-served"]"#)
419 .execute(&pool)
420 .await
421 .unwrap();
422 insert_skill(&pool, "stale-accepted", 0.7, "-20 days", None, None).await;
423 sqlx::query(
424 "INSERT INTO fix_outcomes (id, rule_id, rule_name, accepted) \
425 VALUES ('fo-1', 'stale-accepted', 'stale-accepted', 1)",
426 )
427 .execute(&pool)
428 .await
429 .unwrap();
430 insert_skill(&pool, "stale-floor", 0.05, "-20 days", None, None).await;
431
432 let dry = SweepOpts {
433 dry_run: true,
434 ..SweepOpts::default()
435 };
436 let report = sweep_stale_skills(&pool, dry).await.unwrap();
437
438 assert_eq!(report.decayed, 1);
440 assert!(report.dry_run);
441 assert!(approx_eq(confidence(&pool, "fresh").await, 0.7));
443 assert!(approx_eq(confidence(&pool, "stale-quiet").await, 0.7));
444 assert!(approx_eq(confidence(&pool, "stale-served").await, 0.7));
445 assert!(approx_eq(confidence(&pool, "stale-accepted").await, 0.7));
446 assert!(approx_eq(confidence(&pool, "stale-floor").await, 0.05));
447 }
448
449 #[tokio::test]
450 async fn quarantine_flips_only_unguided_conv_reviews() {
451 let pool = fresh_pool().await;
452 insert_skill(
454 &pool,
455 "conv-review-1",
456 0.6,
457 "-1 days",
458 Some(r#"["**/*.rs"]"#),
459 None,
460 )
461 .await;
462 insert_skill(&pool, "conv-review-2", 0.6, "-1 days", None, None).await;
464 insert_skill(
466 &pool,
467 "conv-review-3",
468 0.6,
469 "-1 days",
470 None,
471 Some("when editing"),
472 )
473 .await;
474
475 let report = quarantine_unguided_conv_reviews(&pool, false)
476 .await
477 .unwrap();
478
479 assert_eq!(report.flipped, 1);
480 assert_eq!(report.flipped_ids, vec!["conv-review-2".to_owned()]);
481 assert_eq!(status(&pool, "conv-review-1").await, "active");
482 assert_eq!(status(&pool, "conv-review-2").await, "pending");
483 assert_eq!(status(&pool, "conv-review-3").await, "active");
484 }
485
486 #[tokio::test]
487 async fn decay_is_bounded_by_min_floor() {
488 let pool = fresh_pool().await;
489 insert_skill(&pool, "just-above-floor", 0.06, "-20 days", None, None).await;
492
493 let report = sweep_stale_skills(&pool, opts()).await.unwrap();
494 assert_eq!(report.decayed, 1);
495
496 let new_conf = confidence(&pool, "just-above-floor").await;
497 assert!(
499 (new_conf - 0.05).abs() < 1e-6,
500 "expected floor clamp at 0.05, got {new_conf}"
501 );
502 let report2 = sweep_stale_skills(&pool, opts()).await.unwrap();
504 assert_eq!(report2.decayed, 0);
505 assert_eq!(report2.skipped_because_already_at_floor, 1);
506 }
507}