1use super::*;
2
3impl Storage {
4 #[allow(clippy::too_many_arguments)]
5 pub fn insert_usage_trace(
6 &self,
7 trace_id: &str,
8 chunk_id: Option<&str>,
9 event: &str,
10 strength: f64,
11 similarity: Option<f64>,
12 refine_mode: Option<&str>,
13 tokens: Option<i64>,
14 rank: Option<i64>,
15 attribution: Option<&str>,
16 source: &str,
17 ts: &str,
18 ) -> Result<usize> {
19 let mut stmt = self.conn.prepare_cached(
20 "INSERT OR IGNORE INTO usage_trace
21 (trace_id, chunk_id, event, strength, similarity, refine_mode, tokens, rank, attribution, source, ts)
22 VALUES (?,?,?,?,?,?,?,?,?,?,?)",
23 )?;
24 Ok(stmt.execute(params![
25 trace_id,
26 chunk_id,
27 event,
28 strength,
29 similarity,
30 refine_mode,
31 tokens,
32 rank,
33 attribution,
34 source,
35 ts
36 ])?)
37 }
38
39 pub fn replace_used_trace(
40 &self,
41 trace_id: &str,
42 used_ids: &[String],
43 strength: f64,
44 attribution: &str,
45 source: &str,
46 ts: &str,
47 ) -> Result<()> {
48 self.conn.execute(
49 "DELETE FROM usage_trace WHERE trace_id=? AND event='used'",
50 [trace_id],
51 )?;
52 for chunk_id in used_ids {
53 self.insert_usage_trace(
54 trace_id,
55 Some(chunk_id),
56 "used",
57 strength,
58 None,
59 None,
60 None,
61 None,
62 Some(attribution),
63 source,
64 ts,
65 )?;
66 }
67 Ok(())
68 }
69
70 pub fn merge_used_trace(
71 &self,
72 trace_id: &str,
73 used_ids: &[String],
74 strength: f64,
75 attribution: &str,
76 source: &str,
77 ts: &str,
78 ) -> Result<()> {
79 if used_ids.is_empty() {
80 return Ok(());
81 }
82 let attribution_rank = |value: &str| match value {
83 "explicit" => 3,
84 "cited" => 2,
85 "inferred" => 1,
86 _ => 0,
87 };
88
89 let placeholders = used_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
92 let sql = format!(
93 "SELECT chunk_id, attribution FROM usage_trace
94 WHERE trace_id=? AND event='used' AND chunk_id IN ({placeholders})"
95 );
96 let mut qparams: Vec<&str> = Vec::with_capacity(used_ids.len() + 1);
97 qparams.push(trace_id);
98 qparams.extend(used_ids.iter().map(String::as_str));
99 let existing: HashMap<String, String> = {
100 let mut stmt = self.conn.prepare(&sql)?;
101 let rows = stmt.query_map(rusqlite::params_from_iter(qparams.iter()), |r| {
102 let id: String = r.get(0)?;
103 let attr: Option<String> = r.get(1)?;
104 Ok((id, attr.unwrap_or_else(|| "inferred".to_string())))
105 })?;
106 rows.collect::<rusqlite::Result<HashMap<_, _>>>()?
107 };
108
109 for chunk_id in used_ids {
110 match existing.get(chunk_id) {
111 Some(existing_attribution) => {
112 if attribution_rank(attribution) > attribution_rank(existing_attribution) {
113 self.conn.execute(
114 "UPDATE usage_trace
115 SET strength=?, attribution=?, source=?, ts=?
116 WHERE trace_id=? AND chunk_id=? AND event='used'",
117 params![strength, attribution, source, ts, trace_id, chunk_id],
118 )?;
119 }
120 }
121 None => {
122 self.insert_usage_trace(
123 trace_id,
124 Some(chunk_id),
125 "used",
126 strength,
127 None,
128 None,
129 None,
130 None,
131 Some(attribution),
132 source,
133 ts,
134 )?;
135 }
136 }
137 }
138 Ok(())
139 }
140
141 pub fn refresh_chunk_last_used(&self, chunk_id: &str, now: &str) -> Result<()> {
142 self.conn.execute(
143 "UPDATE chunks
144 SET last_used_at=COALESCE(
145 (SELECT MAX(ts) FROM usage_trace
146 WHERE chunk_id=? AND event='used'
147 AND ts > COALESCE(chunks.evidence_cutoff_at, '')),
148 last_used_base
149 ),
150 updated_at=?
151 WHERE id=?",
152 params![chunk_id, now, chunk_id],
153 )?;
154 Ok(())
155 }
156
157 pub fn get_outcome_for_trace(&self, trace_id: &str) -> Result<Option<String>> {
158 let row = self.conn.query_row(
159 "SELECT event FROM usage_trace
160 WHERE trace_id=? AND event IN ('task_ok','task_fail') AND chunk_id IS NULL
161 LIMIT 1",
162 [trace_id],
163 |r| r.get::<_, String>(0),
164 );
165 match row {
166 Ok(v) => Ok(Some(v)),
167 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
168 Err(e) => Err(e.into()),
169 }
170 }
171
172 pub fn purge_usage_trace(&self, before_ts: &str) -> Result<usize> {
173 let n = self.conn.execute(
175 "DELETE FROM usage_trace
176 WHERE ts < ?
177 AND event IN ('retrieved','refined')
178 AND NOT (event = 'retrieved'
179 AND chunk_id IN (SELECT id FROM chunks WHERE origin='spark'))",
180 [before_ts],
181 )?;
182 Ok(n)
183 }
184
185 pub fn upsert_episodic_log(&self, log: &EpisodicLogRow) -> Result<()> {
200 self.conn.execute(
201 "INSERT OR IGNORE INTO episodic_log
202 (id, trace_id, lib_id, ts, query, recall_snapshot, output,
203 output_summary, outcome, event_source, task_state, completed_at,
204 usage_state, used_ids, used_attribution, used_complete, context_key, nomination, priority,
205 distill_state, distill_note, distill_attempts, distill_last_failed_at, agent)
206 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,0,NULL,?22)",
207 params![
208 log.id,
209 log.trace_id,
210 log.lib_id,
211 log.ts,
212 log.query,
213 log.recall_snapshot,
214 log.output,
215 log.output_summary,
216 log.outcome,
217 log.event_source,
218 log.task_state,
219 log.completed_at,
220 log.usage_state,
221 log.used_ids,
222 log.used_attribution,
223 i64::from(log.used_complete),
224 log.context_key,
225 log.nomination,
226 log.priority,
227 log.distill_state,
228 log.distill_note,
229 log.agent
230 ],
231 )?;
232 Ok(())
233 }
234
235 pub fn get_episodic_log(&self, trace_id: &str) -> Result<Option<Value>> {
236 let row = self.conn.query_row(
237 "SELECT * FROM episodic_log WHERE trace_id=?",
238 [trace_id],
239 row_to_json,
240 );
241 match row {
242 Ok(v) => Ok(Some(v)),
243 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
244 Err(e) => Err(e.into()),
245 }
246 }
247
248 pub fn recent_episodic_logs(&self, limit: usize) -> Result<Vec<Value>> {
252 let mut stmt = self.conn.prepare_cached(
253 "SELECT trace_id, ts, query, outcome, event_source, task_state,
254 usage_state, distill_state, agent, output_summary
255 FROM episodic_log
256 ORDER BY ts DESC
257 LIMIT ?1",
258 )?;
259 let rows = stmt.query_map([limit as i64], |r| {
260 Ok(serde_json::json!({
261 "trace_id": r.get::<_, String>(0)?,
262 "ts": r.get::<_, String>(1)?,
263 "query": r.get::<_, Option<String>>(2)?,
264 "outcome": r.get::<_, Option<String>>(3)?,
265 "event_source": r.get::<_, Option<String>>(4)?,
266 "task_state": r.get::<_, Option<String>>(5)?,
267 "usage_state": r.get::<_, Option<String>>(6)?,
268 "distill_state": r.get::<_, Option<String>>(7)?,
269 "agent": r.get::<_, Option<String>>(8)?,
270 "output_summary": r.get::<_, Option<String>>(9)?,
271 }))
272 })?;
273 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
274 }
275
276 pub fn chunk_provenance(&self, chunk_id: &str, limit: usize) -> Result<Value> {
288 let mut stmt = self.conn.prepare_cached(
291 "SELECT u.trace_id, u.source, u.ts AS used_ts, o.event AS outcome, o.ts AS outcome_ts
292 FROM usage_trace u
293 LEFT JOIN usage_trace o
294 ON o.trace_id = u.trace_id AND o.chunk_id IS NULL
295 AND o.event IN ('task_ok','task_fail')
296 WHERE u.chunk_id = ?1 AND u.event = 'used'",
297 )?;
298 let outcome_rows = stmt
299 .query_map([chunk_id], |r| {
300 let trace_id: String = r.get(0)?;
301 let source: Option<String> = r.get(1)?;
302 let used_ts: String = r.get(2)?;
303 let outcome: Option<String> = r.get(3)?;
304 let outcome_ts: Option<String> = r.get(4)?;
305 let event = outcome.clone().unwrap_or_else(|| "used".to_string());
306 let ts = outcome_ts.unwrap_or(used_ts);
307 Ok(serde_json::json!({
308 "event": event,
309 "ts": ts,
310 "trace_id": trace_id,
311 "source": source,
312 "reason": Value::Null,
313 }))
314 })?
315 .collect::<rusqlite::Result<Vec<_>>>()?;
316
317 let mut fstmt = self.conn.prepare_cached(
319 "SELECT signal, ts, trace_id, source, reason
320 FROM feedback_events WHERE chunk_id = ?1",
321 )?;
322 let feedback_rows = fstmt
323 .query_map([chunk_id], |r| {
324 let signal: String = r.get(0)?;
325 Ok(serde_json::json!({
326 "event": format!("feedback_{signal}"),
327 "ts": r.get::<_, String>(1)?,
328 "trace_id": r.get::<_, String>(2)?,
329 "source": r.get::<_, Option<String>>(3)?,
330 "reason": r.get::<_, Option<String>>(4)?,
331 }))
332 })?
333 .collect::<rusqlite::Result<Vec<_>>>()?;
334
335 let mut events: Vec<Value> = outcome_rows.into_iter().chain(feedback_rows).collect();
338 events.sort_by(|a, b| {
339 let ta = a.get("ts").and_then(Value::as_str).unwrap_or("");
340 let tb = b.get("ts").and_then(Value::as_str).unwrap_or("");
341 tb.cmp(ta)
342 });
343 events.truncate(limit);
344
345 let count_outcome = |outcome: &str| -> Result<i64> {
350 Ok(self.conn.query_row(
351 "SELECT COUNT(DISTINCT u.trace_id)
352 FROM usage_trace u
353 JOIN usage_trace o
354 ON o.trace_id = u.trace_id AND o.chunk_id IS NULL AND o.event = ?2
355 WHERE u.chunk_id = ?1 AND u.event = 'used'",
356 params![chunk_id, outcome],
357 |r| r.get(0),
358 )?)
359 };
360 let successes = count_outcome("task_ok")?;
361 let failures = count_outcome("task_fail")?;
362 let (feedback_up, feedback_down): (i64, i64) = self.conn.query_row(
363 "SELECT
364 COALESCE(SUM(signal = 'up'), 0),
365 COALESCE(SUM(signal = 'down'), 0)
366 FROM feedback_events WHERE chunk_id = ?1",
367 [chunk_id],
368 |r| Ok((r.get(0)?, r.get(1)?)),
369 )?;
370 let (confidence, distilled_from): (Option<f64>, Option<String>) = self.conn.query_row(
371 "SELECT confidence, distilled_from FROM chunks WHERE id = ?1",
372 [chunk_id],
373 |r| Ok((r.get(0)?, r.get(1)?)),
374 )?;
375 let last_negative_at: Option<String> = self.conn.query_row(
377 "SELECT MAX(ts) FROM (
378 SELECT ts FROM feedback_events WHERE chunk_id = ?1 AND signal = 'down'
379 UNION ALL
380 SELECT o.ts FROM usage_trace u
381 JOIN usage_trace o ON o.trace_id = u.trace_id
382 AND o.chunk_id IS NULL AND o.event = 'task_fail'
383 WHERE u.chunk_id = ?1 AND u.event = 'used'
384 )",
385 [chunk_id],
386 |r| r.get(0),
387 )?;
388
389 let source = match distilled_from.as_deref() {
391 Some(log_id) if !log_id.is_empty() => self
392 .conn
393 .query_row(
394 "SELECT id, trace_id, query, output_summary, ts
395 FROM episodic_log WHERE id = ?1",
396 [log_id],
397 |r| {
398 Ok(serde_json::json!({
399 "log_id": r.get::<_, String>(0)?,
400 "trace_id": r.get::<_, String>(1)?,
401 "query": r.get::<_, Option<String>>(2)?,
402 "output_summary": r.get::<_, Option<String>>(3)?,
403 "ts": r.get::<_, String>(4)?,
404 }))
405 },
406 )
407 .optional()?
408 .unwrap_or(Value::Null),
409 _ => Value::Null,
410 };
411
412 let explanation = confidence_explanation(
413 successes,
414 failures,
415 confidence,
416 last_negative_at.as_deref(),
417 );
418
419 Ok(serde_json::json!({
420 "chunk_id": chunk_id,
421 "events": events,
422 "source": source,
423 "stats": {
424 "successes": successes,
425 "failures": failures,
426 "feedback_up": feedback_up,
427 "feedback_down": feedback_down,
428 "confidence": confidence,
429 "last_negative_at": last_negative_at,
430 },
431 "explanation": explanation,
432 }))
433 }
434
435 pub fn update_episodic_log_state(
436 &self,
437 trace_id: &str,
438 state: &str,
439 note: Option<&str>,
440 outcome: Option<&str>,
441 ) -> Result<()> {
442 self.conn.execute(
443 "UPDATE episodic_log
444 SET distill_state=?, distill_note=COALESCE(?,distill_note),
445 outcome=COALESCE(?,outcome),
446 distill_run_id=NULL, distill_locked_at=NULL
447 WHERE trace_id=?",
448 params![state, note, outcome, trace_id],
449 )?;
450 Ok(())
451 }
452
453 pub fn patch_episodic_log_content(
455 &self,
456 trace_id: &str,
457 query: Option<&str>,
458 output: Option<&str>,
459 output_summary: Option<&str>,
460 nomination: Option<&str>,
461 priority: i64,
462 ) -> Result<()> {
463 self.conn.execute(
464 "UPDATE episodic_log
465 SET output_summary = COALESCE(?, output_summary),
466 nomination = COALESCE(?, nomination),
467 output = COALESCE(?, output),
468 query = COALESCE(?, query),
469 priority = MAX(priority, ?)
470 WHERE trace_id = ?",
471 params![
472 output_summary,
473 nomination,
474 output,
475 query,
476 priority,
477 trace_id
478 ],
479 )?;
480 Ok(())
481 }
482
483 #[allow(clippy::too_many_arguments)]
484 pub fn update_trace_lifecycle(
485 &self,
486 trace_id: &str,
487 task_state: &str,
488 completed_at: Option<&str>,
489 usage_state: Option<&str>,
490 used_ids: Option<&str>,
491 used_attribution: Option<&str>,
492 used_complete: Option<bool>,
493 ) -> Result<()> {
494 self.conn.execute(
495 "UPDATE episodic_log
496 SET task_state=?,
497 completed_at=COALESCE(?, completed_at),
498 usage_state=COALESCE(?, usage_state),
499 used_ids=COALESCE(?, used_ids),
500 used_attribution=COALESCE(?, used_attribution),
501 used_complete=COALESCE(?, used_complete)
502 WHERE trace_id=?",
503 params![
504 task_state,
505 completed_at,
506 usage_state,
507 used_ids,
508 used_attribution,
509 used_complete.map(i64::from),
510 trace_id
511 ],
512 )?;
513 Ok(())
514 }
515
516 #[allow(clippy::too_many_arguments)]
517 pub fn upsert_confidence_evidence(
518 &self,
519 id: &str,
520 trace_id: Option<&str>,
521 chunk_id: &str,
522 kind: &str,
523 target: f64,
524 alpha: f64,
525 reason: &str,
526 context_key: Option<&str>,
527 ts: &str,
528 provenance: &str,
529 ) -> Result<()> {
530 self.conn.execute(
531 "INSERT INTO confidence_evidence
532 (id, trace_id, chunk_id, kind, target, alpha, reason, context_key, ts, provenance)
533 VALUES (?,?,?,?,?,?,?,?,?,?)
534 ON CONFLICT(trace_id, chunk_id, kind) WHERE trace_id IS NOT NULL
535 DO UPDATE SET target=excluded.target, alpha=excluded.alpha,
536 reason=excluded.reason, context_key=excluded.context_key,
537 provenance=excluded.provenance",
538 params![
539 id,
540 trace_id,
541 chunk_id,
542 kind,
543 target,
544 alpha,
545 reason,
546 context_key,
547 ts,
548 provenance
549 ],
550 )?;
551 Ok(())
552 }
553
554 pub fn observed_outcome_count(&self, chunk_id: &str) -> Result<i64> {
557 let n = self.conn.query_row(
558 "SELECT COUNT(*) FROM confidence_evidence
559 WHERE chunk_id=? AND provenance='observed'
560 AND kind IN ('outcome_ok','outcome_fail')",
561 params![chunk_id],
562 |r| r.get::<_, i64>(0),
563 )?;
564 Ok(n)
565 }
566
567 pub fn context_stat_present_batch(
571 &self,
572 chunk_ids: &[&str],
573 context_key: &str,
574 ) -> Result<std::collections::HashSet<String>> {
575 let mut set = std::collections::HashSet::new();
576 if chunk_ids.is_empty() {
577 return Ok(set);
578 }
579 let placeholders = chunk_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
580 let sql = format!(
581 "SELECT chunk_id FROM chunk_context_stats
582 WHERE context_key=? AND chunk_id IN ({placeholders})"
583 );
584 let mut params: Vec<&str> = Vec::with_capacity(chunk_ids.len() + 1);
585 params.push(context_key);
586 params.extend_from_slice(chunk_ids);
587 let mut stmt = self.conn.prepare(&sql)?;
588 let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
589 r.get::<_, String>(0)
590 })?;
591 for row in rows {
592 set.insert(row?);
593 }
594 Ok(set)
595 }
596
597 #[allow(clippy::too_many_arguments)]
600 pub fn insert_verdict_log(
601 &self,
602 verdict_id: &str,
603 trace_id: &str,
604 situation_sig: &str,
605 emitted_valence: Option<&str>,
606 emitted_conf: Option<f64>,
607 emitted_strength: f64,
608 emitted_tier: Option<&str>,
609 abstain_reason: Option<&str>,
610 emitted_at: &str,
611 ) -> Result<()> {
612 self.conn.execute(
615 "INSERT INTO verdict_log
616 (verdict_id, trace_id, situation_sig, emitted_valence, emitted_conf,
617 emitted_strength, emitted_tier, abstain_reason, emitted_at)
618 VALUES (?,?,?,?,?,?,?,?,?)",
619 params![
620 verdict_id,
621 trace_id,
622 situation_sig,
623 emitted_valence,
624 emitted_conf,
625 emitted_strength,
626 emitted_tier,
627 abstain_reason,
628 emitted_at
629 ],
630 )?;
631 Ok(())
632 }
633
634 pub fn backfill_verdict_outcome(
638 &self,
639 trace_id: &str,
640 observed_outcome: f64,
641 provenance: &str,
642 observed_at: &str,
643 ) -> Result<()> {
644 self.conn.execute(
645 "UPDATE verdict_log
646 SET observed_outcome=?, outcome_observed_at=?, outcome_provenance=?
647 WHERE trace_id=? AND outcome_observed_at IS NULL",
648 params![observed_outcome, observed_at, provenance, trace_id],
649 )?;
650 Ok(())
651 }
652
653 pub fn load_calibration_map(&self) -> Result<Vec<(f64, f64, f64)>> {
655 let mut stmt = self.conn.prepare(
656 "SELECT claimed_lo, claimed_hi, observed_rate FROM calibration_map ORDER BY bucket",
657 )?;
658 let rows = stmt.query_map([], |r| {
659 Ok((
660 r.get::<_, f64>(0)?,
661 r.get::<_, f64>(1)?,
662 r.get::<_, f64>(2)?,
663 ))
664 })?;
665 let mut out = Vec::new();
666 for row in rows {
667 out.push(row?);
668 }
669 Ok(out)
670 }
671
672 pub fn verdict_calibration_samples(&self) -> Result<Vec<(f64, f64, f64)>> {
682 let mut stmt = self.conn.prepare(
683 "SELECT emitted_strength, emitted_conf,
684 CASE WHEN emitted_valence='affirm'
685 THEN (CASE WHEN observed_outcome < 0 THEN 1.0 ELSE 0.0 END)
686 ELSE (CASE WHEN observed_outcome > 0 THEN 1.0 ELSE 0.0 END) END
687 FROM verdict_log
688 WHERE outcome_provenance='observed'
689 AND emitted_conf IS NOT NULL AND emitted_strength IS NOT NULL
690 AND observed_outcome IS NOT NULL
691 AND emitted_valence IN ('affirm','caution')",
692 )?;
693 let rows = stmt.query_map([], |r| {
694 Ok((
695 r.get::<_, f64>(0)?,
696 r.get::<_, f64>(1)?,
697 r.get::<_, f64>(2)?,
698 ))
699 })?;
700 let mut out = Vec::new();
701 for row in rows {
702 out.push(row?);
703 }
704 Ok(out)
705 }
706
707 pub fn verdict_log_overview(&self) -> Result<(i64, i64, i64)> {
709 let total: i64 = self
710 .conn
711 .query_row("SELECT COUNT(*) FROM verdict_log", [], |r| r.get(0))?;
712 let abstained: i64 = self.conn.query_row(
713 "SELECT COUNT(*) FROM verdict_log WHERE abstain_reason IS NOT NULL",
714 [],
715 |r| r.get(0),
716 )?;
717 let observed: i64 = self.conn.query_row(
718 "SELECT COUNT(*) FROM verdict_log WHERE outcome_provenance='observed'",
719 [],
720 |r| r.get(0),
721 )?;
722 Ok((total, abstained, observed))
723 }
724
725 pub fn replace_calibration_map(
727 &self,
728 buckets: &[(f64, f64, f64, i64)],
729 now: &str,
730 ) -> Result<()> {
731 self.conn.execute("DELETE FROM calibration_map", [])?;
732 for (i, (lo, hi, rate, n)) in buckets.iter().enumerate() {
733 self.conn.execute(
734 "INSERT INTO calibration_map
735 (bucket, claimed_lo, claimed_hi, observed_rate, n, updated_at)
736 VALUES (?,?,?,?,?,?)",
737 params![i as i64, lo, hi, rate, n, now],
738 )?;
739 }
740 Ok(())
741 }
742
743 pub fn delete_trace_confidence_evidence(&self, trace_id: &str, kinds: &[&str]) -> Result<()> {
744 if kinds.is_empty() {
745 return Ok(());
746 }
747 let placeholders = kinds.iter().map(|_| "?").collect::<Vec<_>>().join(",");
748 let sql = format!(
749 "DELETE FROM confidence_evidence WHERE trace_id=? AND kind IN ({placeholders})"
750 );
751 let mut params: Vec<&str> = Vec::with_capacity(kinds.len() + 1);
752 params.push(trace_id);
753 params.extend_from_slice(kinds);
754 self.conn
755 .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
756 Ok(())
757 }
758
759 pub fn delete_chunk_trace_confidence_evidence(
760 &self,
761 trace_id: &str,
762 chunk_id: &str,
763 kind: &str,
764 ) -> Result<()> {
765 self.conn.execute(
766 "DELETE FROM confidence_evidence
767 WHERE trace_id=? AND chunk_id=? AND kind=?",
768 params![trace_id, chunk_id, kind],
769 )?;
770 Ok(())
771 }
772
773 pub fn confidence_evidence_for_chunk(&self, chunk_id: &str) -> Result<Vec<Value>> {
774 self.query_json(
776 "SELECT target, alpha, reason, ts, id
777 FROM confidence_evidence WHERE chunk_id=? AND provenance='observed'
778 ORDER BY ts ASC,
779 CASE kind
780 WHEN 'outcome_ok' THEN 1
781 WHEN 'outcome_fail' THEN 1
782 WHEN 'selected_unused' THEN 2
783 WHEN 'feedback_up' THEN 3
784 WHEN 'feedback_down' THEN 3
785 WHEN 'decay' THEN 4
786 ELSE 5
787 END ASC,
788 kind ASC, id ASC",
789 [chunk_id],
790 )
791 }
792
793 #[allow(clippy::too_many_arguments)]
794 pub fn insert_feedback_event(
795 &self,
796 id: &str,
797 trace_id: &str,
798 chunk_id: &str,
799 signal: &str,
800 strength: f64,
801 source: &str,
802 actor: Option<&str>,
803 reason: Option<&str>,
804 context_key: Option<&str>,
805 ts: &str,
806 ) -> Result<usize> {
807 Ok(self.conn.execute(
808 "INSERT OR IGNORE INTO feedback_events
809 (id, trace_id, chunk_id, signal, strength, source, actor, reason, context_key, ts)
810 VALUES (?,?,?,?,?,?,?,?,?,?)",
811 params![
812 id,
813 trace_id,
814 chunk_id,
815 signal,
816 strength,
817 source,
818 actor,
819 reason,
820 context_key,
821 ts
822 ],
823 )?)
824 }
825
826 pub fn delete_feedback_event(
827 &self,
828 trace_id: &str,
829 chunk_id: &str,
830 signal: &str,
831 ) -> Result<usize> {
832 Ok(self.conn.execute(
833 "DELETE FROM feedback_events
834 WHERE trace_id=? AND chunk_id=? AND signal=?",
835 params![trace_id, chunk_id, signal],
836 )?)
837 }
838
839 pub fn update_chunk_last_decayed_at(&self, id: &str, now: &str) -> Result<()> {
840 self.conn.execute(
841 "UPDATE chunks SET last_decayed_at=?, updated_at=? WHERE id=?",
842 params![now, now, id],
843 )?;
844 Ok(())
845 }
846
847 #[allow(clippy::too_many_arguments)]
848 pub fn update_context_stat(
849 &self,
850 chunk_id: &str,
851 context_key: &str,
852 success: i64,
853 failure: i64,
854 positive: i64,
855 negative: i64,
856 now: &str,
857 ) -> Result<()> {
858 self.conn.execute(
859 "INSERT INTO chunk_context_stats
860 (chunk_id, context_key, success_count, failure_count,
861 positive_feedback, negative_feedback, last_updated_at)
862 VALUES (?,?,?,?,?,?,?)
863 ON CONFLICT(chunk_id, context_key) DO UPDATE SET
864 success_count=success_count+excluded.success_count,
865 failure_count=failure_count+excluded.failure_count,
866 positive_feedback=positive_feedback+excluded.positive_feedback,
867 negative_feedback=negative_feedback+excluded.negative_feedback,
868 last_updated_at=excluded.last_updated_at",
869 params![
870 chunk_id,
871 context_key,
872 success,
873 failure,
874 positive,
875 negative,
876 now
877 ],
878 )?;
879 Ok(())
880 }
881
882 pub fn context_score(
883 &self,
884 chunk_id: &str,
885 context_key: &str,
886 prior_m: f64,
887 base_rate: f64,
888 ) -> Result<f64> {
889 let mut stmt = self.conn.prepare_cached(
890 "SELECT success_count, failure_count, positive_feedback, negative_feedback
891 FROM chunk_context_stats WHERE chunk_id=? AND context_key=?",
892 )?;
893 let row = stmt
894 .query_row(params![chunk_id, context_key], |row| {
895 Ok((
896 row.get::<_, i64>(0)?,
897 row.get::<_, i64>(1)?,
898 row.get::<_, i64>(2)?,
899 row.get::<_, i64>(3)?,
900 ))
901 })
902 .optional()?;
903 let Some((success, failure, positive, negative)) = row else {
904 return Ok(0.0);
905 };
906 Ok(context_score_from_counts(
907 success, failure, positive, negative, prior_m, base_rate,
908 ))
909 }
910
911 pub fn context_scores_batch(
914 &self,
915 chunk_ids: &[&str],
916 context_key: &str,
917 prior_m: f64,
918 base_rate: f64,
919 ) -> Result<HashMap<String, f64>> {
920 if chunk_ids.is_empty() {
921 return Ok(HashMap::new());
922 }
923 let placeholders = chunk_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
924 let sql = format!(
925 "SELECT chunk_id, success_count, failure_count, positive_feedback, negative_feedback
926 FROM chunk_context_stats
927 WHERE context_key=? AND chunk_id IN ({placeholders})"
928 );
929 let mut params: Vec<&str> = Vec::with_capacity(chunk_ids.len() + 1);
930 params.push(context_key);
931 params.extend_from_slice(chunk_ids);
932 let mut stmt = self.conn.prepare(&sql)?;
933 let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
934 Ok((
935 r.get::<_, String>(0)?,
936 r.get::<_, i64>(1)?,
937 r.get::<_, i64>(2)?,
938 r.get::<_, i64>(3)?,
939 r.get::<_, i64>(4)?,
940 ))
941 })?;
942 let mut map = HashMap::new();
943 for row in rows {
944 let (id, success, failure, positive, negative) = row?;
945 map.insert(
946 id,
947 context_score_from_counts(success, failure, positive, negative, prior_m, base_rate),
948 );
949 }
950 Ok(map)
951 }
952}
953
954fn context_score_from_counts(
960 success: i64,
961 failure: i64,
962 positive: i64,
963 negative: i64,
964 prior_m: f64,
965 base_rate: f64,
966) -> f64 {
967 let wins = success as f64 + positive as f64 * 2.0;
968 let losses = failure as f64 + negative as f64 * 2.0;
969 let evidence = wins + losses;
970 let alpha0 = prior_m * base_rate;
971 let beta0 = prior_m * (1.0 - base_rate);
972 let posterior = (wins + alpha0) / (evidence + alpha0 + beta0);
973 let evidence_weight = (evidence / 5.0).min(1.0);
974 (posterior - 0.5) * 2.0 * evidence_weight
975}
976
977fn confidence_explanation(
982 successes: i64,
983 failures: i64,
984 confidence: Option<f64>,
985 last_negative_at: Option<&str>,
986) -> String {
987 let s_word = if successes == 1 { "success" } else { "successes" };
988 let f_word = if failures == 1 { "failure" } else { "failures" };
989 let ema = confidence
990 .map(|c| format!("{c:.2}"))
991 .unwrap_or_else(|| "n/a".to_string());
992 let mut out = format!("{successes} {s_word}, {failures} {f_word} → EMA {ema}");
993 if let Some(ts) = last_negative_at {
994 if let Some(ago) = relative_ago(ts) {
995 out.push_str(&format!("; last negative feedback {ago}"));
996 }
997 }
998 out
999}
1000
1001fn relative_ago(ts: &str) -> Option<String> {
1005 let then = chrono::DateTime::parse_from_rfc3339(ts)
1006 .ok()?
1007 .with_timezone(&chrono::Utc);
1008 let secs = (chrono::Utc::now() - then).num_seconds();
1009 if secs < 0 {
1010 return Some("just now".to_string());
1011 }
1012 let out = if secs < 60 {
1013 "just now".to_string()
1014 } else if secs < 3600 {
1015 format!("{}m ago", secs / 60)
1016 } else if secs < 86_400 {
1017 format!("{}h ago", secs / 3600)
1018 } else {
1019 format!("{}d ago", secs / 86_400)
1020 };
1021 Some(out)
1022}