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    pub fn update_episodic_log_state(
277        &self,
278        trace_id: &str,
279        state: &str,
280        note: Option<&str>,
281        outcome: Option<&str>,
282    ) -> Result<()> {
283        self.conn.execute(
284            "UPDATE episodic_log
285             SET distill_state=?, distill_note=COALESCE(?,distill_note),
286                 outcome=COALESCE(?,outcome),
287                 distill_run_id=NULL, distill_locked_at=NULL
288             WHERE trace_id=?",
289            params![state, note, outcome, trace_id],
290        )?;
291        Ok(())
292    }
293
294    /// Patch content fields on an existing episodic_log row (補写: output_summary, nomination, etc.)
295    pub fn patch_episodic_log_content(
296        &self,
297        trace_id: &str,
298        query: Option<&str>,
299        output: Option<&str>,
300        output_summary: Option<&str>,
301        nomination: Option<&str>,
302        priority: i64,
303    ) -> Result<()> {
304        self.conn.execute(
305            "UPDATE episodic_log
306             SET output_summary = COALESCE(?, output_summary),
307                 nomination     = COALESCE(?, nomination),
308                 output         = COALESCE(?, output),
309                 query          = COALESCE(?, query),
310                 priority       = MAX(priority, ?)
311             WHERE trace_id = ?",
312            params![
313                output_summary,
314                nomination,
315                output,
316                query,
317                priority,
318                trace_id
319            ],
320        )?;
321        Ok(())
322    }
323
324    #[allow(clippy::too_many_arguments)]
325    pub fn update_trace_lifecycle(
326        &self,
327        trace_id: &str,
328        task_state: &str,
329        completed_at: Option<&str>,
330        usage_state: Option<&str>,
331        used_ids: Option<&str>,
332        used_attribution: Option<&str>,
333        used_complete: Option<bool>,
334    ) -> Result<()> {
335        self.conn.execute(
336            "UPDATE episodic_log
337             SET task_state=?,
338                 completed_at=COALESCE(?, completed_at),
339                 usage_state=COALESCE(?, usage_state),
340                 used_ids=COALESCE(?, used_ids),
341                 used_attribution=COALESCE(?, used_attribution),
342                 used_complete=COALESCE(?, used_complete)
343             WHERE trace_id=?",
344            params![
345                task_state,
346                completed_at,
347                usage_state,
348                used_ids,
349                used_attribution,
350                used_complete.map(i64::from),
351                trace_id
352            ],
353        )?;
354        Ok(())
355    }
356
357    #[allow(clippy::too_many_arguments)]
358    pub fn upsert_confidence_evidence(
359        &self,
360        id: &str,
361        trace_id: Option<&str>,
362        chunk_id: &str,
363        kind: &str,
364        target: f64,
365        alpha: f64,
366        reason: &str,
367        context_key: Option<&str>,
368        ts: &str,
369        provenance: &str,
370    ) -> Result<()> {
371        self.conn.execute(
372            "INSERT INTO confidence_evidence
373             (id, trace_id, chunk_id, kind, target, alpha, reason, context_key, ts, provenance)
374             VALUES (?,?,?,?,?,?,?,?,?,?)
375             ON CONFLICT(trace_id, chunk_id, kind) WHERE trace_id IS NOT NULL
376             DO UPDATE SET target=excluded.target, alpha=excluded.alpha,
377                           reason=excluded.reason, context_key=excluded.context_key,
378                           provenance=excluded.provenance",
379            params![
380                id,
381                trace_id,
382                chunk_id,
383                kind,
384                target,
385                alpha,
386                reason,
387                context_key,
388                ts,
389                provenance
390            ],
391        )?;
392        Ok(())
393    }
394
395    /// 方案 C / 门3:某 chunk 实际观测到的结果数(只数 provenance='observed' 的
396    /// outcome 证据)。供 appraise 门3「证据充分性」判断邻居是否有观测历史。
397    pub fn observed_outcome_count(&self, chunk_id: &str) -> Result<i64> {
398        let n = self.conn.query_row(
399            "SELECT COUNT(*) FROM confidence_evidence
400             WHERE chunk_id=? AND provenance='observed'
401               AND kind IN ('outcome_ok','outcome_fail')",
402            params![chunk_id],
403            |r| r.get::<_, i64>(0),
404        )?;
405        Ok(n)
406    }
407
408    /// 方案 F 门2:返回在给定 context_key(coarse signature 桶)下**有校准历史**的
409    /// chunk 集合。rich 嵌入说「近」的邻居里,有多少在 signature 通道也「近」(有该
410    /// 情境类的观测),低 = rich 嵌入在撒谎(疑似假共振)。
411    pub fn context_stat_present_batch(
412        &self,
413        chunk_ids: &[&str],
414        context_key: &str,
415    ) -> Result<std::collections::HashSet<String>> {
416        let mut set = std::collections::HashSet::new();
417        if chunk_ids.is_empty() {
418            return Ok(set);
419        }
420        let placeholders = chunk_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
421        let sql = format!(
422            "SELECT chunk_id FROM chunk_context_stats
423             WHERE context_key=? AND chunk_id IN ({placeholders})"
424        );
425        let mut params: Vec<&str> = Vec::with_capacity(chunk_ids.len() + 1);
426        params.push(context_key);
427        params.extend_from_slice(chunk_ids);
428        let mut stmt = self.conn.prepare(&sql)?;
429        let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
430            r.get::<_, String>(0)
431        })?;
432        for row in rows {
433            set.insert(row?);
434        }
435        Ok(set)
436    }
437
438    /// 方案 B:写一条 verdict_log(emit 时)。表态填 valence/conf/strength/tier,
439    /// 弃权填 abstain_reason(其余 NULL)。outcome 列留空,等 record 回填。
440    #[allow(clippy::too_many_arguments)]
441    pub fn insert_verdict_log(
442        &self,
443        verdict_id: &str,
444        trace_id: &str,
445        situation_sig: &str,
446        emitted_valence: Option<&str>,
447        emitted_conf: Option<f64>,
448        emitted_strength: f64,
449        emitted_tier: Option<&str>,
450        abstain_reason: Option<&str>,
451        emitted_at: &str,
452    ) -> Result<()> {
453        // verdict_id is a freshly minted UUID, so there is exactly one row per
454        // appraise — a plain INSERT documents that invariant (no silent OR IGNORE).
455        self.conn.execute(
456            "INSERT INTO verdict_log
457             (verdict_id, trace_id, situation_sig, emitted_valence, emitted_conf,
458              emitted_strength, emitted_tier, abstain_reason, emitted_at)
459             VALUES (?,?,?,?,?,?,?,?,?)",
460            params![
461                verdict_id,
462                trace_id,
463                situation_sig,
464                emitted_valence,
465                emitted_conf,
466                emitted_strength,
467                emitted_tier,
468                abstain_reason,
469                emitted_at
470            ],
471        )?;
472        Ok(())
473    }
474
475    /// 方案 B/H:用 record 的实际结果回填 verdict_log。`provenance` 区分
476    /// 'observed'(真实采取动作并观测到结果,计入校准)与
477    /// 'counterfactual_censored'(因警告回避了动作,**不计入校准**,见原则 3)。
478    pub fn backfill_verdict_outcome(
479        &self,
480        trace_id: &str,
481        observed_outcome: f64,
482        provenance: &str,
483        observed_at: &str,
484    ) -> Result<()> {
485        self.conn.execute(
486            "UPDATE verdict_log
487                SET observed_outcome=?, outcome_observed_at=?, outcome_provenance=?
488              WHERE trace_id=? AND outcome_observed_at IS NULL",
489            params![observed_outcome, observed_at, provenance, trace_id],
490        )?;
491        Ok(())
492    }
493
494    /// 方案 E:加载校准映射(分桶查表)。返回 (claimed_lo, claimed_hi, observed_rate)。
495    pub fn load_calibration_map(&self) -> Result<Vec<(f64, f64, f64)>> {
496        let mut stmt = self.conn.prepare(
497            "SELECT claimed_lo, claimed_hi, observed_rate FROM calibration_map ORDER BY bucket",
498        )?;
499        let rows = stmt.query_map([], |r| {
500            Ok((
501                r.get::<_, f64>(0)?,
502                r.get::<_, f64>(1)?,
503                r.get::<_, f64>(2)?,
504            ))
505        })?;
506        let mut out = Vec::new();
507        for row in rows {
508            out.push(row?);
509        }
510        Ok(out)
511    }
512
513    /// 方案 E/B:取所有「observed」回填的 (emitted_strength, emitted_conf, hit) 三元组,
514    /// 供 curate 重算校准映射(按 **strength** 分桶,因为 emit 时 `calibrate_confidence`
515    /// 正是用原始 strength 查表)与 inspect 算 ECE(按 **conf** 分桶,衡量声称置信度的
516    /// 真实兑现率)。两者域不同,故同时返回,调用方各取所需。
517    ///
518    /// `hit` = verdict 的关切是否兑现,只对**方向性** verdict 有良定义:
519    ///   affirm → 命中=结果 ok(observed_outcome<0);caution → 命中=结果 fail。
520    /// neutral(无信号)与 mixed(方向歧义)不参与校准 —— 否则把「没表态」误记成
521    /// 「预测失败」,污染校准映射与 ECE。
522    pub fn verdict_calibration_samples(&self) -> Result<Vec<(f64, f64, f64)>> {
523        let mut stmt = self.conn.prepare(
524            "SELECT emitted_strength, emitted_conf,
525                    CASE WHEN emitted_valence='affirm'
526                         THEN (CASE WHEN observed_outcome < 0 THEN 1.0 ELSE 0.0 END)
527                         ELSE (CASE WHEN observed_outcome > 0 THEN 1.0 ELSE 0.0 END) END
528               FROM verdict_log
529              WHERE outcome_provenance='observed'
530                AND emitted_conf IS NOT NULL AND emitted_strength IS NOT NULL
531                AND observed_outcome IS NOT NULL
532                AND emitted_valence IN ('affirm','caution')",
533        )?;
534        let rows = stmt.query_map([], |r| {
535            Ok((
536                r.get::<_, f64>(0)?,
537                r.get::<_, f64>(1)?,
538                r.get::<_, f64>(2)?,
539            ))
540        })?;
541        let mut out = Vec::new();
542        for row in rows {
543            out.push(row?);
544        }
545        Ok(out)
546    }
547
548    /// 方案 B:verdict_log 概览 (total, abstained, with_observed_outcome)。供 inspect 仪表盘。
549    pub fn verdict_log_overview(&self) -> Result<(i64, i64, i64)> {
550        let total: i64 = self
551            .conn
552            .query_row("SELECT COUNT(*) FROM verdict_log", [], |r| r.get(0))?;
553        let abstained: i64 = self.conn.query_row(
554            "SELECT COUNT(*) FROM verdict_log WHERE abstain_reason IS NOT NULL",
555            [],
556            |r| r.get(0),
557        )?;
558        let observed: i64 = self.conn.query_row(
559            "SELECT COUNT(*) FROM verdict_log WHERE outcome_provenance='observed'",
560            [],
561            |r| r.get(0),
562        )?;
563        Ok((total, abstained, observed))
564    }
565
566    /// 方案 E:重写 calibration_map(curate 调用)。`buckets` = (lo, hi, rate, n)。
567    pub fn replace_calibration_map(
568        &self,
569        buckets: &[(f64, f64, f64, i64)],
570        now: &str,
571    ) -> Result<()> {
572        self.conn.execute("DELETE FROM calibration_map", [])?;
573        for (i, (lo, hi, rate, n)) in buckets.iter().enumerate() {
574            self.conn.execute(
575                "INSERT INTO calibration_map
576                 (bucket, claimed_lo, claimed_hi, observed_rate, n, updated_at)
577                 VALUES (?,?,?,?,?,?)",
578                params![i as i64, lo, hi, rate, n, now],
579            )?;
580        }
581        Ok(())
582    }
583
584    pub fn delete_trace_confidence_evidence(&self, trace_id: &str, kinds: &[&str]) -> Result<()> {
585        if kinds.is_empty() {
586            return Ok(());
587        }
588        let placeholders = kinds.iter().map(|_| "?").collect::<Vec<_>>().join(",");
589        let sql = format!(
590            "DELETE FROM confidence_evidence WHERE trace_id=? AND kind IN ({placeholders})"
591        );
592        let mut params: Vec<&str> = Vec::with_capacity(kinds.len() + 1);
593        params.push(trace_id);
594        params.extend_from_slice(kinds);
595        self.conn
596            .execute(&sql, rusqlite::params_from_iter(params.iter()))?;
597        Ok(())
598    }
599
600    pub fn delete_chunk_trace_confidence_evidence(
601        &self,
602        trace_id: &str,
603        chunk_id: &str,
604        kind: &str,
605    ) -> Result<()> {
606        self.conn.execute(
607            "DELETE FROM confidence_evidence
608             WHERE trace_id=? AND chunk_id=? AND kind=?",
609            params![trace_id, chunk_id, kind],
610        )?;
611        Ok(())
612    }
613
614    pub fn confidence_evidence_for_chunk(&self, chunk_id: &str) -> Result<Vec<Value>> {
615        // 方案 C:只让观测结果驱动置信度;verdict_derived 证据留痕但不参与重算。
616        self.query_json(
617            "SELECT target, alpha, reason, ts, id
618             FROM confidence_evidence WHERE chunk_id=? AND provenance='observed'
619             ORDER BY ts ASC,
620                      CASE kind
621                        WHEN 'outcome_ok' THEN 1
622                        WHEN 'outcome_fail' THEN 1
623                        WHEN 'selected_unused' THEN 2
624                        WHEN 'feedback_up' THEN 3
625                        WHEN 'feedback_down' THEN 3
626                        WHEN 'decay' THEN 4
627                        ELSE 5
628                      END ASC,
629                      kind ASC, id ASC",
630            [chunk_id],
631        )
632    }
633
634    #[allow(clippy::too_many_arguments)]
635    pub fn insert_feedback_event(
636        &self,
637        id: &str,
638        trace_id: &str,
639        chunk_id: &str,
640        signal: &str,
641        strength: f64,
642        source: &str,
643        actor: Option<&str>,
644        reason: Option<&str>,
645        context_key: Option<&str>,
646        ts: &str,
647    ) -> Result<usize> {
648        Ok(self.conn.execute(
649            "INSERT OR IGNORE INTO feedback_events
650             (id, trace_id, chunk_id, signal, strength, source, actor, reason, context_key, ts)
651             VALUES (?,?,?,?,?,?,?,?,?,?)",
652            params![
653                id,
654                trace_id,
655                chunk_id,
656                signal,
657                strength,
658                source,
659                actor,
660                reason,
661                context_key,
662                ts
663            ],
664        )?)
665    }
666
667    pub fn delete_feedback_event(
668        &self,
669        trace_id: &str,
670        chunk_id: &str,
671        signal: &str,
672    ) -> Result<usize> {
673        Ok(self.conn.execute(
674            "DELETE FROM feedback_events
675             WHERE trace_id=? AND chunk_id=? AND signal=?",
676            params![trace_id, chunk_id, signal],
677        )?)
678    }
679
680    pub fn update_chunk_last_decayed_at(&self, id: &str, now: &str) -> Result<()> {
681        self.conn.execute(
682            "UPDATE chunks SET last_decayed_at=?, updated_at=? WHERE id=?",
683            params![now, now, id],
684        )?;
685        Ok(())
686    }
687
688    #[allow(clippy::too_many_arguments)]
689    pub fn update_context_stat(
690        &self,
691        chunk_id: &str,
692        context_key: &str,
693        success: i64,
694        failure: i64,
695        positive: i64,
696        negative: i64,
697        now: &str,
698    ) -> Result<()> {
699        self.conn.execute(
700            "INSERT INTO chunk_context_stats
701             (chunk_id, context_key, success_count, failure_count,
702              positive_feedback, negative_feedback, last_updated_at)
703             VALUES (?,?,?,?,?,?,?)
704             ON CONFLICT(chunk_id, context_key) DO UPDATE SET
705               success_count=success_count+excluded.success_count,
706               failure_count=failure_count+excluded.failure_count,
707               positive_feedback=positive_feedback+excluded.positive_feedback,
708               negative_feedback=negative_feedback+excluded.negative_feedback,
709               last_updated_at=excluded.last_updated_at",
710            params![
711                chunk_id,
712                context_key,
713                success,
714                failure,
715                positive,
716                negative,
717                now
718            ],
719        )?;
720        Ok(())
721    }
722
723    pub fn context_score(
724        &self,
725        chunk_id: &str,
726        context_key: &str,
727        prior_m: f64,
728        base_rate: f64,
729    ) -> Result<f64> {
730        let mut stmt = self.conn.prepare_cached(
731            "SELECT success_count, failure_count, positive_feedback, negative_feedback
732             FROM chunk_context_stats WHERE chunk_id=? AND context_key=?",
733        )?;
734        let row = stmt
735            .query_row(params![chunk_id, context_key], |row| {
736                Ok((
737                    row.get::<_, i64>(0)?,
738                    row.get::<_, i64>(1)?,
739                    row.get::<_, i64>(2)?,
740                    row.get::<_, i64>(3)?,
741                ))
742            })
743            .optional()?;
744        let Some((success, failure, positive, negative)) = row else {
745            return Ok(0.0);
746        };
747        Ok(context_score_from_counts(
748            success, failure, positive, negative, prior_m, base_rate,
749        ))
750    }
751
752    /// Batch variant of `context_score`: one query for many chunk ids under a
753    /// single context key. Chunks with no stats are absent from the map (score 0).
754    pub fn context_scores_batch(
755        &self,
756        chunk_ids: &[&str],
757        context_key: &str,
758        prior_m: f64,
759        base_rate: f64,
760    ) -> Result<HashMap<String, f64>> {
761        if chunk_ids.is_empty() {
762            return Ok(HashMap::new());
763        }
764        let placeholders = chunk_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
765        let sql = format!(
766            "SELECT chunk_id, success_count, failure_count, positive_feedback, negative_feedback
767             FROM chunk_context_stats
768             WHERE context_key=? AND chunk_id IN ({placeholders})"
769        );
770        let mut params: Vec<&str> = Vec::with_capacity(chunk_ids.len() + 1);
771        params.push(context_key);
772        params.extend_from_slice(chunk_ids);
773        let mut stmt = self.conn.prepare(&sql)?;
774        let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
775            Ok((
776                r.get::<_, String>(0)?,
777                r.get::<_, i64>(1)?,
778                r.get::<_, i64>(2)?,
779                r.get::<_, i64>(3)?,
780                r.get::<_, i64>(4)?,
781            ))
782        })?;
783        let mut map = HashMap::new();
784        for row in rows {
785            let (id, success, failure, positive, negative) = row?;
786            map.insert(
787                id,
788                context_score_from_counts(success, failure, positive, negative, prior_m, base_rate),
789            );
790        }
791        Ok(map)
792    }
793}
794
795/// Shared scoring math for `context_score` / `context_scores_batch`.
796///
797/// 方案 D —— 基率锚定先验:prior = Beta(α0, β0),α0 = m·g0,β0 = m·(1-g0),
798/// 其中 g0 是全局「好结果」基率、m 是伪观测数(谦逊度旋钮)。证据稀疏时后验回归
799/// 到 g0 而非 0.5。`m=2, g0=0.5` 与旧 Laplace `(wins+1)/(evidence+2)` 完全等价。
800fn context_score_from_counts(
801    success: i64,
802    failure: i64,
803    positive: i64,
804    negative: i64,
805    prior_m: f64,
806    base_rate: f64,
807) -> f64 {
808    let wins = success as f64 + positive as f64 * 2.0;
809    let losses = failure as f64 + negative as f64 * 2.0;
810    let evidence = wins + losses;
811    let alpha0 = prior_m * base_rate;
812    let beta0 = prior_m * (1.0 - base_rate);
813    let posterior = (wins + alpha0) / (evidence + alpha0 + beta0);
814    let evidence_weight = (evidence / 5.0).min(1.0);
815    (posterior - 0.5) * 2.0 * evidence_weight
816}