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