Skip to main content

remem/memory/
lesson.rs

1use anyhow::{Context, Result};
2use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
3
4use super::promote::slugify_for_topic;
5use super::types::{map_memory_row_pub, Memory, MEMORY_COLS};
6
7const MIN_CONFIDENCE_FOR_CONTEXT: f64 = 0.5;
8
9#[derive(Debug, Clone)]
10pub struct LessonMetadata {
11    pub memory_id: i64,
12    pub confidence: f64,
13    pub reinforcement_count: i64,
14    pub source_evidence: Option<String>,
15    pub last_reinforced_at_epoch: i64,
16    pub stale_after_epoch: Option<i64>,
17    pub outcome_kind: String,
18    pub success_count: i64,
19    pub failure_count: i64,
20    pub recovery_count: i64,
21    pub correction_count: i64,
22    pub revert_count: i64,
23}
24
25#[derive(Debug, Clone)]
26pub struct LessonMemory {
27    pub memory: Memory,
28    pub metadata: LessonMetadata,
29}
30
31#[derive(Debug, Clone)]
32pub struct SaveLessonRequest<'a> {
33    pub session_id: Option<&'a str>,
34    pub project: &'a str,
35    pub topic_key: Option<&'a str>,
36    pub title: &'a str,
37    pub content: &'a str,
38    pub confidence: f64,
39    pub source_evidence: Option<&'a str>,
40    pub files: Option<&'a str>,
41    pub branch: Option<&'a str>,
42    pub scope: &'a str,
43    pub created_at_epoch: Option<i64>,
44    pub stale_after_epoch: Option<i64>,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum LessonOutcomeKind {
49    Unknown,
50    Success,
51    Failure,
52    Recovery,
53    Correction,
54    Revert,
55}
56
57impl LessonOutcomeKind {
58    fn as_str(self) -> &'static str {
59        match self {
60            Self::Unknown => "unknown",
61            Self::Success => "success",
62            Self::Failure => "failure",
63            Self::Recovery => "recovery",
64            Self::Correction => "correction",
65            Self::Revert => "revert",
66        }
67    }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct LessonOutcomeUpdate {
72    kind: LessonOutcomeKind,
73    success_delta: i64,
74    failure_delta: i64,
75    recovery_delta: i64,
76    correction_delta: i64,
77    revert_delta: i64,
78}
79
80impl LessonOutcomeUpdate {
81    pub fn unknown() -> Self {
82        Self {
83            kind: LessonOutcomeKind::Unknown,
84            success_delta: 0,
85            failure_delta: 0,
86            recovery_delta: 0,
87            correction_delta: 0,
88            revert_delta: 0,
89        }
90    }
91
92    pub fn success() -> Self {
93        Self {
94            kind: LessonOutcomeKind::Success,
95            success_delta: 1,
96            ..Self::unknown()
97        }
98    }
99
100    pub fn failure() -> Self {
101        Self {
102            kind: LessonOutcomeKind::Failure,
103            failure_delta: 1,
104            ..Self::unknown()
105        }
106    }
107
108    pub fn recovery() -> Self {
109        Self {
110            kind: LessonOutcomeKind::Recovery,
111            recovery_delta: 1,
112            ..Self::unknown()
113        }
114    }
115
116    pub fn correction() -> Self {
117        Self {
118            kind: LessonOutcomeKind::Correction,
119            correction_delta: 1,
120            ..Self::unknown()
121        }
122    }
123
124    pub fn revert() -> Self {
125        Self {
126            kind: LessonOutcomeKind::Revert,
127            revert_delta: 1,
128            ..Self::unknown()
129        }
130    }
131}
132
133pub fn save_lesson(conn: &Connection, req: &SaveLessonRequest<'_>) -> Result<i64> {
134    save_lesson_with_reference_time(conn, req, req.created_at_epoch)
135}
136
137pub fn save_lesson_with_outcome(
138    conn: &Connection,
139    req: &SaveLessonRequest<'_>,
140    outcome: LessonOutcomeUpdate,
141) -> Result<i64> {
142    save_lesson_with_reference_time_and_outcome(conn, req, req.created_at_epoch, outcome)
143}
144
145pub fn save_lesson_with_reference_time(
146    conn: &Connection,
147    req: &SaveLessonRequest<'_>,
148    reference_time_epoch: Option<i64>,
149) -> Result<i64> {
150    save_lesson_with_reference_time_and_outcome(
151        conn,
152        req,
153        reference_time_epoch,
154        LessonOutcomeUpdate::unknown(),
155    )
156}
157
158fn save_lesson_with_reference_time_and_outcome(
159    conn: &Connection,
160    req: &SaveLessonRequest<'_>,
161    reference_time_epoch: Option<i64>,
162    outcome: LessonOutcomeUpdate,
163) -> Result<i64> {
164    if !conn.is_autocommit() {
165        conn.execute(
166            "UPDATE memory_activation_requests
167             SET activation_id = activation_id WHERE 0",
168            [],
169        )
170        .context("serialize lesson save inside caller transaction")?;
171        return crate::memory::operation::with_operation_savepoint(conn, || {
172            save_lesson_with_reference_time_and_outcome_inner(
173                conn,
174                None,
175                req,
176                reference_time_epoch,
177                outcome,
178            )
179        });
180    }
181
182    let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
183        .context("begin serialized lesson save")?;
184    match save_lesson_with_reference_time_and_outcome_inner(
185        &tx,
186        None,
187        req,
188        reference_time_epoch,
189        outcome,
190    ) {
191        Ok(id) => {
192            tx.commit().context("commit serialized lesson save")?;
193            Ok(id)
194        }
195        Err(error) => match tx.rollback() {
196            Ok(()) => Err(error),
197            Err(rollback_error) => Err(error.context(format!(
198                "lesson save transaction rollback also failed: {rollback_error}"
199            ))),
200        },
201    }
202}
203
204pub(crate) fn save_lesson_with_reference_time_activated(
205    conn: &Connection,
206    permit: &crate::memory::activation::ActiveMemoryWritePermit,
207    req: &SaveLessonRequest<'_>,
208    reference_time_epoch: Option<i64>,
209) -> Result<i64> {
210    save_lesson_with_reference_time_and_outcome_inner(
211        conn,
212        Some(permit),
213        req,
214        reference_time_epoch,
215        LessonOutcomeUpdate::unknown(),
216    )
217}
218
219fn save_lesson_with_reference_time_and_outcome_inner(
220    conn: &Connection,
221    permit: Option<&crate::memory::activation::ActiveMemoryWritePermit>,
222    req: &SaveLessonRequest<'_>,
223    reference_time_epoch: Option<i64>,
224    outcome: LessonOutcomeUpdate,
225) -> Result<i64> {
226    validate_outcome_update(outcome)?;
227    let topic_key = req
228        .topic_key
229        .map(str::to_string)
230        .unwrap_or_else(|| format!("lesson-{}", slugify_for_topic(req.content, 64)));
231    let scope = if req.scope.trim().is_empty() {
232        "project"
233    } else {
234        req.scope
235    };
236    let existing_id = existing_lesson_id(conn, req.project, &topic_key, scope)?;
237    let id = if let Some(permit) = permit {
238        crate::memory::store::insert_memory_full_activated(
239            conn,
240            permit,
241            req.session_id,
242            req.project,
243            Some(&topic_key),
244            req.title,
245            req.content,
246            "lesson",
247            req.files,
248            req.branch,
249            scope,
250            req.created_at_epoch,
251            reference_time_epoch,
252        )?
253    } else {
254        crate::memory::insert_memory_full_with_reference_time(
255            conn,
256            req.session_id,
257            req.project,
258            Some(&topic_key),
259            req.title,
260            req.content,
261            "lesson",
262            req.files,
263            req.branch,
264            scope,
265            req.created_at_epoch,
266            reference_time_epoch,
267        )?
268    };
269    let metadata_exists = get_lesson_metadata(conn, id)?.is_some();
270    upsert_lesson_metadata(
271        conn,
272        id,
273        req,
274        existing_id.is_some() || metadata_exists,
275        outcome,
276    )?;
277    Ok(id)
278}
279
280fn validate_outcome_update(outcome: LessonOutcomeUpdate) -> Result<()> {
281    for (name, value) in [
282        ("success_delta", outcome.success_delta),
283        ("failure_delta", outcome.failure_delta),
284        ("recovery_delta", outcome.recovery_delta),
285        ("correction_delta", outcome.correction_delta),
286        ("revert_delta", outcome.revert_delta),
287    ] {
288        if value < 0 {
289            anyhow::bail!("lesson outcome {name} must be non-negative");
290        }
291    }
292    Ok(())
293}
294
295fn existing_lesson_id(
296    conn: &Connection,
297    project: &str,
298    topic_key: &str,
299    scope: &str,
300) -> Result<Option<i64>> {
301    let id = conn
302        .query_row(
303            "SELECT id FROM memories
304             WHERE project = ?1
305               AND topic_key = ?2
306               AND COALESCE(scope, 'project') = ?3
307               AND memory_type = 'lesson'
308             LIMIT 1",
309            params![project, topic_key, scope],
310            |row| row.get(0),
311        )
312        .optional()?;
313    Ok(id)
314}
315
316fn upsert_lesson_metadata(
317    conn: &Connection,
318    memory_id: i64,
319    req: &SaveLessonRequest<'_>,
320    existed: bool,
321    outcome: LessonOutcomeUpdate,
322) -> Result<()> {
323    let now = chrono::Utc::now().timestamp();
324    let confidence = req.confidence.clamp(0.0, 1.0);
325    let reinforcement_delta = if existed { 1 } else { 0 };
326    conn.execute(
327        "INSERT INTO memory_lessons
328         (memory_id, confidence, reinforcement_count, source_evidence,
329          last_reinforced_at_epoch, stale_after_epoch, outcome_kind,
330          success_count, failure_count, recovery_count, correction_count, revert_count)
331         VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
332         ON CONFLICT(memory_id) DO UPDATE SET
333           confidence = MAX(memory_lessons.confidence, excluded.confidence),
334           reinforcement_count = memory_lessons.reinforcement_count + ?12,
335           source_evidence = COALESCE(excluded.source_evidence, memory_lessons.source_evidence),
336           last_reinforced_at_epoch = excluded.last_reinforced_at_epoch,
337           stale_after_epoch = excluded.stale_after_epoch,
338           outcome_kind = CASE
339             WHEN excluded.outcome_kind != 'unknown' THEN excluded.outcome_kind
340             ELSE memory_lessons.outcome_kind
341           END,
342           success_count = memory_lessons.success_count + excluded.success_count,
343           failure_count = memory_lessons.failure_count + excluded.failure_count,
344           recovery_count = memory_lessons.recovery_count + excluded.recovery_count,
345           correction_count = memory_lessons.correction_count + excluded.correction_count,
346           revert_count = memory_lessons.revert_count + excluded.revert_count",
347        params![
348            memory_id,
349            confidence,
350            req.source_evidence,
351            now,
352            req.stale_after_epoch,
353            outcome.kind.as_str(),
354            outcome.success_delta,
355            outcome.failure_delta,
356            outcome.recovery_delta,
357            outcome.correction_delta,
358            outcome.revert_delta,
359            reinforcement_delta
360        ],
361    )?;
362    Ok(())
363}
364
365pub fn get_lesson_metadata(conn: &Connection, memory_id: i64) -> Result<Option<LessonMetadata>> {
366    conn.query_row(
367        "SELECT memory_id, confidence, reinforcement_count, source_evidence,
368                last_reinforced_at_epoch, stale_after_epoch, outcome_kind,
369                success_count, failure_count, recovery_count, correction_count, revert_count
370         FROM memory_lessons WHERE memory_id = ?1",
371        [memory_id],
372        map_lesson_metadata_row,
373    )
374    .optional()
375    .map_err(Into::into)
376}
377
378pub fn list_lessons_for_context(
379    conn: &Connection,
380    project: &str,
381    current_branch: Option<&str>,
382    limit: i64,
383) -> Result<Vec<LessonMemory>> {
384    if limit <= 0 {
385        return Ok(vec![]);
386    }
387    let now = chrono::Utc::now().timestamp();
388    let mut stmt = conn.prepare(&format!(
389        "SELECT {cols},
390                l.memory_id, l.confidence, l.reinforcement_count, l.source_evidence,
391                l.last_reinforced_at_epoch, l.stale_after_epoch, l.outcome_kind,
392                l.success_count, l.failure_count, l.recovery_count, l.correction_count,
393                l.revert_count
394         FROM memories m
395         JOIN memory_lessons l ON l.memory_id = m.id
396         WHERE m.memory_type = 'lesson'
397           AND {current_filter}
398           AND {policy_filter}
399           AND ((m.owner_scope = 'repo' AND m.owner_key = ?1)
400                OR (m.owner_scope = 'repo' AND m.target_project = ?1)
401                OR (m.owner_scope = 'user' AND m.owner_key = 'user:default')
402                OR (m.owner_scope IS NULL AND (m.project = ?1 OR m.scope = 'global')))
403           AND l.confidence >= ?2
404           AND (l.stale_after_epoch IS NULL OR l.stale_after_epoch > ?3)
405           AND (?4 IS NULL OR m.branch = ?4 OR m.branch IS NULL)
406         ORDER BY
407           CASE WHEN m.project = ?1 THEN 0 ELSE 1 END,
408           l.confidence DESC,
409           l.reinforcement_count DESC,
410           l.last_reinforced_at_epoch DESC,
411           m.id ASC
412         LIMIT ?5",
413        cols = prefixed_memory_cols("m"),
414        current_filter =
415            crate::memory::memory_current_filter_sql("m.status", "m.expires_at_epoch", false),
416        policy_filter = crate::memory::suppression::memory_policy_filter_sql("m"),
417    ))?;
418    let rows = stmt.query_map(
419        params![
420            project,
421            MIN_CONFIDENCE_FOR_CONTEXT,
422            now,
423            current_branch,
424            limit
425        ],
426        |row| {
427            let memory = map_memory_row_pub(row)?;
428            let metadata = map_lesson_metadata_from_offset(row, 13)?;
429            Ok(LessonMemory { memory, metadata })
430        },
431    )?;
432    crate::db::query::collect_rows(rows)
433}
434
435pub fn is_lesson_candidate(text: &str) -> bool {
436    let normalized = text.trim().to_lowercase();
437    if normalized.len() < 30 {
438        return false;
439    }
440    const SIGNALS: &[&str] = &[
441        "lesson:",
442        "root cause",
443        "avoid ",
444        "do not ",
445        "don't ",
446        "never ",
447        "must ",
448        "should ",
449        "proven ",
450        "workflow",
451        "pitfall",
452        "prevent",
453        "regression",
454    ];
455    SIGNALS.iter().any(|signal| normalized.contains(signal))
456}
457
458fn prefixed_memory_cols(alias: &str) -> String {
459    MEMORY_COLS
460        .split(',')
461        .map(|col| format!("{alias}.{}", col.trim()))
462        .collect::<Vec<_>>()
463        .join(", ")
464}
465
466fn map_lesson_metadata_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<LessonMetadata> {
467    map_lesson_metadata_from_offset(row, 0)
468}
469
470fn map_lesson_metadata_from_offset(
471    row: &rusqlite::Row<'_>,
472    offset: usize,
473) -> rusqlite::Result<LessonMetadata> {
474    Ok(LessonMetadata {
475        memory_id: row.get(offset)?,
476        confidence: row.get(offset + 1)?,
477        reinforcement_count: row.get(offset + 2)?,
478        source_evidence: row.get(offset + 3)?,
479        last_reinforced_at_epoch: row.get(offset + 4)?,
480        stale_after_epoch: row.get(offset + 5)?,
481        outcome_kind: row.get(offset + 6)?,
482        success_count: row.get(offset + 7)?,
483        failure_count: row.get(offset + 8)?,
484        recovery_count: row.get(offset + 9)?,
485        correction_count: row.get(offset + 10)?,
486        revert_count: row.get(offset + 11)?,
487    })
488}
489
490#[cfg(test)]
491mod tests;