Skip to main content

recall_echo/graph/
utility.rs

1//! Outcome feedback loop for adaptive entity learning.
2//!
3//! Tracks which graph entities contributed to session outcomes (success/partial/failure)
4//! and adjusts their `utility_score` via exponential moving average.
5//!
6//! Phase 1 of Adaptive Entity Learning v2.
7
8use serde::{Deserialize, Serialize};
9use surrealdb::Surreal;
10
11use super::error::GraphError;
12use super::store::Db;
13
14/// The result of a task or session outcome.
15#[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    /// Numeric reward signal for EMA update.
25    #[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
58/// Default utility score for new entities.
59pub const DEFAULT_UTILITY: f64 = 0.5;
60
61/// EMA alpha for entities that were retrieved AND used.
62const USED_ALPHA: f64 = 0.1;
63
64/// Smaller EMA alpha for entities that were retrieved but not used.
65const UNUSED_ALPHA: f64 = 0.05;
66
67/// Reward override for "retrieved but not used" — slight negative signal.
68const UNUSED_REWARD: f64 = 0.3;
69
70/// One entity's utility score after an outcome was applied to it.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct EntityUtility {
73    pub entity_id: String,
74    pub utility_score: f64,
75}
76
77/// Report from a feedback recording operation.
78#[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    /// Post-update utility of every entity the outcome reached — the
84    /// observable half of the feedback loop.
85    #[serde(default)]
86    pub utilities: Vec<EntityUtility>,
87    pub errors: Vec<String>,
88}
89
90/// The entities one session is known to have touched.
91///
92/// `retrieved` is everything linked to the session; `used` is the subset the
93/// session's own records say it actually leaned on. Entities in `retrieved`
94/// but not `used` get the muted "retrieved and ignored" signal.
95#[derive(Debug, Clone, Default, PartialEq)]
96pub struct SessionEntities {
97    pub retrieved: Vec<String>,
98    pub used: Vec<String>,
99}
100
101impl SessionEntities {
102    /// True when the session has no entities to apply an outcome to.
103    #[must_use]
104    pub fn is_empty(&self) -> bool {
105        self.retrieved.is_empty()
106    }
107}
108
109/// Record outcome feedback: link retrieved entities to an outcome and update utility scores.
110pub 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    // Build a HashSet for O(1) "was used" lookups instead of O(n) per entity
130    let used_set: Option<std::collections::HashSet<&str>> =
131        used_entity_ids.map(|ids| ids.iter().map(|s| s.as_str()).collect());
132
133    // Process all entities concurrently — each entity's feedback is independent
134    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
196/// Record that a session touched these entities, without judging the outcome.
197///
198/// The passive half of the feedback loop: ingestion knows which entities a
199/// session produced or reinforced, and says so here. A later
200/// `graph feedback <session>` supplies the outcome and this is what tells it
201/// which entities the outcome applies to.
202///
203/// Idempotent per session: re-ingesting a session rewrites its records rather
204/// than accumulating duplicates. Utility scores are untouched — an
205/// unadjudicated session is not evidence of usefulness.
206pub 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
235/// The entities a session touched, as its `contributed_to` records tell it.
236///
237/// Falls back to the entities the session authored (`source = session_id`)
238/// when no records exist — the shape of a store whose sessions were ingested
239/// before passive recording, where authorship is the only session link.
240pub 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
296/// Render a record ID value as `table:id`.
297fn 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/// What a contribution record says about its session so far.
305///
306/// A session is linked to its entities the moment ingestion knows about them,
307/// which is before anyone has judged how it went. `Pending` is that state; it
308/// carries no reward and never moves a utility score.
309#[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
324/// The session's outcome entity, created on first use and reused after.
325///
326/// One outcome record per session, whatever order the passive linkage and the
327/// adjudicated outcome arrive in: re-running feedback for a session corrects
328/// its record rather than growing a second one.
329async 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
441/// Write one entity's contribution record for a session, replacing any
442/// earlier record for the same pair.
443///
444/// One record per entity per session: the passive "this session touched it"
445/// marker and the adjudicated outcome are the same fact learned twice, not
446/// two contributions.
447async 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
478/// Atomic EMA update — single query, no read-modify-write race.
479async fn update_utility_score(
480    db: &Surreal<Db>,
481    entity_id: &str,
482    reward: f64,
483    alpha: f64,
484) -> Result<(), GraphError> {
485    // Inline EMA: new = (1 - alpha) * current + alpha * reward, clamped to [0, 1].
486    // SurrealDB has no clamp, so the bounds are an IF chain — one chain, one
487    // `END`, whatever the branch count.
488    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
507/// Get the current utility score for an entity.
508pub 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/// Get aggregate contribution stats for an entity.
533#[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}