Skip to main content

recall_echo/graph/
utility.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Outcome feedback loop for adaptive entity learning.
6//!
7//! Tracks which graph entities contributed to session outcomes (success/partial/failure)
8//! and adjusts their `utility_score` via exponential moving average.
9//!
10//! Phase 1 of Adaptive Entity Learning v2.
11
12use serde::{Deserialize, Serialize};
13use surrealdb::Surreal;
14
15use super::error::GraphError;
16use super::store::Db;
17
18/// The result of a task or session outcome.
19#[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    /// Numeric reward signal for EMA update.
29    #[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
62/// Default utility score for new entities.
63pub const DEFAULT_UTILITY: f64 = 0.5;
64
65/// EMA alpha for entities that were retrieved AND used.
66const USED_ALPHA: f64 = 0.1;
67
68/// Smaller EMA alpha for entities that were retrieved but not used.
69const UNUSED_ALPHA: f64 = 0.05;
70
71/// Reward override for "retrieved but not used" — slight negative signal.
72const UNUSED_REWARD: f64 = 0.3;
73
74/// One entity's utility score after an outcome was applied to it.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct EntityUtility {
77    pub entity_id: String,
78    pub utility_score: f64,
79}
80
81/// Report from a feedback recording operation.
82#[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    /// Post-update utility of every entity the outcome reached — the
88    /// observable half of the feedback loop.
89    #[serde(default)]
90    pub utilities: Vec<EntityUtility>,
91    pub errors: Vec<String>,
92}
93
94/// The entities one session is known to have touched.
95///
96/// `retrieved` is everything linked to the session; `used` is the subset the
97/// session's own records say it actually leaned on. Entities in `retrieved`
98/// but not `used` get the muted "retrieved and ignored" signal.
99#[derive(Debug, Clone, Default, PartialEq)]
100pub struct SessionEntities {
101    pub retrieved: Vec<String>,
102    pub used: Vec<String>,
103}
104
105impl SessionEntities {
106    /// True when the session has no entities to apply an outcome to.
107    #[must_use]
108    pub fn is_empty(&self) -> bool {
109        self.retrieved.is_empty()
110    }
111}
112
113/// Record outcome feedback: link retrieved entities to an outcome and update utility scores.
114pub 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    // Build a HashSet for O(1) "was used" lookups instead of O(n) per entity
134    let used_set: Option<std::collections::HashSet<&str>> =
135        used_entity_ids.map(|ids| ids.iter().map(|s| s.as_str()).collect());
136
137    // Process all entities concurrently — each entity's feedback is independent
138    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
200/// Record that a session touched these entities, without judging the outcome.
201///
202/// The passive half of the feedback loop: ingestion knows which entities a
203/// session produced or reinforced, and says so here. A later
204/// `graph feedback <session>` supplies the outcome and this is what tells it
205/// which entities the outcome applies to.
206///
207/// Idempotent per session: re-ingesting a session rewrites its records rather
208/// than accumulating duplicates. Utility scores are untouched — an
209/// unadjudicated session is not evidence of usefulness.
210pub 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
239/// The entities a session touched, as its `contributed_to` records tell it.
240///
241/// Falls back to the entities the session authored (`source = session_id`)
242/// when no records exist — the shape of a store whose sessions were ingested
243/// before passive recording, where authorship is the only session link.
244pub 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
300/// Render a record ID value as `table:id`.
301fn 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/// What a contribution record says about its session so far.
309///
310/// A session is linked to its entities the moment ingestion knows about them,
311/// which is before anyone has judged how it went. `Pending` is that state; it
312/// carries no reward and never moves a utility score.
313#[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
328/// The session's outcome entity, created on first use and reused after.
329///
330/// One outcome record per session, whatever order the passive linkage and the
331/// adjudicated outcome arrive in: re-running feedback for a session corrects
332/// its record rather than growing a second one.
333async 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
445/// Write one entity's contribution record for a session, replacing any
446/// earlier record for the same pair.
447///
448/// One record per entity per session: the passive "this session touched it"
449/// marker and the adjudicated outcome are the same fact learned twice, not
450/// two contributions.
451async 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
482/// Atomic EMA update — single query, no read-modify-write race.
483async fn update_utility_score(
484    db: &Surreal<Db>,
485    entity_id: &str,
486    reward: f64,
487    alpha: f64,
488) -> Result<(), GraphError> {
489    // Inline EMA: new = (1 - alpha) * current + alpha * reward, clamped to [0, 1].
490    // SurrealDB has no clamp, so the bounds are an IF chain — one chain, one
491    // `END`, whatever the branch count.
492    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
511/// Get the current utility score for an entity.
512pub 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/// Get aggregate contribution stats for an entity.
537#[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}