Skip to main content

lunaris_verify/
reflect.rs

1//! [`ReflectSupervisor`] — per-turn memory calibration via `LlmBackend`.
2//!
3//! ## Purpose
4//!
5//! Bi-temporal MVCC stores accumulate facts faster than they invalidate
6//! them. After an agent finishes a turn (a coherent ingest + recall +
7//! act cycle), a *reflection* pass can:
8//!
9//! - **Invalidate** facts the turn-end state contradicts (e.g. the user
10//!   corrected an earlier statement).
11//! - **Boost** chunks that proved load-bearing in the turn so future
12//!   recall ranks them higher.
13//! - **Pre-warm** a likely-next query so the next turn's first recall
14//!   doesn't pay cold-path latency.
15//!
16//! Reflect is conceptually sibling to `lunaris-verify` (slow-path
17//! arbitration on contradiction) and `lunaris-consolidate` (background
18//! merge of high-confidence chunks into the graph). It differs by
19//! **trigger**: reflect fires per-turn on demand, not per-message
20//! (extract) or on a background cadence (consolidate).
21//!
22//! ## Scope of this commit
23//!
24//! Scaffold only — DTOs + trait + a minimal `LlmReflectSupervisor`
25//! impl that round-trips a JSON-schema-constrained generate call.
26//! Wire-up to the turn-end signal lives in a follow-up commit once
27//! the upstream `lunaris` umbrella exposes a turn boundary.
28//!
29//! ## Cost
30//!
31//! Reflect is the LOWEST-budget LLM call in Lunaris — it runs on a
32//! per-turn cadence and shares the same `LlmBackend` instance (likely
33//! Gemma-3 4B once the verify-default flip lands). Default
34//! [`ReflectOpts::timeout_ms`] is 500 ms; that's loose enough for 4B
35//! on CPU and tight enough that a stuck reflection doesn't stall the
36//! next turn.
37
38use std::sync::Arc;
39use std::time::Duration;
40
41use async_trait::async_trait;
42use lunaris_core::LunarisError;
43use lunaris_llm::{GenOpts, LlmBackend, SchemaConstraint};
44use serde::{Deserialize, Serialize};
45use ulid::Ulid;
46
47/// Input snapshot the reflection LLM sees. Caller assembles this from
48/// the just-finished turn's ingest + recall state. The fields are
49/// intentionally minimal — wider context (full chunk text, full
50/// recall hits) belongs in the prompt the reflection wrapper builds.
51#[derive(Clone, Debug, Default, Serialize)]
52pub struct ReflectInput {
53    /// ULID identifying the turn. Used for telemetry only — reflect
54    /// does NOT key any storage by this; reflections are advisory.
55    pub turn_id: Option<Ulid>,
56    /// Short summary of what the agent did this turn (the agent or
57    /// the ingest pipeline produces this).
58    pub turn_summary: String,
59    /// Recent fact ulids the turn surfaced. The reflect LLM may
60    /// nominate any of these for invalidation if the turn-end state
61    /// contradicts them.
62    pub recent_fact_ids: Vec<Ulid>,
63    /// Recent chunk ulids that proved load-bearing for retrieval.
64    /// The reflect LLM may nominate any of these for boost.
65    pub recent_chunk_ids: Vec<Ulid>,
66}
67
68/// Output the reflection LLM emits. All three fields are advisory —
69/// nothing in this commit applies them. Wire-up to the storage layer
70/// is a follow-up.
71#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
72pub struct ReflectOutput {
73    /// Fact ulids the LLM nominates for invalidation.
74    #[serde(default)]
75    pub invalidate: Vec<Ulid>,
76    /// Chunk ulids the LLM nominates for retrieval-rank boost.
77    #[serde(default)]
78    pub boost: Vec<Ulid>,
79    /// A likely-next query the next turn will issue, so the umbrella
80    /// can pre-warm the cache. `None` if the LLM has no signal.
81    #[serde(default)]
82    pub pre_warm_query: Option<String>,
83}
84
85/// Construction options.
86#[derive(Clone, Debug)]
87pub struct ReflectOpts {
88    /// Per-call timeout. 500 ms accommodates 4B-on-CPU; lower for GPU
89    /// (~150 ms is reasonable).
90    pub timeout_ms: u64,
91    /// Max output tokens. Reflect output is short — ulid lists +
92    /// optional query — 384 covers ~20 ulids comfortably.
93    pub max_tokens: u32,
94    /// Sampling temperature. 0.2 = mostly-greedy with a small bit of
95    /// variety so identical turns don't always pre-warm the same query
96    /// (the calibration loop benefits slightly from diversity).
97    pub temperature: f32,
98}
99
100impl Default for ReflectOpts {
101    fn default() -> Self {
102        Self { timeout_ms: 500, max_tokens: 384, temperature: 0.2 }
103    }
104}
105
106/// Object-safe async reflection supervisor.
107#[async_trait]
108pub trait ReflectSupervisor: Send + Sync + 'static {
109    async fn reflect(&self, input: ReflectInput) -> Result<ReflectOutput, LunarisError>;
110
111    /// `true` if this supervisor produces real reflections; `false`
112    /// for a noop so callers can short-circuit prompt assembly.
113    fn applies(&self) -> bool {
114        true
115    }
116}
117
118/// Noop reflect supervisor — always returns an empty `ReflectOutput`.
119/// Default for `Lunaris::with_reflect` when the umbrella ships
120/// reflect-OFF (mirrors blueprint §5.1 default-OFF for verify).
121#[derive(Clone, Copy, Debug, Default)]
122pub struct NoopReflectSupervisor;
123
124#[async_trait]
125impl ReflectSupervisor for NoopReflectSupervisor {
126    async fn reflect(&self, _input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
127        Ok(ReflectOutput::default())
128    }
129    fn applies(&self) -> bool {
130        false
131    }
132}
133
134/// LLM-backed reflect supervisor. Wraps any `Arc<dyn LlmBackend>` —
135/// same trait the extract + verify pipelines consume — so a single
136/// candle Gemma-3 4B handle can serve all three when the unified
137/// config wires it that way.
138#[derive(Clone)]
139pub struct LlmReflectSupervisor {
140    backend: Arc<dyn LlmBackend>,
141    opts: ReflectOpts,
142}
143
144impl std::fmt::Debug for LlmReflectSupervisor {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("LlmReflectSupervisor")
147            .field("model_id", &self.backend.model_id())
148            .field("opts", &self.opts)
149            .finish()
150    }
151}
152
153impl LlmReflectSupervisor {
154    pub fn new(backend: Arc<dyn LlmBackend>) -> Self {
155        Self { backend, opts: ReflectOpts::default() }
156    }
157
158    pub fn with_opts(backend: Arc<dyn LlmBackend>, opts: ReflectOpts) -> Self {
159        Self { backend, opts }
160    }
161}
162
163#[async_trait]
164impl ReflectSupervisor for LlmReflectSupervisor {
165    async fn reflect(&self, input: ReflectInput) -> Result<ReflectOutput, LunarisError> {
166        let prompt = build_prompt(&input);
167        let schema = output_schema();
168        let gen_opts = GenOpts {
169            max_tokens: self.opts.max_tokens,
170            temperature: self.opts.temperature,
171            timeout: Duration::from_millis(self.opts.timeout_ms),
172        };
173        match self.backend.generate(&prompt, SchemaConstraint::JsonSchema(&schema), gen_opts).await
174        {
175            Ok(decoded) => Ok(parse_reflect_output(&decoded)),
176            Err(e) => {
177                tracing::warn!(
178                    err = %e,
179                    model_id = self.backend.model_id(),
180                    turn_id = ?input.turn_id,
181                    "LlmReflectSupervisor generate failed; emitting empty reflection"
182                );
183                Ok(ReflectOutput::default())
184            }
185        }
186    }
187
188    fn applies(&self) -> bool {
189        self.backend.applies()
190    }
191}
192
193fn build_prompt(input: &ReflectInput) -> String {
194    // Compact prompt — full chunk/fact bodies are NOT inlined here. The
195    // reflection LLM works from ulids + the agent-supplied summary; if
196    // it wants more it asks the next turn for it. This keeps the
197    // per-turn token budget low.
198    let facts = input.recent_fact_ids.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(", ");
199    let chunks =
200        input.recent_chunk_ids.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(", ");
201    format!(
202        "You are calibrating an agent memory store after a turn finished.\n\
203         Turn summary:\n{summary}\n\n\
204         Recent fact ulids: [{facts}]\n\
205         Recent chunk ulids: [{chunks}]\n\n\
206         Respond with a JSON object:\n\
207         {{\"invalidate\": [<fact ulids to invalidate>],\
208          \"boost\": [<chunk ulids to boost>],\
209          \"pre_warm_query\": <string or null>}}\n\
210         If nothing should change, emit empty arrays and a null query.",
211        summary = input.turn_summary
212    )
213}
214
215fn output_schema() -> serde_json::Value {
216    serde_json::json!({
217        "type": "object",
218        "properties": {
219            "invalidate": {"type": "array", "items": {"type": "string"}},
220            "boost":      {"type": "array", "items": {"type": "string"}},
221            "pre_warm_query": {"type": ["string", "null"]}
222        },
223        "required": ["invalidate", "boost", "pre_warm_query"]
224    })
225}
226
227fn parse_reflect_output(decoded: &str) -> ReflectOutput {
228    let Some(start) = decoded.find('{') else {
229        return ReflectOutput::default();
230    };
231    let bytes = decoded.as_bytes();
232    let mut depth = 0_i32;
233    let mut end_excl = start;
234    let mut in_string = false;
235    let mut escaped = false;
236    for (i, &b) in bytes.iter().enumerate().skip(start) {
237        if in_string {
238            if escaped {
239                escaped = false;
240            } else if b == b'\\' {
241                escaped = true;
242            } else if b == b'"' {
243                in_string = false;
244            }
245            continue;
246        }
247        match b {
248            b'"' => in_string = true,
249            b'{' => depth += 1,
250            b'}' => {
251                depth -= 1;
252                if depth == 0 {
253                    end_excl = i + 1;
254                    break;
255                }
256            }
257            _ => {}
258        }
259    }
260    if end_excl == start {
261        return ReflectOutput::default();
262    }
263    let json_slice = &decoded[start..end_excl];
264
265    #[derive(Deserialize)]
266    struct Wire {
267        #[serde(default)]
268        invalidate: Vec<String>,
269        #[serde(default)]
270        boost: Vec<String>,
271        #[serde(default)]
272        pre_warm_query: Option<String>,
273    }
274    match serde_json::from_str::<Wire>(json_slice) {
275        Ok(w) => ReflectOutput {
276            invalidate: w
277                .invalidate
278                .into_iter()
279                .filter_map(|s| Ulid::from_string(&s).ok())
280                .collect(),
281            boost: w.boost.into_iter().filter_map(|s| Ulid::from_string(&s).ok()).collect(),
282            pre_warm_query: w.pre_warm_query.filter(|s| !s.is_empty()),
283        },
284        Err(e) => {
285            tracing::warn!(err = %e, "LlmReflectSupervisor JSON parse failed; emitting empty");
286            ReflectOutput::default()
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    struct StubBackend {
296        out: String,
297    }
298
299    #[async_trait]
300    impl LlmBackend for StubBackend {
301        async fn generate(
302            &self,
303            _prompt: &str,
304            _constraint: SchemaConstraint<'_>,
305            _opts: GenOpts,
306        ) -> Result<String, LunarisError> {
307            Ok(self.out.clone())
308        }
309        fn model_id(&self) -> &str {
310            "stub://reflect"
311        }
312    }
313
314    #[tokio::test]
315    async fn noop_supervisor_returns_empty() {
316        let s = NoopReflectSupervisor;
317        let out = s.reflect(ReflectInput::default()).await.unwrap();
318        assert_eq!(out, ReflectOutput::default());
319        assert!(!s.applies());
320    }
321
322    #[tokio::test]
323    async fn parses_valid_reflect_json() {
324        let fact = Ulid::new();
325        let chunk = Ulid::new();
326        let out_json = format!(
327            r#"{{"invalidate":["{fact}"],"boost":["{chunk}"],"pre_warm_query":"who is Alice?"}}"#
328        );
329        let backend: Arc<dyn LlmBackend> = Arc::new(StubBackend { out: out_json });
330        let supervisor = LlmReflectSupervisor::new(backend);
331        let out = supervisor
332            .reflect(ReflectInput {
333                turn_summary: "agent answered question".into(),
334                ..ReflectInput::default()
335            })
336            .await
337            .unwrap();
338        assert_eq!(out.invalidate, vec![fact]);
339        assert_eq!(out.boost, vec![chunk]);
340        assert_eq!(out.pre_warm_query.as_deref(), Some("who is Alice?"));
341    }
342
343    #[tokio::test]
344    async fn malformed_output_emits_empty() {
345        let backend: Arc<dyn LlmBackend> =
346            Arc::new(StubBackend { out: "definitely not json".into() });
347        let supervisor = LlmReflectSupervisor::new(backend);
348        let out = supervisor.reflect(ReflectInput::default()).await.unwrap();
349        assert_eq!(out, ReflectOutput::default());
350    }
351
352    #[tokio::test]
353    async fn invalid_ulids_in_output_are_dropped() {
354        let valid = Ulid::new();
355        let out_json = format!(
356            r#"{{"invalidate":["{valid}","not-a-ulid"],"boost":[],"pre_warm_query":null}}"#
357        );
358        let backend: Arc<dyn LlmBackend> = Arc::new(StubBackend { out: out_json });
359        let supervisor = LlmReflectSupervisor::new(backend);
360        let out = supervisor.reflect(ReflectInput::default()).await.unwrap();
361        assert_eq!(out.invalidate, vec![valid]);
362        assert!(out.boost.is_empty());
363        assert!(out.pre_warm_query.is_none());
364    }
365
366    #[test]
367    fn output_schema_has_required_fields() {
368        let schema = output_schema();
369        let required = schema["required"].as_array().unwrap();
370        let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
371        assert!(names.contains(&"invalidate"));
372        assert!(names.contains(&"boost"));
373        assert!(names.contains(&"pre_warm_query"));
374    }
375}