semantic-memory 0.5.8

Local-first hybrid semantic search (SQLite + FTS5 + usearch 2.25) with bitemporal truth and typed receipts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
//! RL-trained retrieval routing on receipt replay data.
//!
//! Uses the system's receipt-driven replay as training signal for a
//! simple tabular/linear routing policy. The policy maps query profile
//! features to pipeline stage selection probabilities, learning from
//! past search outcomes.
//!
//! Degrades gracefully to heuristic routing when untrained.
//!
//! Behind `#[cfg(feature = "rl-routing")]` which depends on `routing`.

#![cfg(feature = "rl-routing")]

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::routing::{QueryProfile, RetrievalRouter, RoutingDecision};

// ─── Policy ─────────────────────────────────────────────────────────────

/// A simple tabular routing policy trained on receipt replay.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutingPolicy {
    /// Maps feature name to weight.
    pub weights: HashMap<String, f64>,
    /// Learning rate for policy updates.
    pub learning_rate: f64,
    /// Baseline outcome score (exponential moving average).
    pub baseline: f64,
    /// Number of training examples seen.
    pub trained_examples: usize,
}

impl Default for RoutingPolicy {
    fn default() -> Self {
        let mut weights = HashMap::new();
        weights.insert("bm25_coarse".to_string(), 1.0);
        weights.insert("vector_medium".to_string(), 1.0);
        weights.insert("rerank_fine".to_string(), 0.5);
        weights.insert("graph_expansion".to_string(), 0.3);
        weights.insert("decoder".to_string(), 0.2);
        weights.insert("discord".to_string(), 0.2);
        Self {
            weights,
            learning_rate: 0.01,
            baseline: 0.5,
            trained_examples: 0,
        }
    }
}

// ─── Training example ───────────────────────────────────────────────────

/// A training example extracted from a search receipt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingExample {
    pub query_profile: QueryProfile,
    pub decision: RoutingDecision,
    pub outcome_score: f64,
}

/// Extract a training example from a search outcome.
pub fn extract_training_example(
    profile: &QueryProfile,
    decision: &RoutingDecision,
    outcome_score: f64,
) -> TrainingExample {
    TrainingExample {
        query_profile: profile.clone(),
        decision: decision.clone(),
        outcome_score,
    }
}

// ─── Policy update ──────────────────────────────────────────────────────

/// Update the policy from a batch of training examples.
///
/// For each example:
/// - reward = outcome_score - baseline
/// - If reward > 0, increase weights for stages that were enabled
/// - If reward < 0, decrease weights for stages that were enabled
/// - Update baseline as exponential moving average
pub fn update_policy(policy: &mut RoutingPolicy, examples: &[TrainingExample]) {
    if examples.is_empty() {
        return;
    }

    let lr = policy.learning_rate;
    let alpha = 0.1; // EMA smoothing

    for ex in examples {
        let reward = ex.outcome_score - policy.baseline;

        // Update weights for each stage.
        let stages = [
            ("bm25_coarse", ex.decision.bm25_coarse),
            ("vector_medium", ex.decision.vector_medium),
            ("rerank_fine", ex.decision.rerank_fine),
            ("graph_expansion", ex.decision.graph_expansion),
            ("decoder", ex.decision.decoder),
            ("discord", ex.decision.discord),
        ];

        for (name, enabled) in stages {
            if enabled {
                let w = policy.weights.entry(name.to_string()).or_insert(0.5);
                *w += lr * reward;
                *w = w.clamp(0.0, 2.0);
            }
        }

        // Update baseline.
        policy.baseline = alpha * ex.outcome_score + (1.0 - alpha) * policy.baseline;
        policy.trained_examples += 1;
    }
}

// ─── Routing with RL ────────────────────────────────────────────────────

