Skip to main content

innate_core/storage/
traces.rs

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        // Batch-fetch all existing 'used' rows for this trace in one query
90        // instead of one SELECT per chunk id.
91        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        // Preserve compact attribution facts. They are required to replay corrections.
174        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    // ------------------------------------------------------------------
186    // Episodic log
187    // ------------------------------------------------------------------
188
189    /// Insert an episodic_log row **if its trace_id does not already exist**.
190    ///
191    /// All callers (recall, appraise, record's fresh-insert branch) pass a newly
192    /// generated trace_id, so this is an insert in practice. It is `INSERT OR
193    /// IGNORE` rather than `INSERT OR REPLACE` on purpose: REPLACE deletes and
194    /// re-inserts the whole row, which would silently wipe lifecycle state
195    /// (`distill_state`, `distill_attempts` → 0, `distill_last_failed_at` → NULL,
196    /// `outcome`, `usage_state`). An accidental re-upsert of an existing trace
197    /// must never destroy that progress — mutate existing rows through
198    /// `update_episodic_log_state` / `patch_episodic_log_content` instead.
199    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    /// Recent recall→record trace timeline (newest first) for the Web "Sessions"
249    /// view. Projects a compact, UI-friendly subset of `episodic_log` rather than
250    /// `SELECT *` so the endpoint stays cheap and stable. Read-only.
251    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    /// Provenance timeline for a single chunk, powering the Web chunk-detail
277    /// Provenance panel. Read-only projection across `usage_trace`,
278    /// `feedback_events`, `chunk_success_traces`, and (for distilled chunks) the
279    /// source `episodic_log`. Returns:
280    ///   - `events`: newest-first merge of per-trace outcomes (task_ok/task_fail,
281    ///     derived from the trace's chunk-less outcome row) and feedback_up/down
282    ///     events, capped at `limit`.
283    ///   - `source`: the originating episodic_log (query + output_summary) when the
284    ///     chunk is `distilled` (`chunks.distilled_from` → `episodic_log.id`), else null.
285    ///   - `stats` + `explanation`: success/failure/feedback counts, current EMA
286    ///     confidence, and a natural-language confidence summary.
287    pub fn chunk_provenance(&self, chunk_id: &str, limit: usize) -> Result<Value> {
288        // Per-trace outcome timeline: each trace where this chunk was `used`, joined
289        // to that trace's outcome row (chunk_id IS NULL, task_ok/task_fail).
290        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        // Feedback timeline: up/down signals carrying their own chunk_id.
318        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        // Merge, newest-first, cap at `limit`. `ts` is the lexicographically
336        // sortable utc_now_iso() format, so string compare == chronological.
337        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        // Aggregate stats counted live from usage_trace (independent of the curate
346        // roll-up, so the panel is accurate before chunk_success_traces is built):
347        // a success/failure is a trace where this chunk was `used` and that trace's
348        // chunk-less outcome row is task_ok / task_fail respectively.
349        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        // Most recent negative signal: feedback_down or a task_fail outcome.
376        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        // Source episodic_log for distilled chunks (distilled_from = episodic_log.id).
390        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    /// Patch content fields on an existing episodic_log row (補写: output_summary, nomination, etc.)
454    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    /// 方案 C / 门3:某 chunk 实际观测到的结果数(只数 provenance='observed' 的
555    /// outcome 证据)。供 appraise 门3「证据充分性」判断邻居是否有观测历史。
556    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    /// 方案 F 门2:返回在给定 context_key(coarse signature 桶)下**有校准历史**的
568    /// chunk 集合。rich 嵌入说「近」的邻居里,有多少在 signature 通道也「近」(有该
569    /// 情境类的观测),低 = rich 嵌入在撒谎(疑似假共振)。
570    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    /// 方案 B:写一条 verdict_log(emit 时)。表态填 valence/conf/strength/tier,
598    /// 弃权填 abstain_reason(其余 NULL)。outcome 列留空,等 record 回填。
599    #[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        // verdict_id is a freshly minted UUID, so there is exactly one row per
613        // appraise — a plain INSERT documents that invariant (no silent OR IGNORE).
614        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    /// 方案 B/H:用 record 的实际结果回填 verdict_log。`provenance` 区分
635    /// 'observed'(真实采取动作并观测到结果,计入校准)与
636    /// 'counterfactual_censored'(因警告回避了动作,**不计入校准**,见原则 3)。
637    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    /// 方案 E:加载校准映射(分桶查表)。返回 (claimed_lo, claimed_hi, observed_rate)。
654    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    /// 方案 E/B:取所有「observed」回填的 (emitted_strength, emitted_conf, hit) 三元组,
673    /// 供 curate 重算校准映射(按 **strength** 分桶,因为 emit 时 `calibrate_confidence`
674    /// 正是用原始 strength 查表)与 inspect 算 ECE(按 **conf** 分桶,衡量声称置信度的
675    /// 真实兑现率)。两者域不同,故同时返回,调用方各取所需。
676    ///
677    /// `hit` = verdict 的关切是否兑现,只对**方向性** verdict 有良定义:
678    ///   affirm → 命中=结果 ok(observed_outcome<0);caution → 命中=结果 fail。
679    /// neutral(无信号)与 mixed(方向歧义)不参与校准 —— 否则把「没表态」误记成
680    /// 「预测失败」,污染校准映射与 ECE。
681    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    /// 方案 B:verdict_log 概览 (total, abstained, with_observed_outcome)。供 inspect 仪表盘。
708    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    /// 方案 E:重写 calibration_map(curate 调用)。`buckets` = (lo, hi, rate, n)。
726    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        // 方案 C:只让观测结果驱动置信度;verdict_derived 证据留痕但不参与重算。
775        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    /// Batch variant of `context_score`: one query for many chunk ids under a
912    /// single context key. Chunks with no stats are absent from the map (score 0).
913    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
954/// Shared scoring math for `context_score` / `context_scores_batch`.
955///
956/// 方案 D —— 基率锚定先验:prior = Beta(α0, β0),α0 = m·g0,β0 = m·(1-g0),
957/// 其中 g0 是全局「好结果」基率、m 是伪观测数(谦逊度旋钮)。证据稀疏时后验回归
958/// 到 g0 而非 0.5。`m=2, g0=0.5` 与旧 Laplace `(wins+1)/(evidence+2)` 完全等价。
959fn 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
977/// Natural-language confidence summary for the Provenance panel, e.g.
978/// `"5 successes, 2 failures → EMA 0.62; last negative feedback 3d ago"`.
979/// `last_negative_at` is an `utc_now_iso()` timestamp; the relative suffix is
980/// omitted when there has never been a negative signal.
981fn 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
1001/// Humanize an `utc_now_iso()` timestamp relative to now, e.g. `"3d ago"`,
1002/// `"5h ago"`, `"2m ago"`, `"just now"`. Returns `None` if the timestamp can't
1003/// be parsed, so callers can drop the clause rather than render garbage.
1004fn 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}