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 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 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 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 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 #[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 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 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 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 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 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 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 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 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
795fn 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}