/// Route a query using the learned policy.
///
/// Falls back to heuristic routing if the policy is untrained
/// (trained_examples == 0).
pub fn route_with_rl(policy: &RoutingPolicy, profile: &QueryProfile) -> RoutingDecision {
    if policy.trained_examples == 0 {
        let router = RetrievalRouter::default();
        return router.route(profile);
    }

    // Compute stage scores from learned weights.
    let bm25_w = *policy.weights.get("bm25_coarse").unwrap_or(&1.0);
    let vector_w = *policy.weights.get("vector_medium").unwrap_or(&1.0);
    let rerank_w = *policy.weights.get("rerank_fine").unwrap_or(&0.5);
    let graph_w = *policy.weights.get("graph_expansion").unwrap_or(&0.3);
    let decoder_w = *policy.weights.get("decoder").unwrap_or(&0.2);
    let discord_w = *policy.weights.get("discord").unwrap_or(&0.2);

    // Enable stages with weight > 0.5.
    let bm25_coarse = bm25_w > 0.5;
    let vector_medium = vector_w > 0.5 && profile.specificity >= 0.15;
    let rerank_fine = rerank_w > 0.5;
    let graph_expansion = graph_w > 0.5 && profile.has_entities;
    let decoder = decoder_w > 0.5 && profile.contradiction_risk;
    let discord = discord_w > 0.5 && profile.has_entities;

    let no_retrieval = !bm25_coarse && !vector_medium && profile.token_count < 3;

    RoutingDecision {
        bm25_coarse,
        vector_medium,
        rerank_fine,
        graph_expansion,
        decoder,
        discord,
        no_retrieval,
        reasoning: format!(
            "RL policy (trained={}): bm25_w={:.2}, vec_w={:.2}, rerank_w={:.2}",
            policy.trained_examples, bm25_w, vector_w, rerank_w
        ),
    }
}

/// Check if the policy has been trained enough to use.
pub fn is_trained(policy: &RoutingPolicy) -> bool {
    policy.trained_examples > 10
}

// ─── Receipt-driven RL routing feedback ─────────────────────────────────

/// The outcome quality of a routing decision, as reported by the caller
/// after observing search results.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RoutingOutcome {
    /// The routing decision produced good results.
    Good,
    /// The routing decision produced poor results.
    Bad,
    /// The routing decision produced acceptable results.
    Neutral,
}

impl RoutingOutcome {
    /// Convert the outcome to a numeric score for policy updates.
    fn to_score(self) -> f64 {
        match self {
            RoutingOutcome::Good => 0.9,
            RoutingOutcome::Neutral => 0.5,
            RoutingOutcome::Bad => 0.1,
        }
    }
}

/// Alias for the tabular routing policy used in receipt-driven RL feedback.
/// This is the same `RoutingPolicy` struct, aliased for clarity in the
/// receipt-driven feedback context.
pub type TabularRoutingPolicy = RoutingPolicy;

/// Record a routing outcome and update the tabular routing policy's Q-table.
///
/// This is the receipt-driven RL feedback loop: after a search is performed
/// using a routing decision, the caller reports whether the outcome was good,
/// bad, or neutral. This function converts the outcome to a score and updates
/// the policy weights accordingly.
///
/// Returns the updated policy.
pub fn record_routing_outcome(
    policy: &mut TabularRoutingPolicy,
    profile: &QueryProfile,
    decision: &RoutingDecision,
    outcome: RoutingOutcome,
) -> TabularRoutingPolicy {
    let score = outcome.to_score();
    let example = extract_training_example(profile, decision, score);
    update_policy(policy, &[example]);
    policy.clone()
}

