1use serde::{Deserialize, Serialize};
9use surrealdb::Surreal;
10
11use super::error::GraphError;
12use super::store::Db;
13
14#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum OutcomeKind {
18 Success,
19 Partial,
20 Failed,
21}
22
23impl OutcomeKind {
24 #[must_use]
26 pub fn reward(self) -> f64 {
27 match self {
28 Self::Success => 1.0,
29 Self::Partial => 0.5,
30 Self::Failed => 0.0,
31 }
32 }
33}
34
35impl std::fmt::Display for OutcomeKind {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Success => write!(f, "success"),
39 Self::Partial => write!(f, "partial"),
40 Self::Failed => write!(f, "failed"),
41 }
42 }
43}
44
45impl std::str::FromStr for OutcomeKind {
46 type Err = String;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 match s.to_lowercase().as_str() {
50 "success" => Ok(Self::Success),
51 "partial" => Ok(Self::Partial),
52 "failed" => Ok(Self::Failed),
53 other => Err(format!("unknown outcome kind: {other}")),
54 }
55 }
56}
57
58pub const DEFAULT_UTILITY: f64 = 0.5;
60
61const USED_ALPHA: f64 = 0.1;
63
64const UNUSED_ALPHA: f64 = 0.05;
66
67const UNUSED_REWARD: f64 = 0.3;
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct EntityUtility {
73 pub entity_id: String,
74 pub utility_score: f64,
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct FeedbackReport {
80 pub outcome_entity_id: String,
81 pub edges_created: u32,
82 pub entities_updated: u32,
83 #[serde(default)]
86 pub utilities: Vec<EntityUtility>,
87 pub errors: Vec<String>,
88}
89
90#[derive(Debug, Clone, Default, PartialEq)]
96pub struct SessionEntities {
97 pub retrieved: Vec<String>,
98 pub used: Vec<String>,
99}
100
101impl SessionEntities {
102 #[must_use]
104 pub fn is_empty(&self) -> bool {
105 self.retrieved.is_empty()
106 }
107}
108
109pub async fn record_outcome_feedback(
111 db: &Surreal<Db>,
112 session_id: &str,
113 outcome: OutcomeKind,
114 retrieved_entity_ids: &[String],
115 used_entity_ids: Option<&[String]>,
116) -> Result<FeedbackReport, GraphError> {
117 let mut report = FeedbackReport::default();
118
119 if retrieved_entity_ids.is_empty() {
120 return Ok(report);
121 }
122
123 let result = ContributionResult::Resolved(outcome);
124 let outcome_id = outcome_entity_for_session(db, session_id, result).await?;
125 report.outcome_entity_id = outcome_id.clone();
126
127 let reward = outcome.reward();
128
129 let used_set: Option<std::collections::HashSet<&str>> =
131 used_entity_ids.map(|ids| ids.iter().map(|s| s.as_str()).collect());
132
133 let outcome_id_ref = &outcome_id;
135 let futures: Vec<_> = retrieved_entity_ids
136 .iter()
137 .map(|entity_id| {
138 let was_used = used_set
139 .as_ref()
140 .map(|s| s.contains(entity_id.as_str()))
141 .unwrap_or(true);
142 let (alpha, effective_reward) = if was_used {
143 (USED_ALPHA, reward)
144 } else {
145 (UNUSED_ALPHA, UNUSED_REWARD)
146 };
147
148 async move {
149 let edge_result = create_contribution_edge(
150 db,
151 entity_id,
152 outcome_id_ref,
153 result,
154 was_used,
155 session_id,
156 )
157 .await;
158 let utility_result =
159 update_utility_score(db, entity_id, effective_reward, alpha).await;
160 let score = get_utility_score(db, entity_id).await;
161 (entity_id, edge_result, utility_result, score)
162 }
163 })
164 .collect();
165
166 let results = futures::future::join_all(futures).await;
167
168 for (entity_id, edge_result, utility_result, score) in results {
169 match edge_result {
170 Ok(()) => report.edges_created += 1,
171 Err(e) => {
172 report
173 .errors
174 .push(format!("edge {entity_id} -> {outcome_id}: {e}"));
175 }
176 }
177 match utility_result {
178 Ok(()) => report.entities_updated += 1,
179 Err(e) => {
180 report
181 .errors
182 .push(format!("utility update {entity_id}: {e}"));
183 }
184 }
185 if let Ok(utility_score) = score {
186 report.utilities.push(EntityUtility {
187 entity_id: entity_id.clone(),
188 utility_score,
189 });
190 }
191 }
192
193 Ok(report)
194}
195
196pub async fn record_session_use(
207 db: &Surreal<Db>,
208 session_id: &str,
209 entity_ids: &[String],
210) -> Result<u32, GraphError> {
211 if entity_ids.is_empty() {
212 return Ok(0);
213 }
214
215 let outcome_id =
216 outcome_entity_for_session(db, session_id, ContributionResult::Pending).await?;
217
218 let mut recorded = 0;
219 for entity_id in entity_ids {
220 create_contribution_edge(
221 db,
222 entity_id,
223 &outcome_id,
224 ContributionResult::Pending,
225 true,
226 session_id,
227 )
228 .await?;
229 recorded += 1;
230 }
231
232 Ok(recorded)
233}
234
235pub async fn session_entities(
241 db: &Surreal<Db>,
242 session_id: &str,
243) -> Result<SessionEntities, GraphError> {
244 #[derive(Deserialize)]
245 struct EdgeRow {
246 #[serde(rename = "in")]
247 entity: serde_json::Value,
248 #[serde(default = "default_was_used")]
249 was_used: bool,
250 }
251
252 fn default_was_used() -> bool {
253 true
254 }
255
256 let mut response = db
257 .query("SELECT in, was_used FROM contributed_to WHERE session_id = $sid")
258 .bind(("sid", session_id.to_string()))
259 .await?;
260
261 let rows: Vec<EdgeRow> = super::deserialize_take(&mut response, 0)?;
262 if !rows.is_empty() {
263 let mut session = SessionEntities::default();
264 for row in rows {
265 let id = record_id_string(&row.entity);
266 if session.retrieved.contains(&id) {
267 continue;
268 }
269 if row.was_used {
270 session.used.push(id.clone());
271 }
272 session.retrieved.push(id);
273 }
274 return Ok(session);
275 }
276
277 let mut response = db
278 .query("SELECT id FROM entity WHERE source = $sid")
279 .bind(("sid", session_id.to_string()))
280 .await?;
281
282 #[derive(Deserialize)]
283 struct IdRow {
284 id: serde_json::Value,
285 }
286
287 let rows: Vec<IdRow> = super::deserialize_take(&mut response, 0)?;
288 let retrieved: Vec<String> = rows.iter().map(|r| record_id_string(&r.id)).collect();
289
290 Ok(SessionEntities {
291 used: retrieved.clone(),
292 retrieved,
293 })
294}
295
296fn record_id_string(value: &serde_json::Value) -> String {
298 match value {
299 serde_json::Value::String(s) => s.clone(),
300 other => other.to_string(),
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq)]
310enum ContributionResult {
311 Pending,
312 Resolved(OutcomeKind),
313}
314
315impl std::fmt::Display for ContributionResult {
316 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317 match self {
318 Self::Pending => write!(f, "pending"),
319 Self::Resolved(outcome) => write!(f, "{outcome}"),
320 }
321 }
322}
323
324async fn outcome_entity_for_session(
330 db: &Surreal<Db>,
331 session_id: &str,
332 result: ContributionResult,
333) -> Result<String, GraphError> {
334 match find_outcome_entity(db, session_id).await? {
335 Some(id) => {
336 update_outcome_entity(db, &id, session_id, result).await?;
337 Ok(id)
338 }
339 None => create_outcome_entity(db, session_id, result).await,
340 }
341}
342
343async fn find_outcome_entity(
344 db: &Surreal<Db>,
345 session_id: &str,
346) -> Result<Option<String>, GraphError> {
347 #[derive(Deserialize)]
348 struct IdRow {
349 id: serde_json::Value,
350 }
351
352 let mut response = db
353 .query(
354 r#"SELECT id FROM entity
355 WHERE entity_type = "outcome" AND attributes.session_id = $sid
356 LIMIT 1"#,
357 )
358 .bind(("sid", session_id.to_string()))
359 .await?;
360
361 let rows: Vec<IdRow> = super::deserialize_take(&mut response, 0)?;
362 Ok(rows.first().map(|r| record_id_string(&r.id)))
363}
364
365async fn update_outcome_entity(
366 db: &Surreal<Db>,
367 outcome_id: &str,
368 session_id: &str,
369 result: ContributionResult,
370) -> Result<(), GraphError> {
371 db.query(
372 r#"UPDATE type::record($id) SET
373 abstract = $abstract,
374 attributes = $attributes,
375 updated_at = time::now()"#,
376 )
377 .bind(("id", outcome_id.to_string()))
378 .bind(("abstract", outcome_abstract(session_id, result)))
379 .bind(("attributes", outcome_attributes(session_id, result)))
380 .await?
381 .check()?;
382
383 Ok(())
384}
385
386fn outcome_abstract(session_id: &str, result: ContributionResult) -> String {
387 format!("Session {session_id} outcome: {result}")
388}
389
390fn outcome_attributes(session_id: &str, result: ContributionResult) -> serde_json::Value {
391 serde_json::json!({
392 "outcome_result": result.to_string(),
393 "session_id": session_id,
394 })
395}
396
397async fn create_outcome_entity(
398 db: &Surreal<Db>,
399 session_id: &str,
400 outcome: ContributionResult,
401) -> Result<String, GraphError> {
402 let abstract_text = outcome_abstract(session_id, outcome);
403
404 let mut response = db
405 .query(
406 r#"
407 CREATE entity SET
408 name = $name,
409 entity_type = "outcome",
410 abstract = $abstract,
411 overview = "",
412 content = NONE,
413 attributes = $attributes,
414 embedding = NONE,
415 mutable = false,
416 access_count = 0,
417 utility_score = $utility,
418 utility_updates = 0,
419 created_at = time::now(),
420 updated_at = time::now(),
421 source = $source
422 "#,
423 )
424 .bind(("name", format!("outcome-{session_id}")))
425 .bind(("abstract", abstract_text))
426 .bind(("attributes", outcome_attributes(session_id, outcome)))
427 .bind(("utility", DEFAULT_UTILITY))
428 .bind(("source", format!("caliber:{session_id}")))
429 .await?;
430
431 let entity: Option<super::types::Entity> = super::deserialize_take_opt(&mut response, 0)?;
432 let entity = entity.ok_or_else(|| {
433 GraphError::Db(surrealdb::Error::thrown(
434 "failed to create outcome entity".into(),
435 ))
436 })?;
437
438 Ok(entity.id_string())
439}
440
441async fn create_contribution_edge(
448 db: &Surreal<Db>,
449 entity_id: &str,
450 outcome_id: &str,
451 outcome: ContributionResult,
452 was_used: bool,
453 session_id: &str,
454) -> Result<(), GraphError> {
455 db.query(
456 r#"
457 LET $from = type::record($from_id);
458 LET $to = type::record($to_id);
459 DELETE contributed_to WHERE in = $from AND session_id = $session_id;
460 RELATE $from -> contributed_to -> $to SET
461 outcome_result = $outcome_result,
462 was_used = $was_used,
463 session_id = $session_id,
464 timestamp = time::now()
465 "#,
466 )
467 .bind(("from_id", entity_id.to_string()))
468 .bind(("to_id", outcome_id.to_string()))
469 .bind(("outcome_result", outcome.to_string()))
470 .bind(("was_used", was_used))
471 .bind(("session_id", session_id.to_string()))
472 .await?
473 .check()?;
474
475 Ok(())
476}
477
478async fn update_utility_score(
480 db: &Surreal<Db>,
481 entity_id: &str,
482 reward: f64,
483 alpha: f64,
484) -> Result<(), GraphError> {
485 db.query(
489 r#"
490 LET $raw = (1.0 - $alpha) * type::record($id).utility_score + $alpha * $reward;
491 LET $clamped = IF $raw < 0.0 THEN 0.0 ELSE IF $raw > 1.0 THEN 1.0 ELSE $raw END;
492 UPDATE type::record($id) SET
493 utility_score = $clamped,
494 utility_updates += 1,
495 updated_at = time::now()
496 "#,
497 )
498 .bind(("id", entity_id.to_string()))
499 .bind(("alpha", alpha))
500 .bind(("reward", reward))
501 .await?
502 .check()?;
503
504 Ok(())
505}
506
507pub async fn get_utility_score(db: &Surreal<Db>, entity_id: &str) -> Result<f64, GraphError> {
509 #[derive(Deserialize)]
510 struct Row {
511 #[serde(default = "default_util")]
512 utility_score: f64,
513 }
514
515 fn default_util() -> f64 {
516 DEFAULT_UTILITY
517 }
518
519 let mut response = db
520 .query("SELECT utility_score FROM type::record($id)")
521 .bind(("id", entity_id.to_string()))
522 .await?;
523
524 let rows: Vec<Row> = super::deserialize_take(&mut response, 0)?;
525
526 Ok(rows
527 .first()
528 .map(|r| r.utility_score)
529 .unwrap_or(DEFAULT_UTILITY))
530}
531
532#[derive(Debug, Clone, Default)]
534pub struct ContributionStats {
535 pub total_contributions: u32,
536 pub successes: u32,
537 pub partials: u32,
538 pub failures: u32,
539 pub times_used: u32,
540 pub times_ignored: u32,
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn outcome_kind_reward_values() {
549 assert_eq!(OutcomeKind::Success.reward(), 1.0);
550 assert_eq!(OutcomeKind::Partial.reward(), 0.5);
551 assert_eq!(OutcomeKind::Failed.reward(), 0.0);
552 }
553
554 #[test]
555 fn outcome_kind_roundtrip() {
556 for kind in [
557 OutcomeKind::Success,
558 OutcomeKind::Partial,
559 OutcomeKind::Failed,
560 ] {
561 let s = kind.to_string();
562 let parsed: OutcomeKind = s.parse().unwrap();
563 assert_eq!(parsed, kind);
564 }
565 assert!("unknown".parse::<OutcomeKind>().is_err());
566 }
567
568 #[test]
569 fn ema_update_math() {
570 let current: f64 = 0.5;
571 let alpha: f64 = 0.1;
572
573 let success = (1.0 - alpha) * current + alpha * 1.0;
574 assert!((success - 0.55).abs() < 0.001);
575
576 let partial = (1.0 - alpha) * current + alpha * 0.5;
577 assert!((partial - 0.5).abs() < 0.001);
578
579 let failed = (1.0 - alpha) * current + alpha * 0.0;
580 assert!((failed - 0.45).abs() < 0.001);
581 }
582
583 #[test]
584 fn ema_converges() {
585 let mut score = 0.5;
586 for _ in 0..50 {
587 score = (1.0 - USED_ALPHA) * score + USED_ALPHA * 1.0;
588 }
589 assert!(score > 0.99);
590
591 let mut score = 0.5;
592 for _ in 0..50 {
593 score = (1.0 - USED_ALPHA) * score + USED_ALPHA * 0.0;
594 }
595 assert!(score < 0.01);
596 }
597
598 #[test]
599 fn pending_contribution_reads_as_unadjudicated() {
600 assert_eq!(ContributionResult::Pending.to_string(), "pending");
601 assert_eq!(
602 ContributionResult::Resolved(OutcomeKind::Success).to_string(),
603 "success"
604 );
605 assert_eq!(
606 outcome_abstract("s1", ContributionResult::Pending),
607 "Session s1 outcome: pending"
608 );
609 assert_eq!(
610 outcome_attributes("s1", ContributionResult::Resolved(OutcomeKind::Failed)),
611 serde_json::json!({"outcome_result": "failed", "session_id": "s1"})
612 );
613 }
614
615 #[test]
616 fn session_with_no_entities_is_empty() {
617 assert!(SessionEntities::default().is_empty());
618 assert!(!SessionEntities {
619 retrieved: vec!["entity:a".into()],
620 used: vec![],
621 }
622 .is_empty());
623 }
624
625 #[test]
626 fn record_ids_render_as_table_colon_id() {
627 assert_eq!(
628 record_id_string(&serde_json::json!("entity:abc")),
629 "entity:abc"
630 );
631 assert_eq!(record_id_string(&serde_json::json!(42)), "42");
632 }
633
634 #[test]
635 fn feedback_report_crosses_the_wire() {
636 let report = FeedbackReport {
637 outcome_entity_id: "entity:outcome".into(),
638 edges_created: 2,
639 entities_updated: 2,
640 utilities: vec![EntityUtility {
641 entity_id: "entity:a".into(),
642 utility_score: 0.55,
643 }],
644 errors: vec![],
645 };
646 let json = serde_json::to_value(&report).expect("serialize");
647 let parsed: FeedbackReport = serde_json::from_value(json).expect("deserialize");
648 assert_eq!(parsed.utilities, report.utilities);
649 assert_eq!(parsed.entities_updated, 2);
650 }
651
652 #[test]
653 fn unused_entity_gets_weaker_signal() {
654 let current = 0.5;
655 let used_step = (1.0 - USED_ALPHA) * current + USED_ALPHA * 1.0;
656 let unused_step = (1.0 - UNUSED_ALPHA) * current + UNUSED_ALPHA * UNUSED_REWARD;
657
658 assert!(used_step > current);
659 assert!(unused_step < current);
660 }
661}