Skip to main content

prismer_sdk/
evolution_cache.rs

1//! EvolutionCache — local gene cache with Thompson Sampling selection.
2//!
3//! Enables <1ms gene selection without network calls.
4//! Port of sdk/typescript/src/evolution-cache.ts.
5
6use std::collections::HashMap;
7use crate::types::SignalTag;
8
9/// Result of a local gene selection via Thompson Sampling.
10#[derive(Debug)]
11pub struct GeneSelectionResult {
12    /// "apply_gene", "create_suggested", or "none"
13    pub action: String,
14    pub gene_id: Option<String>,
15    pub gene: Option<serde_json::Value>,
16    pub strategy: Option<Vec<String>>,
17    pub confidence: f64,
18    pub coverage_score: Option<f64>,
19    pub alternatives: Vec<Alternative>,
20    pub reason: String,
21    pub from_cache: bool,
22}
23
24/// An alternative gene candidate.
25#[derive(Debug)]
26pub struct Alternative {
27    pub gene_id: String,
28    pub confidence: f64,
29    pub title: Option<String>,
30}
31
32/// Local gene cache with Thompson Sampling selection.
33///
34/// # Example
35/// ```no_run
36/// use prismer_sdk::evolution_cache::EvolutionCache;
37/// use prismer_sdk::types::SignalTag;
38///
39/// let mut cache = EvolutionCache::new();
40/// // cache.load_snapshot(&snapshot);
41/// // let result = cache.select_gene(&[SignalTag { signal_type: "error:timeout".into(), .. }]);
42/// ```
43pub struct EvolutionCache {
44    genes: HashMap<String, serde_json::Value>,
45    edges: HashMap<String, Vec<serde_json::Value>>,
46    global_prior: HashMap<String, (f64, f64)>, // (alpha, beta)
47    cursor: u64,
48}
49
50impl EvolutionCache {
51    /// Create a new empty evolution cache.
52    pub fn new() -> Self {
53        Self {
54            genes: HashMap::new(),
55            edges: HashMap::new(),
56            global_prior: HashMap::new(),
57            cursor: 0,
58        }
59    }
60
61    /// Number of genes in the cache.
62    pub fn gene_count(&self) -> usize {
63        self.genes.len()
64    }
65
66    /// Current sync cursor.
67    pub fn cursor(&self) -> u64 {
68        self.cursor
69    }
70
71    /// Load from a full sync snapshot, replacing all existing data.
72    pub fn load_snapshot(&mut self, snapshot: &serde_json::Value) {
73        self.genes.clear();
74        self.edges.clear();
75        self.global_prior.clear();
76
77        // Load genes
78        if let Some(genes) = snapshot.get("genes").and_then(|v| v.as_array()) {
79            for gene in genes {
80                let id = gene.get("id").or_else(|| gene.get("gene_id"))
81                    .and_then(|v| v.as_str());
82                if let Some(id) = id {
83                    self.genes.insert(id.to_string(), gene.clone());
84                }
85            }
86        }
87
88        // Load edges
89        if let Some(edges) = snapshot.get("edges").and_then(|v| v.as_array()) {
90            for edge in edges {
91                let key = edge.get("signal_key").or_else(|| edge.get("signalKey"))
92                    .and_then(|v| v.as_str());
93                if let Some(key) = key {
94                    self.edges.entry(key.to_string()).or_default().push(edge.clone());
95                }
96            }
97        }
98
99        // Load global prior
100        let gp = snapshot.get("globalPrior").or_else(|| snapshot.get("global_prior"));
101        if let Some(gp) = gp.and_then(|v| v.as_object()) {
102            for (key, val) in gp {
103                if let Some(obj) = val.as_object() {
104                    let alpha = obj.get("alpha").and_then(|v| v.as_f64()).unwrap_or(1.0);
105                    let beta = obj.get("beta").and_then(|v| v.as_f64()).unwrap_or(1.0);
106                    self.global_prior.insert(key.clone(), (alpha, beta));
107                } else if let Some(f) = val.as_f64() {
108                    self.global_prior.insert(key.clone(), (f, 1.0));
109                }
110            }
111        }
112
113        if let Some(cur) = snapshot.get("cursor").and_then(|v| v.as_u64()) {
114            self.cursor = cur;
115        }
116    }
117
118    /// Apply an incremental sync delta.
119    pub fn apply_delta(&mut self, delta: &serde_json::Value) {
120        let pulled = delta.get("pulled").unwrap_or(delta);
121
122        // Update genes
123        if let Some(genes) = pulled.get("genes").and_then(|v| v.as_array()) {
124            for gene in genes {
125                let id = gene.get("id").or_else(|| gene.get("gene_id"))
126                    .and_then(|v| v.as_str());
127                if let Some(id) = id {
128                    self.genes.insert(id.to_string(), gene.clone());
129                }
130            }
131        }
132
133        // Remove quarantined
134        if let Some(quarantines) = pulled.get("quarantines").and_then(|v| v.as_array()) {
135            for qid in quarantines {
136                if let Some(qid) = qid.as_str() {
137                    self.genes.remove(qid);
138                }
139            }
140        }
141
142        // Update edges
143        if let Some(edges) = pulled.get("edges").and_then(|v| v.as_array()) {
144            for edge in edges {
145                let key = edge.get("signal_key").or_else(|| edge.get("signalKey"))
146                    .and_then(|v| v.as_str());
147                let gene_id = edge.get("gene_id").or_else(|| edge.get("geneId"))
148                    .and_then(|v| v.as_str()).unwrap_or("");
149                if let Some(key) = key {
150                    let list = self.edges.entry(key.to_string()).or_default();
151                    let mut found = false;
152                    for existing in list.iter_mut() {
153                        let eid = existing.get("gene_id").or_else(|| existing.get("geneId"))
154                            .and_then(|v| v.as_str()).unwrap_or("");
155                        if eid == gene_id {
156                            *existing = edge.clone();
157                            found = true;
158                            break;
159                        }
160                    }
161                    if !found {
162                        list.push(edge.clone());
163                    }
164                }
165            }
166        }
167
168        // Update global prior
169        let gp = pulled.get("globalPrior").or_else(|| pulled.get("global_prior"));
170        if let Some(gp) = gp.and_then(|v| v.as_object()) {
171            for (key, val) in gp {
172                if let Some(obj) = val.as_object() {
173                    let alpha = obj.get("alpha").and_then(|v| v.as_f64()).unwrap_or(1.0);
174                    let beta = obj.get("beta").and_then(|v| v.as_f64()).unwrap_or(1.0);
175                    self.global_prior.insert(key.clone(), (alpha, beta));
176                }
177            }
178        }
179
180        if let Some(cur) = pulled.get("cursor").and_then(|v| v.as_u64()) {
181            self.cursor = cur;
182        }
183    }
184
185    /// Alias for `apply_delta` (API parity).
186    pub fn load_delta(&mut self, delta: &serde_json::Value) {
187        self.apply_delta(delta);
188    }
189
190    /// Select the best gene for the given signals using Thompson Sampling.
191    /// Pure CPU, <1ms.
192    pub fn select_gene(&self, signals: &[SignalTag]) -> GeneSelectionResult {
193        if self.genes.is_empty() {
194            return GeneSelectionResult {
195                action: "none".to_string(),
196                gene_id: None,
197                gene: None,
198                strategy: None,
199                confidence: 0.0,
200                coverage_score: None,
201                alternatives: vec![],
202                reason: "no genes in cache".to_string(),
203                from_cache: true,
204            };
205        }
206
207        let signal_keys: Vec<&str> = signals.iter().map(|s| s.signal_type.as_str()).collect();
208
209        struct Candidate {
210            gene: serde_json::Value,
211            rank_score: f64,
212            coverage_score: f64,
213        }
214        let mut candidates: Vec<Candidate> = Vec::new();
215
216        for gene in self.genes.values() {
217            // Skip quarantined
218            if gene.get("visibility").and_then(|v| v.as_str()) == Some("quarantined") {
219                continue;
220            }
221
222            // Extract gene signal types
223            let gene_signal_types = self.extract_gene_signal_types(gene);
224            if gene_signal_types.is_empty() {
225                continue;
226            }
227
228            // Coverage score
229            let match_count = signal_keys.iter()
230                .filter(|k| gene_signal_types.iter().any(|gs| gs == *k))
231                .count();
232            let coverage_score = match_count as f64 / gene_signal_types.len() as f64;
233            if coverage_score == 0.0 {
234                continue;
235            }
236
237            // Thompson Sampling: Beta(alpha, beta) mean
238            let sc = gene.get("success_count").or_else(|| gene.get("successCount"))
239                .and_then(|v| v.as_f64()).unwrap_or(0.0);
240            let fc = gene.get("failure_count").or_else(|| gene.get("failureCount"))
241                .and_then(|v| v.as_f64()).unwrap_or(0.0);
242            let mut alpha = sc + 1.0;
243            let mut beta = fc + 1.0;
244
245            // Blend with global prior (weight 0.3)
246            for key in &signal_keys {
247                if let Some(&(pa, pb)) = self.global_prior.get(*key) {
248                    alpha += 0.3 * pa;
249                    beta += 0.3 * pb;
250                }
251            }
252
253            let sampled_score = alpha / (alpha + beta);
254
255            // Ban threshold: skip if success rate < 18% with enough data
256            let total_obs = sc + fc;
257            if total_obs >= 10.0 && sc / total_obs < 0.18 {
258                continue;
259            }
260
261            // Combined rank score
262            let rank_score = coverage_score * 0.4 + sampled_score * 0.6;
263
264            candidates.push(Candidate {
265                gene: gene.clone(),
266                rank_score,
267                coverage_score,
268            });
269        }
270
271        if candidates.is_empty() {
272            return GeneSelectionResult {
273                action: "create_suggested".to_string(),
274                gene_id: None,
275                gene: None,
276                strategy: None,
277                confidence: 0.0,
278                coverage_score: None,
279                alternatives: vec![],
280                reason: "no matching genes for signals".to_string(),
281                from_cache: true,
282            };
283        }
284
285        // Sort by rank score descending
286        candidates.sort_by(|a, b| b.rank_score.partial_cmp(&a.rank_score).unwrap_or(std::cmp::Ordering::Equal));
287
288        let best = &candidates[0];
289
290        // Build alternatives (top 3 after best)
291        let limit = std::cmp::min(candidates.len(), 4);
292        let alternatives: Vec<Alternative> = candidates[1..limit].iter().map(|c| {
293            Alternative {
294                gene_id: c.gene.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
295                confidence: round_to_2(c.rank_score),
296                title: c.gene.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()),
297            }
298        }).collect();
299
300        // Extract strategy
301        let strategy = best.gene.get("strategy").and_then(|v| v.as_array()).map(|arr| {
302            arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()
303        });
304
305        let gene_id = best.gene.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
306
307        GeneSelectionResult {
308            action: "apply_gene".to_string(),
309            gene_id: Some(gene_id),
310            gene: Some(best.gene.clone()),
311            strategy,
312            confidence: round_to_2(best.rank_score),
313            coverage_score: Some(round_to_2(best.coverage_score)),
314            alternatives,
315            reason: format!("local cache selection ({} genes)", self.genes.len()),
316            from_cache: true,
317        }
318    }
319
320    // ── helpers ───────────────────────────────────────────────
321
322    fn extract_gene_signal_types(&self, gene: &serde_json::Value) -> Vec<String> {
323        let raw = gene.get("signals_match").or_else(|| gene.get("signalsMatch"));
324        match raw.and_then(|v| v.as_array()) {
325            Some(arr) => arr.iter().filter_map(|s| {
326                if let Some(str_val) = s.as_str() {
327                    Some(str_val.to_string())
328                } else if let Some(obj) = s.as_object() {
329                    obj.get("type").and_then(|v| v.as_str()).map(|s| s.to_string())
330                } else {
331                    None
332                }
333            }).collect(),
334            None => vec![],
335        }
336    }
337}
338
339impl Default for EvolutionCache {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345fn round_to_2(v: f64) -> f64 {
346    (v * 100.0 + 0.5).floor() / 100.0
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    fn make_signal(signal_type: &str) -> SignalTag {
355        SignalTag {
356            signal_type: signal_type.to_string(),
357            provider: None,
358            stage: None,
359            severity: None,
360        }
361    }
362
363    fn make_snapshot(genes: Vec<serde_json::Value>, edges: Vec<serde_json::Value>) -> serde_json::Value {
364        json!({
365            "genes": genes,
366            "edges": edges,
367            "cursor": 42,
368        })
369    }
370
371    fn gene_json(id: &str, signals: &[&str], success: i64, failure: i64) -> serde_json::Value {
372        json!({
373            "id": id,
374            "category": "test",
375            "title": format!("Gene {}", id),
376            "signals_match": signals,
377            "strategy": ["try something"],
378            "visibility": "public",
379            "success_count": success,
380            "failure_count": failure,
381        })
382    }
383
384    // ── EvolutionCache basic ─────────────────────────────
385
386    #[test]
387    fn new_cache_is_empty() {
388        let cache = EvolutionCache::new();
389        assert_eq!(cache.gene_count(), 0);
390        assert_eq!(cache.cursor(), 0);
391    }
392
393    #[test]
394    fn default_cache_is_empty() {
395        let cache = EvolutionCache::default();
396        assert_eq!(cache.gene_count(), 0);
397    }
398
399    #[test]
400    fn load_snapshot_populates_genes() {
401        let mut cache = EvolutionCache::new();
402        let snapshot = make_snapshot(
403            vec![gene_json("g1", &["error:timeout"], 5, 1)],
404            vec![],
405        );
406        cache.load_snapshot(&snapshot);
407        assert_eq!(cache.gene_count(), 1);
408        assert_eq!(cache.cursor(), 42);
409    }
410
411    #[test]
412    fn load_snapshot_replaces_existing() {
413        let mut cache = EvolutionCache::new();
414        cache.load_snapshot(&make_snapshot(
415            vec![gene_json("g1", &["error:timeout"], 5, 1)],
416            vec![],
417        ));
418        assert_eq!(cache.gene_count(), 1);
419
420        cache.load_snapshot(&make_snapshot(
421            vec![
422                gene_json("g2", &["error:dns_error"], 3, 0),
423                gene_json("g3", &["error:crash"], 1, 1),
424            ],
425            vec![],
426        ));
427        assert_eq!(cache.gene_count(), 2);
428    }
429
430    #[test]
431    fn load_snapshot_with_gene_id_key() {
432        let mut cache = EvolutionCache::new();
433        let snapshot = json!({
434            "genes": [{"gene_id": "alt-1", "signals_match": ["error:timeout"]}],
435            "cursor": 10,
436        });
437        cache.load_snapshot(&snapshot);
438        assert_eq!(cache.gene_count(), 1);
439    }
440
441    // ── select_gene ──────────────────────────────────────
442
443    #[test]
444    fn select_gene_empty_cache_returns_none() {
445        let cache = EvolutionCache::new();
446        let result = cache.select_gene(&[make_signal("error:timeout")]);
447        assert_eq!(result.action, "none");
448        assert!(result.gene_id.is_none());
449        assert!(result.from_cache);
450    }
451
452    #[test]
453    fn select_gene_matching_signal() {
454        let mut cache = EvolutionCache::new();
455        cache.load_snapshot(&make_snapshot(
456            vec![gene_json("g1", &["error:timeout"], 10, 1)],
457            vec![],
458        ));
459        let result = cache.select_gene(&[make_signal("error:timeout")]);
460        assert_eq!(result.action, "apply_gene");
461        assert_eq!(result.gene_id.as_deref(), Some("g1"));
462        assert!(result.confidence > 0.0);
463        assert!(result.from_cache);
464    }
465
466    #[test]
467    fn select_gene_no_matching_signal() {
468        let mut cache = EvolutionCache::new();
469        cache.load_snapshot(&make_snapshot(
470            vec![gene_json("g1", &["error:timeout"], 10, 1)],
471            vec![],
472        ));
473        let result = cache.select_gene(&[make_signal("error:crash")]);
474        assert_eq!(result.action, "create_suggested");
475    }
476
477    #[test]
478    fn select_gene_skips_quarantined() {
479        let mut cache = EvolutionCache::new();
480        let mut gene = gene_json("g1", &["error:timeout"], 10, 1);
481        gene["visibility"] = json!("quarantined");
482        cache.load_snapshot(&make_snapshot(vec![gene], vec![]));
483        let result = cache.select_gene(&[make_signal("error:timeout")]);
484        assert_eq!(result.action, "create_suggested");
485    }
486
487    #[test]
488    fn select_gene_ban_threshold() {
489        let mut cache = EvolutionCache::new();
490        // 1 success, 9 failures = 10% success rate < 18% ban threshold
491        cache.load_snapshot(&make_snapshot(
492            vec![gene_json("g1", &["error:timeout"], 1, 9)],
493            vec![],
494        ));
495        let result = cache.select_gene(&[make_signal("error:timeout")]);
496        assert_eq!(result.action, "create_suggested");
497    }
498
499    #[test]
500    fn select_gene_picks_best_of_multiple() {
501        let mut cache = EvolutionCache::new();
502        cache.load_snapshot(&make_snapshot(
503            vec![
504                gene_json("g-low", &["error:timeout"], 2, 5),
505                gene_json("g-high", &["error:timeout"], 20, 1),
506            ],
507            vec![],
508        ));
509        let result = cache.select_gene(&[make_signal("error:timeout")]);
510        assert_eq!(result.action, "apply_gene");
511        assert_eq!(result.gene_id.as_deref(), Some("g-high"));
512    }
513
514    #[test]
515    fn select_gene_returns_alternatives() {
516        let mut cache = EvolutionCache::new();
517        cache.load_snapshot(&make_snapshot(
518            vec![
519                gene_json("g1", &["error:timeout"], 20, 1),
520                gene_json("g2", &["error:timeout"], 15, 2),
521                gene_json("g3", &["error:timeout"], 10, 3),
522            ],
523            vec![],
524        ));
525        let result = cache.select_gene(&[make_signal("error:timeout")]);
526        assert_eq!(result.action, "apply_gene");
527        assert!(result.alternatives.len() >= 1);
528    }
529
530    #[test]
531    fn select_gene_returns_strategy() {
532        let mut cache = EvolutionCache::new();
533        cache.load_snapshot(&make_snapshot(
534            vec![gene_json("g1", &["error:timeout"], 10, 1)],
535            vec![],
536        ));
537        let result = cache.select_gene(&[make_signal("error:timeout")]);
538        assert!(result.strategy.is_some());
539        assert!(!result.strategy.unwrap().is_empty());
540    }
541
542    // ── apply_delta ──────────────────────────────────────
543
544    #[test]
545    fn apply_delta_adds_genes() {
546        let mut cache = EvolutionCache::new();
547        cache.load_snapshot(&make_snapshot(
548            vec![gene_json("g1", &["error:timeout"], 5, 1)],
549            vec![],
550        ));
551        assert_eq!(cache.gene_count(), 1);
552
553        let delta = json!({
554            "pulled": {
555                "genes": [{"id": "g2", "signals_match": ["error:crash"], "success_count": 3, "failure_count": 0}],
556                "cursor": 50,
557            }
558        });
559        cache.apply_delta(&delta);
560        assert_eq!(cache.gene_count(), 2);
561        assert_eq!(cache.cursor(), 50);
562    }
563
564    #[test]
565    fn apply_delta_quarantines_genes() {
566        let mut cache = EvolutionCache::new();
567        cache.load_snapshot(&make_snapshot(
568            vec![
569                gene_json("g1", &["error:timeout"], 5, 1),
570                gene_json("g2", &["error:crash"], 3, 0),
571            ],
572            vec![],
573        ));
574        assert_eq!(cache.gene_count(), 2);
575
576        let delta = json!({
577            "pulled": {
578                "quarantines": ["g1"],
579                "cursor": 55,
580            }
581        });
582        cache.apply_delta(&delta);
583        assert_eq!(cache.gene_count(), 1);
584    }
585
586    #[test]
587    fn load_delta_is_alias_for_apply_delta() {
588        let mut cache = EvolutionCache::new();
589        let delta = json!({
590            "pulled": {
591                "genes": [{"id": "g1", "signals_match": ["error:timeout"]}],
592                "cursor": 10,
593            }
594        });
595        cache.load_delta(&delta);
596        assert_eq!(cache.gene_count(), 1);
597        assert_eq!(cache.cursor(), 10);
598    }
599
600    // ── load_snapshot global prior ───────────────────────
601
602    #[test]
603    fn load_snapshot_with_global_prior() {
604        let mut cache = EvolutionCache::new();
605        let snapshot = json!({
606            "genes": [{"id": "g1", "signals_match": ["error:timeout"], "success_count": 5, "failure_count": 1}],
607            "globalPrior": {
608                "error:timeout": {"alpha": 10.0, "beta": 2.0},
609            },
610            "cursor": 1,
611        });
612        cache.load_snapshot(&snapshot);
613        // global prior should influence selection
614        let result = cache.select_gene(&[make_signal("error:timeout")]);
615        assert_eq!(result.action, "apply_gene");
616        // confidence should be higher with strong global prior
617        assert!(result.confidence > 0.5);
618    }
619
620    // ── round_to_2 ──────────────────────────────────────
621
622    #[test]
623    fn round_to_2_basic() {
624        assert_eq!(round_to_2(0.555), 0.56);
625        assert_eq!(round_to_2(0.0), 0.0);
626    }
627
628    #[test]
629    fn round_to_2_already_round() {
630        // (0.50 * 100 + 0.5).floor() / 100 = (50.5).floor() / 100 = 50/100 = 0.50
631        assert_eq!(round_to_2(0.50), 0.50);
632        assert_eq!(round_to_2(1.0), 1.0);
633    }
634
635    // ── coverage_score ───────────────────────────────────
636
637    #[test]
638    fn select_gene_coverage_score_present() {
639        let mut cache = EvolutionCache::new();
640        cache.load_snapshot(&make_snapshot(
641            vec![gene_json("g1", &["error:timeout"], 10, 1)],
642            vec![],
643        ));
644        let result = cache.select_gene(&[make_signal("error:timeout")]);
645        assert!(result.coverage_score.is_some());
646        assert!(result.coverage_score.unwrap() > 0.0);
647    }
648
649    // ── Thompson Sampling confidence ─────────────────────
650
651    #[test]
652    fn thompson_sampling_more_successes_higher_confidence() {
653        let mut cache = EvolutionCache::new();
654        cache.load_snapshot(&make_snapshot(
655            vec![
656                gene_json("g-weak", &["error:timeout"], 3, 3),
657                gene_json("g-strong", &["error:timeout"], 50, 2),
658            ],
659            vec![],
660        ));
661        let result = cache.select_gene(&[make_signal("error:timeout")]);
662        assert_eq!(result.action, "apply_gene");
663        assert_eq!(result.gene_id.as_deref(), Some("g-strong"));
664        // The strong gene should have clearly higher confidence
665        assert!(result.confidence > 0.5);
666        // Alternatives should contain the weaker gene
667        assert_eq!(result.alternatives.len(), 1);
668        assert_eq!(result.alternatives[0].gene_id, "g-weak");
669        assert!(result.confidence > result.alternatives[0].confidence);
670    }
671
672    #[test]
673    fn thompson_sampling_global_prior_boosts_confidence() {
674        // Without global prior
675        let mut cache_no_prior = EvolutionCache::new();
676        cache_no_prior.load_snapshot(&make_snapshot(
677            vec![gene_json("g1", &["error:timeout"], 5, 5)],
678            vec![],
679        ));
680        let result_no_prior = cache_no_prior.select_gene(&[make_signal("error:timeout")]);
681
682        // With strong global prior favoring the signal type
683        let mut cache_with_prior = EvolutionCache::new();
684        let snapshot = json!({
685            "genes": [gene_json("g1", &["error:timeout"], 5, 5)],
686            "globalPrior": {
687                "error:timeout": {"alpha": 50.0, "beta": 1.0},
688            },
689            "cursor": 1,
690        });
691        cache_with_prior.load_snapshot(&snapshot);
692        let result_with_prior = cache_with_prior.select_gene(&[make_signal("error:timeout")]);
693
694        // Global prior should boost confidence
695        assert!(result_with_prior.confidence > result_no_prior.confidence);
696    }
697
698    // ── Edge-based selection ─────────────────────────────
699
700    #[test]
701    fn load_snapshot_with_edges() {
702        let mut cache = EvolutionCache::new();
703        let snapshot = json!({
704            "genes": [
705                gene_json("g1", &["error:timeout"], 10, 1),
706            ],
707            "edges": [
708                {"signal_key": "error:timeout", "gene_id": "g1", "weight": 0.9},
709                {"signalKey": "error:crash", "geneId": "g1", "weight": 0.5},
710            ],
711            "cursor": 100,
712        });
713        cache.load_snapshot(&snapshot);
714        assert_eq!(cache.gene_count(), 1);
715        assert_eq!(cache.cursor(), 100);
716    }
717
718    #[test]
719    fn apply_delta_updates_edges() {
720        let mut cache = EvolutionCache::new();
721        cache.load_snapshot(&make_snapshot(
722            vec![gene_json("g1", &["error:timeout"], 10, 1)],
723            vec![],
724        ));
725
726        let delta = json!({
727            "pulled": {
728                "edges": [
729                    {"signal_key": "error:timeout", "gene_id": "g1", "weight": 0.95},
730                ],
731                "cursor": 60,
732            }
733        });
734        cache.apply_delta(&delta);
735        assert_eq!(cache.cursor(), 60);
736    }
737
738    #[test]
739    fn apply_delta_updates_existing_gene() {
740        let mut cache = EvolutionCache::new();
741        cache.load_snapshot(&make_snapshot(
742            vec![gene_json("g1", &["error:timeout"], 5, 1)],
743            vec![],
744        ));
745
746        // Update g1 with new success count
747        let delta = json!({
748            "pulled": {
749                "genes": [{"id": "g1", "signals_match": ["error:timeout"], "success_count": 20, "failure_count": 1}],
750                "cursor": 70,
751            }
752        });
753        cache.apply_delta(&delta);
754        // Still 1 gene (updated, not added)
755        assert_eq!(cache.gene_count(), 1);
756
757        let result = cache.select_gene(&[make_signal("error:timeout")]);
758        assert_eq!(result.action, "apply_gene");
759        assert_eq!(result.gene_id.as_deref(), Some("g1"));
760    }
761
762    #[test]
763    fn apply_delta_updates_global_prior() {
764        let mut cache = EvolutionCache::new();
765        cache.load_snapshot(&make_snapshot(
766            vec![gene_json("g1", &["error:timeout"], 5, 5)],
767            vec![],
768        ));
769        let result_before = cache.select_gene(&[make_signal("error:timeout")]);
770
771        let delta = json!({
772            "pulled": {
773                "globalPrior": {
774                    "error:timeout": {"alpha": 100.0, "beta": 1.0},
775                },
776                "cursor": 80,
777            }
778        });
779        cache.apply_delta(&delta);
780        let result_after = cache.select_gene(&[make_signal("error:timeout")]);
781
782        // After applying a strong prior, confidence should increase
783        assert!(result_after.confidence >= result_before.confidence);
784    }
785
786    // ── Multi-signal matching ────────────────────────────
787
788    #[test]
789    fn select_gene_multi_signal_coverage() {
790        let mut cache = EvolutionCache::new();
791        cache.load_snapshot(&make_snapshot(
792            vec![
793                // g1 matches only 1 of 2 signals
794                gene_json("g1", &["error:timeout"], 10, 1),
795                // g2 matches both signals
796                gene_json("g2", &["error:timeout", "error:crash"], 8, 1),
797            ],
798            vec![],
799        ));
800        let result = cache.select_gene(&[
801            make_signal("error:timeout"),
802            make_signal("error:crash"),
803        ]);
804        assert_eq!(result.action, "apply_gene");
805        // g2 should win due to higher coverage score (2/2 vs 1/1 for both full coverage,
806        // but g2 matches more of the input signals)
807        assert!(result.coverage_score.is_some());
808    }
809
810    #[test]
811    fn select_gene_no_signal_types_on_gene() {
812        let mut cache = EvolutionCache::new();
813        // Gene with empty signals_match
814        let snapshot = json!({
815            "genes": [{"id": "g1", "signals_match": [], "success_count": 10, "failure_count": 0}],
816            "cursor": 1,
817        });
818        cache.load_snapshot(&snapshot);
819        let result = cache.select_gene(&[make_signal("error:timeout")]);
820        // Should not match — empty signal types
821        assert_eq!(result.action, "create_suggested");
822    }
823
824    // ── signals_match with object format ─────────────────
825
826    #[test]
827    fn select_gene_with_object_signal_format() {
828        let mut cache = EvolutionCache::new();
829        let snapshot = json!({
830            "genes": [{
831                "id": "g1",
832                "signals_match": [{"type": "error:timeout"}],
833                "success_count": 10,
834                "failure_count": 1,
835            }],
836            "cursor": 1,
837        });
838        cache.load_snapshot(&snapshot);
839        let result = cache.select_gene(&[make_signal("error:timeout")]);
840        assert_eq!(result.action, "apply_gene");
841    }
842}