// ─── Tests ──────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn untrained_policy_falls_back_to_heuristic() {
        let policy = RoutingPolicy::default();
        assert_eq!(policy.trained_examples, 0);
        let profile = QueryProfile::from_query("what is rust");
        let decision = route_with_rl(&policy, &profile);
        // Heuristic routing should enable BM25 for a 4-token query.
        assert!(decision.bm25_coarse);
    }

    #[test]
    fn positive_outcome_increases_weights() {
        let mut policy = RoutingPolicy::default();
        let profile = QueryProfile::from_query("compare rust vs python");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: true,
            rerank_fine: true,
            graph_expansion: false,
            decoder: true,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        let example = TrainingExample {
            query_profile: profile,
            decision,
            outcome_score: 0.9, // positive
        };
        let initial_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        update_policy(&mut policy, &[example]);
        let updated_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        assert!(
            updated_bm25_w > initial_bm25_w,
            "positive outcome should increase weight: {} -> {}",
            initial_bm25_w,
            updated_bm25_w
        );
    }

    #[test]
    fn negative_outcome_decreases_weights() {
        let mut policy = RoutingPolicy::default();
        let profile = QueryProfile::from_query("compare rust vs python");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: true,
            rerank_fine: true,
            graph_expansion: false,
            decoder: true,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        let example = TrainingExample {
            query_profile: profile,
            decision,
            outcome_score: 0.1, // negative (below baseline 0.5)
        };
        let initial_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        update_policy(&mut policy, &[example]);
        let updated_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        assert!(
            updated_bm25_w < initial_bm25_w,
            "negative outcome should decrease weight: {} -> {}",
            initial_bm25_w,
            updated_bm25_w
        );
    }

    #[test]
    fn baseline_updates_correctly() {
        let mut policy = RoutingPolicy::default();
        let initial_baseline = policy.baseline;
        let profile = QueryProfile::from_query("test");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: false,
            rerank_fine: false,
            graph_expansion: false,
            decoder: false,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        let example = TrainingExample {
            query_profile: profile,
            decision,
            outcome_score: 0.8,
        };
        update_policy(&mut policy, &[example]);
        // Baseline should move toward 0.8 from 0.5.
        assert!(
            policy.baseline > initial_baseline,
            "baseline should increase: {} -> {}",
            initial_baseline,
            policy.baseline
        );
    }

    #[test]
    fn is_trained_returns_false_for_new_policy() {
        let policy = RoutingPolicy::default();
        assert!(!is_trained(&policy));
    }

    #[test]
    fn is_trained_returns_true_after_11_examples() {
        let mut policy = RoutingPolicy::default();
        let profile = QueryProfile::from_query("test query here");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: true,
            rerank_fine: false,
            graph_expansion: false,
            decoder: false,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        for _ in 0..11 {
            let example = TrainingExample {
                query_profile: profile.clone(),
                decision: decision.clone(),
                outcome_score: 0.7,
            };
            update_policy(&mut policy, &[example]);
        }
        assert_eq!(policy.trained_examples, 11);
        assert!(is_trained(&policy));
    }

    #[test]
    fn trained_policy_produces_different_decisions() {
        let mut policy = RoutingPolicy::default();

        // Train with high outcome for decoder-enabled decisions.
        // Use a high learning rate to ensure weights cross the 0.5 threshold.
        policy.learning_rate = 0.1;
        let profile = QueryProfile::from_query("compare rust vs python");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: true,
            rerank_fine: true,
            graph_expansion: false,
            decoder: true,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        for _ in 0..50 {
            let example = TrainingExample {
                query_profile: profile.clone(),
                decision: decision.clone(),
                outcome_score: 0.9,
            };
            update_policy(&mut policy, &[example]);
        }

        // Now route a similar query — decoder should be enabled.
        let test_profile = QueryProfile::from_query("compare go vs rust differences");
        let rl_decision = route_with_rl(&policy, &test_profile);
        assert!(
            rl_decision.decoder,
            "trained policy should enable decoder for contradiction queries (decoder weight: {})",
            policy.weights.get("decoder").unwrap_or(&0.0)
        );
    }

    #[test]
    fn empty_examples_does_nothing() {
        let mut policy = RoutingPolicy::default();
        let initial = policy.clone();
        update_policy(&mut policy, &[]);
        assert_eq!(policy.trained_examples, initial.trained_examples);
        assert!((policy.baseline - initial.baseline).abs() < 0.001);
    }

    #[test]
    fn record_routing_outcome_good_increases_weights() {
        let mut policy = RoutingPolicy::default();
        let profile = QueryProfile::from_query("compare rust vs python");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: true,
            rerank_fine: true,
            graph_expansion: false,
            decoder: true,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        let initial_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        record_routing_outcome(&mut policy, &profile, &decision, RoutingOutcome::Good);
        let updated_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        assert!(
            updated_bm25_w > initial_bm25_w,
            "good outcome should increase weight: {} -> {}",
            initial_bm25_w,
            updated_bm25_w
        );
    }

    #[test]
    fn record_routing_outcome_bad_decreases_weights() {
        let mut policy = RoutingPolicy::default();
        let profile = QueryProfile::from_query("compare rust vs python");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: true,
            rerank_fine: true,
            graph_expansion: false,
            decoder: true,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        let initial_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        record_routing_outcome(&mut policy, &profile, &decision, RoutingOutcome::Bad);
        let updated_bm25_w = *policy.weights.get("bm25_coarse").unwrap();
        assert!(
            updated_bm25_w < initial_bm25_w,
            "bad outcome should decrease weight: {} -> {}",
            initial_bm25_w,
            updated_bm25_w
        );
    }

    #[test]
    fn record_routing_outcome_neutral_does_not_change_much() {
        let mut policy = RoutingPolicy::default();
        let profile = QueryProfile::from_query("test query here");
        let decision = RoutingDecision {
            bm25_coarse: true,
            vector_medium: false,
            rerank_fine: false,
            graph_expansion: false,
            decoder: false,
            discord: false,
            no_retrieval: false,
            reasoning: "test".to_string(),
        };
        let initial_trained = policy.trained_examples;
        record_routing_outcome(&mut policy, &profile, &decision, RoutingOutcome::Neutral);
        assert_eq!(policy.trained_examples, initial_trained + 1);
    }
}