Skip to main content

scone_core/
distill.rs

1//! Lane 2 fact application (spec §6): entity resolution, provenance,
2//! contradiction closure.
3//!
4//! Invariants enforced here: I2 (no two active facts share subject +
5//! predicate), I3 (contradiction closes the old interval, deletes nothing),
6//! I4 (every fact carries provenance). The predecessor modeled this as
7//! version chains over prose (memory/rationales.md R-3); structure makes
8//! contradiction a keyed lookup instead of a prose comparison.
9
10use rusqlite::Transaction;
11
12use crate::Engine;
13use crate::auth::ScopedSpace;
14use crate::error::{Result, SconeError};
15use crate::llm::ExtractedFact;
16
17#[derive(Debug, Default, PartialEq)]
18pub struct DistillReport {
19    pub processed: usize,
20    pub facts_added: usize,
21    pub facts_closed: usize,
22    pub failed: usize,
23}
24
25#[derive(Debug, Default, PartialEq)]
26pub struct ApplyReport {
27    pub added: usize,
28    pub closed: usize,
29    pub deduplicated: usize,
30}
31
32fn canonicalize(name: &str) -> String {
33    name.trim().to_lowercase()
34}
35
36fn resolve_entity(tx: &Transaction, name: &str) -> Result<i64> {
37    let canonical = canonicalize(name);
38    if canonical.is_empty() {
39        return Err(SconeError::InvalidInput("empty entity name".into()));
40    }
41    if let Some(id) = tx
42        .query_row(
43            "SELECT entity_id FROM entity_aliases WHERE alias = ?1",
44            [&canonical],
45            |r| r.get::<_, i64>(0),
46        )
47        .map(Some)
48        .or_else(|e| match e {
49            rusqlite::Error::QueryReturnedNoRows => Ok(None),
50            other => Err(SconeError::Db(other)),
51        })?
52    {
53        return Ok(id);
54    }
55    tx.execute(
56        "INSERT OR IGNORE INTO entities (canonical) VALUES (?1)",
57        [&canonical],
58    )?;
59    Ok(tx.query_row(
60        "SELECT id FROM entities WHERE canonical = ?1",
61        [&canonical],
62        |r| r.get(0),
63    )?)
64}
65
66impl Engine {
67    /// Drain up to `limit` pending episodes of this space through the LLM.
68    ///
69    /// Failures are recorded on the queue row (attempts, last_error) and
70    /// never delete anything (memory/bugs.md P-2); after 3 attempts the row
71    /// parks as `failed` and stops being retried implicitly.
72    pub fn distill(&mut self, space: &ScopedSpace, limit: usize) -> Result<DistillReport> {
73        if self.llm.is_none() {
74            return Err(SconeError::Llm(
75                "no LLM configured — semantic lane paused; set [llm] in config.toml                  or pass --llm (episodic search is unaffected)"
76                    .into(),
77            ));
78        }
79        let pending: Vec<(i64, i64, String)> = {
80            let mut stmt = self.conn.prepare(
81                "SELECT q.id, q.episode_id, e.content
82                 FROM distill_queue q JOIN episodes e ON e.id = q.episode_id
83                 WHERE q.state = 'pending' AND e.space_id = ?1
84                 ORDER BY q.id LIMIT ?2",
85            )?;
86            let rows = stmt.query_map(rusqlite::params![space.id(), limit as i64], |r| {
87                Ok((r.get(0)?, r.get(1)?, r.get(2)?))
88            })?;
89            rows.collect::<std::result::Result<Vec<_>, _>>()?
90        };
91        let mut report = DistillReport::default();
92        for (queue_id, episode_id, content) in pending {
93            let extraction = match &self.llm {
94                Some(llm) => llm.extract_facts(&content),
95                None => unreachable!("checked above"),
96            };
97            match extraction {
98                Ok(facts) => {
99                    // Sanitize before applying: a model emitting one junk
100                    // triple must not poison the batch (bugs.md P-2; found
101                    // by manual QA with llama3.2:3b, 2026-08-27).
102                    let usable: Vec<_> = facts
103                        .into_iter()
104                        .filter(|f| {
105                            !f.subject.trim().is_empty()
106                                && !f.predicate.trim().is_empty()
107                                && !f.object.trim().is_empty()
108                        })
109                        .collect();
110                    let applied = match self.apply_facts(space, episode_id, &usable) {
111                        Ok(applied) => applied,
112                        Err(e) => {
113                            self.conn.execute(
114                                "UPDATE distill_queue SET attempts = attempts + 1,
115                                        last_error = ?1,
116                                        state = CASE WHEN attempts + 1 >= 3
117                                                     THEN 'failed' ELSE 'pending' END
118                                 WHERE id = ?2",
119                                rusqlite::params![e.to_string(), queue_id],
120                            )?;
121                            report.failed += 1;
122                            continue;
123                        }
124                    };
125                    self.conn.execute(
126                        "UPDATE distill_queue SET state = 'done', last_error = NULL
127                         WHERE id = ?1",
128                        [queue_id],
129                    )?;
130                    report.processed += 1;
131                    report.facts_added += applied.added;
132                    report.facts_closed += applied.closed;
133                }
134                Err(e) => {
135                    self.conn.execute(
136                        "UPDATE distill_queue SET attempts = attempts + 1,
137                                last_error = ?1,
138                                state = CASE WHEN attempts + 1 >= 3
139                                             THEN 'failed' ELSE 'pending' END
140                         WHERE id = ?2",
141                        rusqlite::params![e.to_string(), queue_id],
142                    )?;
143                    report.failed += 1;
144                }
145            }
146        }
147        Ok(report)
148    }
149
150    /// Register `alias` as another name for `canonical` (both canonicalized).
151    pub fn add_entity_alias(&mut self, alias: &str, canonical: &str) -> Result<()> {
152        let tx = self.conn.transaction()?;
153        let entity_id = resolve_entity(&tx, canonical)?;
154        tx.execute(
155            "INSERT OR REPLACE INTO entity_aliases (alias, entity_id) VALUES (?1, ?2)",
156            rusqlite::params![canonicalize(alias), entity_id],
157        )?;
158        tx.commit()?;
159        Ok(())
160    }
161
162    /// Apply extracted facts from one episode, in one transaction.
163    pub fn apply_facts(
164        &mut self,
165        space: &ScopedSpace,
166        episode_id: i64,
167        facts: &[ExtractedFact],
168    ) -> Result<ApplyReport> {
169        let mut report = ApplyReport::default();
170        let tx = self.conn.transaction()?;
171        for fact in facts {
172            let subject = resolve_entity(&tx, &fact.subject)?;
173            let predicate = fact.predicate.trim().to_lowercase();
174            let object = fact.object.trim().to_owned();
175            if predicate.is_empty() || object.is_empty() {
176                return Err(SconeError::InvalidInput(
177                    "fact predicate/object must be non-empty".into(),
178                ));
179            }
180
181            // Exact restatement: strengthen, never duplicate (bugs.md P-5).
182            let existing: Option<i64> = tx
183                .query_row(
184                    "SELECT id FROM facts
185                     WHERE space_id = ?1 AND subject_entity = ?2 AND predicate = ?3
186                       AND object = ?4 AND status = 'active'",
187                    rusqlite::params![space.id(), subject, predicate, object],
188                    |r| r.get(0),
189                )
190                .map(Some)
191                .or_else(|e| match e {
192                    rusqlite::Error::QueryReturnedNoRows => Ok(None),
193                    other => Err(SconeError::Db(other)),
194                })?;
195            if let Some(fact_id) = existing {
196                tx.execute(
197                    "UPDATE facts SET confidence = max(confidence, ?1) WHERE id = ?2",
198                    rusqlite::params![fact.confidence, fact_id],
199                )?;
200                tx.execute(
201                    "INSERT OR IGNORE INTO fact_provenance (fact_id, episode_id) VALUES (?1, ?2)",
202                    rusqlite::params![fact_id, episode_id],
203                )?;
204                report.deduplicated += 1;
205                continue;
206            }
207
208            tx.execute(
209                "INSERT INTO facts (space_id, subject_entity, predicate, object, confidence)
210                 VALUES (?1, ?2, ?3, ?4, ?5)",
211                rusqlite::params![space.id(), subject, predicate, object, fact.confidence],
212            )?;
213            let new_id = tx.last_insert_rowid();
214            tx.execute(
215                "INSERT INTO fact_provenance (fact_id, episode_id) VALUES (?1, ?2)",
216                rusqlite::params![new_id, episode_id],
217            )?;
218            report.added += 1;
219
220            // Contradiction: same subject+predicate, different object →
221            // close the old interval, keep the history (I2/I3).
222            let closed = tx.execute(
223                "UPDATE facts SET status = 'closed',
224                        valid_until = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
225                        status_reason = 'superseded by fact ' || ?1
226                 WHERE space_id = ?2 AND subject_entity = ?3 AND predicate = ?4
227                   AND status = 'active' AND id != ?1",
228                rusqlite::params![new_id, space.id(), subject, predicate],
229            )?;
230            report.closed += closed;
231        }
232        tx.commit()?;
233        Ok(report)
234    }
235}
236
237/// One provenance link: which episode taught us a fact.
238#[derive(Debug)]
239pub struct ProvenanceItem {
240    pub episode_id: i64,
241    pub kind: String,
242    pub source: Option<String>,
243    pub created_at: String,
244}
245
246impl Engine {
247    /// Facts of a space; active only unless `all` (closed/expired included).
248    pub fn facts_list(&self, space: &ScopedSpace, all: bool) -> Result<Vec<crate::FactItem>> {
249        let mut stmt = self.conn.prepare(
250            "SELECT f.id, en.canonical, f.predicate, f.object, f.confidence,
251                    f.valid_from, f.valid_until, f.status, f.status_reason
252             FROM facts f JOIN entities en ON en.id = f.subject_entity
253             WHERE f.space_id = ?1 AND (?2 OR f.status = 'active')
254             ORDER BY f.id",
255        )?;
256        let rows = stmt.query_map(rusqlite::params![space.id(), all], |r| {
257            Ok((
258                crate::FactItem {
259                    fact_id: r.get(0)?,
260                    subject: r.get(1)?,
261                    predicate: r.get(2)?,
262                    object: r.get(3)?,
263                    confidence: r.get(4)?,
264                    valid_from: r.get(5)?,
265                    valid_until: r.get(6)?,
266                    status: r.get(7)?,
267                },
268                r.get::<_, Option<String>>(8)?,
269            ))
270        })?;
271        let mut out = Vec::new();
272        for row in rows {
273            let (mut item, reason) = row?;
274            // Carry the closure reason in status for display surfaces.
275            if let Some(reason) = reason {
276                item.status = format!("{} ({reason})", item.status);
277            }
278            out.push(item);
279        }
280        Ok(out)
281    }
282
283    /// The episodes that taught us this fact (invariant I4 guarantees ≥1).
284    pub fn facts_why(&self, space: &ScopedSpace, fact_id: i64) -> Result<Vec<ProvenanceItem>> {
285        let mut stmt = self.conn.prepare(
286            "SELECT e.id, e.kind, e.source, e.created_at
287             FROM fact_provenance fp
288             JOIN facts f ON f.id = fp.fact_id
289             JOIN episodes e ON e.id = fp.episode_id
290             WHERE fp.fact_id = ?1 AND f.space_id = ?2
291             ORDER BY e.id",
292        )?;
293        let rows = stmt.query_map(rusqlite::params![fact_id, space.id()], |r| {
294            Ok(ProvenanceItem {
295                episode_id: r.get(0)?,
296                kind: r.get(1)?,
297                source: r.get(2)?,
298                created_at: r.get(3)?,
299            })
300        })?;
301        let out = rows.collect::<std::result::Result<Vec<_>, _>>()?;
302        if out.is_empty() {
303            return Err(SconeError::NotFound(format!(
304                "fact {fact_id} in space {}",
305                space.name()
306            )));
307        }
308        Ok(out)
309    }
310
311    /// Close a fact by hand, with a reason (interval close, never delete).
312    pub fn facts_close(&mut self, space: &ScopedSpace, fact_id: i64, reason: &str) -> Result<()> {
313        let changed = self.conn.execute(
314            "UPDATE facts SET status = 'closed',
315                    valid_until = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
316                    status_reason = ?1
317             WHERE id = ?2 AND space_id = ?3 AND status = 'active'",
318            rusqlite::params![reason, fact_id, space.id()],
319        )?;
320        if changed == 0 {
321            return Err(SconeError::NotFound(format!(
322                "active fact {fact_id} in space {}",
323                space.name()
324            )));
325        }
326        Ok(())
327    }
328}
329
330impl Engine {
331    /// Active facts whose subject is `entity` (canonicalized, aliases
332    /// honored), scoped to one space.
333    pub fn facts_about(&self, space: &ScopedSpace, entity: &str) -> Result<Vec<crate::FactItem>> {
334        let canonical = entity.trim().to_lowercase();
335        let mut stmt = self.conn.prepare(
336            "SELECT f.id, en.canonical, f.predicate, f.object, f.confidence,
337                    f.valid_from, f.valid_until, f.status
338             FROM facts f JOIN entities en ON en.id = f.subject_entity
339             WHERE f.space_id = ?1 AND f.status = 'active'
340               AND f.subject_entity IN (
341                   SELECT id FROM entities WHERE canonical = ?2
342                   UNION
343                   SELECT entity_id FROM entity_aliases WHERE alias = ?2)
344             ORDER BY f.confidence DESC, f.id",
345        )?;
346        let rows = stmt.query_map(rusqlite::params![space.id(), canonical], |r| {
347            Ok(crate::FactItem {
348                fact_id: r.get(0)?,
349                subject: r.get(1)?,
350                predicate: r.get(2)?,
351                object: r.get(3)?,
352                confidence: r.get(4)?,
353                valid_from: r.get(5)?,
354                valid_until: r.get(6)?,
355                status: r.get(7)?,
356            })
357        })?;
358        Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
359    }
360}
361
362impl Engine {
363    /// Decay (spec §5): expire active facts that are old, unaccessed, and
364    /// low-confidence. Expiry is an interval close with a recorded reason
365    /// (adopted from the predecessor's forgetReason, rationales.md R-3) —
366    /// never a delete. Recalled facts are reinforced (access_count,
367    /// last_accessed) and therefore immune. Returns how many expired.
368    pub fn decay_facts(&mut self, space: &ScopedSpace, max_idle_days: u32) -> Result<usize> {
369        const DECAY_CONFIDENCE_CEILING: f64 = 0.6;
370        let cutoff = format!("-{max_idle_days} days");
371        let expired = self.conn.execute(
372            "UPDATE facts SET status = 'expired',
373                    valid_until = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
374                    status_reason = 'decayed: unaccessed for ' || ?1 || '+ days'
375             WHERE space_id = ?2 AND status = 'active'
376               AND confidence < ?3
377               AND access_count = 0
378               AND valid_from < strftime('%Y-%m-%dT%H:%M:%fZ','now', ?4)
379               AND (last_accessed IS NULL
380                    OR last_accessed < strftime('%Y-%m-%dT%H:%M:%fZ','now', ?4))",
381            rusqlite::params![max_idle_days, space.id(), DECAY_CONFIDENCE_CEILING, cutoff],
382        )?;
383        Ok(expired)
384    }
385}
386
387impl Engine {
388    /// Episodes awaiting distillation, for agent-driven extraction
389    /// (subscription-native path: the host agent is the model).
390    pub fn pending_episodes(
391        &self,
392        space: &ScopedSpace,
393        limit: usize,
394    ) -> Result<Vec<(i64, String, String)>> {
395        let mut stmt = self.conn.prepare(
396            "SELECT e.id, substr(e.content, 1, 4000), e.created_at
397             FROM distill_queue q JOIN episodes e ON e.id = q.episode_id
398             WHERE q.state = 'pending' AND e.space_id = ?1
399             ORDER BY q.id LIMIT ?2",
400        )?;
401        let rows = stmt.query_map(
402            rusqlite::params![space.id(), limit.clamp(1, 20) as i64],
403            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
404        )?;
405        Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
406    }
407
408    /// Apply agent-extracted facts for one pending episode and mark its
409    /// queue row done. The engine enforces the invariants; the agent only
410    /// proposes.
411    pub fn complete_distillation(
412        &mut self,
413        space: &ScopedSpace,
414        episode_id: i64,
415        facts: &[crate::llm::ExtractedFact],
416    ) -> Result<ApplyReport> {
417        let pending: i64 = self.conn.query_row(
418            "SELECT count(*) FROM distill_queue q JOIN episodes e ON e.id = q.episode_id
419             WHERE q.episode_id = ?1 AND e.space_id = ?2",
420            rusqlite::params![episode_id, space.id()],
421            |r| r.get(0),
422        )?;
423        if pending == 0 {
424            return Err(SconeError::NotFound(format!(
425                "episode {episode_id} in space {}",
426                space.name()
427            )));
428        }
429        let usable: Vec<crate::llm::ExtractedFact> = facts
430            .iter()
431            .filter(|f| {
432                !f.subject.trim().is_empty()
433                    && !f.predicate.trim().is_empty()
434                    && !f.object.trim().is_empty()
435            })
436            .cloned()
437            .collect();
438        let report = self.apply_facts(space, episode_id, &usable)?;
439        self.conn.execute(
440            "UPDATE distill_queue SET state = 'done', last_error = NULL
441             WHERE episode_id = ?1",
442            [episode_id],
443        )?;
444        Ok(report)
445    }
446}