Skip to main content

oris_evolution/
core.rs

1//! Evolution domain model, append-only event store, projections, and selector logic.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::fs::{self, File, OpenOptions};
5use std::io::{BufRead, BufReader, Write};
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use chrono::{DateTime, Duration, Utc};
11use oris_kernel::RunId;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15
16pub type MutationId = String;
17pub type GeneId = String;
18pub type CapsuleId = String;
19
20pub const REPLAY_CONFIDENCE_DECAY_RATE_PER_HOUR: f32 = 0.05;
21pub const MIN_REPLAY_CONFIDENCE: f32 = 0.35;
22
23#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
24pub enum AssetState {
25    Candidate,
26    #[default]
27    Promoted,
28    Revoked,
29    Archived,
30    Quarantined,
31    ShadowValidated,
32}
33
34/// Convert Oris AssetState to EvoMap-compatible state string.
35/// This mapping preserves the EvoMap terminology without modifying the core enum.
36pub fn asset_state_to_evomap_compat(state: &AssetState) -> &'static str {
37    match state {
38        AssetState::Candidate => "candidate",
39        AssetState::Promoted => "promoted",
40        AssetState::Revoked => "revoked",
41        AssetState::Archived => "rejected", // Archive maps to rejected in EvoMap terms
42        AssetState::Quarantined => "quarantined",
43        // EvoMap does not yet model shadow trust directly, so map it to candidate semantics.
44        AssetState::ShadowValidated => "candidate",
45    }
46}
47
48#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
49pub enum CandidateSource {
50    #[default]
51    Local,
52    Remote,
53}
54
55#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum TransitionReasonCode {
58    #[default]
59    Unspecified,
60    PromotionSuccessThreshold,
61    PromotionRemoteReplayValidated,
62    PromotionBuiltinColdStartCompatibility,
63    PromotionTrustedLocalReport,
64    RevalidationConfidenceDecay,
65    DowngradeReplayRegression,
66    DowngradeConfidenceRegression,
67    DowngradeRemoteRequiresLocalValidation,
68    DowngradeBootstrapRequiresLocalValidation,
69    DowngradeBuiltinRequiresValidation,
70    CandidateRateLimited,
71    CandidateCoolingWindow,
72    CandidateBlastRadiusExceeded,
73    CandidateCollectingEvidence,
74    PromotionShadowValidationPassed,
75    PromotionShadowThresholdPassed,
76    ShadowCollectingReplayEvidence,
77}
78
79#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81pub enum ReplayRoiReasonCode {
82    #[default]
83    Unspecified,
84    ReplayHit,
85    ReplayMissNoMatchingGene,
86    ReplayMissScoreBelowThreshold,
87    ReplayMissCandidateHasNoCapsule,
88    ReplayMissMutationPayloadMissing,
89    ReplayMissPatchApplyFailed,
90    ReplayMissValidationFailed,
91}
92
93#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
94pub struct TransitionEvidence {
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub replay_attempts: Option<u64>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub replay_successes: Option<u64>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub replay_success_rate: Option<f32>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub environment_match_factor: Option<f32>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub decayed_confidence: Option<f32>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub confidence_decay_ratio: Option<f32>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub summary: Option<String>,
109}
110
111#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
112pub struct ReplayRoiEvidence {
113    pub success: bool,
114    #[serde(default)]
115    pub reason_code: ReplayRoiReasonCode,
116    pub task_class_id: String,
117    pub task_label: String,
118    pub reasoning_avoided_tokens: u64,
119    pub replay_fallback_cost: u64,
120    pub replay_roi: f64,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub asset_origin: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub source_sender_id: Option<String>,
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub context_dimensions: Vec<String>,
127}
128
129#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
130pub struct BlastRadius {
131    pub files_changed: usize,
132    pub lines_changed: usize,
133}
134
135#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
136pub enum RiskLevel {
137    Low,
138    Medium,
139    High,
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
143pub enum ArtifactEncoding {
144    UnifiedDiff,
145}
146
147#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
148pub enum MutationTarget {
149    WorkspaceRoot,
150    Crate { name: String },
151    Paths { allow: Vec<String> },
152}
153
154#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
155pub struct MutationIntent {
156    pub id: MutationId,
157    pub intent: String,
158    pub target: MutationTarget,
159    pub expected_effect: String,
160    pub risk: RiskLevel,
161    pub signals: Vec<String>,
162    #[serde(default)]
163    pub spec_id: Option<String>,
164}
165
166#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
167pub struct MutationArtifact {
168    pub encoding: ArtifactEncoding,
169    pub payload: String,
170    pub base_revision: Option<String>,
171    pub content_hash: String,
172}
173
174#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
175pub struct PreparedMutation {
176    pub intent: MutationIntent,
177    pub artifact: MutationArtifact,
178}
179
180#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
181pub struct ValidationSnapshot {
182    pub success: bool,
183    pub profile: String,
184    pub duration_ms: u64,
185    pub summary: String,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
189pub struct Outcome {
190    pub success: bool,
191    pub validation_profile: String,
192    pub validation_duration_ms: u64,
193    pub changed_files: Vec<String>,
194    pub validator_hash: String,
195    #[serde(default)]
196    pub lines_changed: usize,
197    #[serde(default)]
198    pub replay_verified: bool,
199}
200
201#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
202pub struct EnvFingerprint {
203    pub rustc_version: String,
204    pub cargo_lock_hash: String,
205    pub target_triple: String,
206    pub os: String,
207}
208
209#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
210pub struct Capsule {
211    pub id: CapsuleId,
212    pub gene_id: GeneId,
213    pub mutation_id: MutationId,
214    pub run_id: RunId,
215    pub diff_hash: String,
216    pub confidence: f32,
217    pub env: EnvFingerprint,
218    pub outcome: Outcome,
219    #[serde(default)]
220    pub state: AssetState,
221}
222
223#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
224pub struct Gene {
225    pub id: GeneId,
226    pub signals: Vec<String>,
227    pub strategy: Vec<String>,
228    pub validation: Vec<String>,
229    #[serde(default)]
230    pub state: AssetState,
231}
232
233#[derive(Clone, Debug, Serialize, Deserialize)]
234#[serde(tag = "kind", rename_all = "snake_case")]
235pub enum EvolutionEvent {
236    MutationDeclared {
237        mutation: PreparedMutation,
238    },
239    MutationApplied {
240        mutation_id: MutationId,
241        patch_hash: String,
242        changed_files: Vec<String>,
243    },
244    SignalsExtracted {
245        mutation_id: MutationId,
246        hash: String,
247        signals: Vec<String>,
248    },
249    MutationRejected {
250        mutation_id: MutationId,
251        reason: String,
252        #[serde(default, skip_serializing_if = "Option::is_none")]
253        reason_code: Option<String>,
254        #[serde(default, skip_serializing_if = "Option::is_none")]
255        recovery_hint: Option<String>,
256        #[serde(default)]
257        fail_closed: bool,
258    },
259    ValidationPassed {
260        mutation_id: MutationId,
261        report: ValidationSnapshot,
262        gene_id: Option<GeneId>,
263    },
264    ValidationFailed {
265        mutation_id: MutationId,
266        report: ValidationSnapshot,
267        gene_id: Option<GeneId>,
268    },
269    CapsuleCommitted {
270        capsule: Capsule,
271    },
272    CapsuleQuarantined {
273        capsule_id: CapsuleId,
274    },
275    CapsuleReleased {
276        capsule_id: CapsuleId,
277        state: AssetState,
278    },
279    CapsuleReused {
280        capsule_id: CapsuleId,
281        gene_id: GeneId,
282        run_id: RunId,
283        #[serde(default, skip_serializing_if = "Option::is_none")]
284        replay_run_id: Option<RunId>,
285    },
286    GeneProjected {
287        gene: Gene,
288    },
289    GenePromoted {
290        gene_id: GeneId,
291    },
292    GeneRevoked {
293        gene_id: GeneId,
294        reason: String,
295    },
296    GeneArchived {
297        gene_id: GeneId,
298    },
299    PromotionEvaluated {
300        gene_id: GeneId,
301        state: AssetState,
302        reason: String,
303        #[serde(default)]
304        reason_code: TransitionReasonCode,
305        #[serde(default, skip_serializing_if = "Option::is_none")]
306        evidence: Option<TransitionEvidence>,
307    },
308    ReplayEconomicsRecorded {
309        #[serde(default, skip_serializing_if = "Option::is_none")]
310        gene_id: Option<GeneId>,
311        #[serde(default, skip_serializing_if = "Option::is_none")]
312        capsule_id: Option<CapsuleId>,
313        #[serde(default, skip_serializing_if = "Option::is_none")]
314        replay_run_id: Option<RunId>,
315        evidence: ReplayRoiEvidence,
316    },
317    RemoteAssetImported {
318        source: CandidateSource,
319        asset_ids: Vec<String>,
320        #[serde(default, skip_serializing_if = "Option::is_none")]
321        sender_id: Option<String>,
322    },
323    ManifestValidated {
324        accepted: bool,
325        reason: String,
326        #[serde(default, skip_serializing_if = "Option::is_none")]
327        sender_id: Option<String>,
328        #[serde(default, skip_serializing_if = "Option::is_none")]
329        publisher: Option<String>,
330        #[serde(default)]
331        asset_ids: Vec<String>,
332    },
333    SpecLinked {
334        mutation_id: MutationId,
335        spec_id: String,
336    },
337}
338
339#[derive(Clone, Debug, Serialize, Deserialize)]
340pub struct StoredEvolutionEvent {
341    pub seq: u64,
342    pub timestamp: String,
343    pub prev_hash: String,
344    pub record_hash: String,
345    pub event: EvolutionEvent,
346}
347
348#[derive(Clone, Debug, Default, Serialize, Deserialize)]
349pub struct EvolutionProjection {
350    pub genes: Vec<Gene>,
351    pub capsules: Vec<Capsule>,
352    pub reuse_counts: BTreeMap<GeneId, u64>,
353    pub attempt_counts: BTreeMap<GeneId, u64>,
354    pub last_updated_at: BTreeMap<GeneId, String>,
355    pub spec_ids_by_gene: BTreeMap<GeneId, BTreeSet<String>>,
356}
357
358#[derive(Clone, Debug)]
359pub struct SelectorInput {
360    pub signals: Vec<String>,
361    pub env: EnvFingerprint,
362    pub spec_id: Option<String>,
363    pub limit: usize,
364}
365
366#[derive(Clone, Debug)]
367pub struct GeneCandidate {
368    pub gene: Gene,
369    pub score: f32,
370    pub capsules: Vec<Capsule>,
371}
372
373pub trait Selector: Send + Sync {
374    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate>;
375}
376
377pub trait EvolutionStore: Send + Sync {
378    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError>;
379    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError>;
380    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError>;
381
382    fn scan_projection(
383        &self,
384    ) -> Result<(Vec<StoredEvolutionEvent>, EvolutionProjection), EvolutionError> {
385        let events = self.scan(1)?;
386        let projection = rebuild_projection_from_events(&events);
387        Ok((events, projection))
388    }
389}
390
391#[derive(Debug, Error)]
392pub enum EvolutionError {
393    #[error("I/O error: {0}")]
394    Io(String),
395    #[error("Serialization error: {0}")]
396    Serde(String),
397    #[error("Hash chain validation failed: {0}")]
398    HashChain(String),
399}
400
401pub struct JsonlEvolutionStore {
402    root_dir: PathBuf,
403    lock: Mutex<()>,
404}
405
406impl JsonlEvolutionStore {
407    pub fn new<P: Into<PathBuf>>(root_dir: P) -> Self {
408        Self {
409            root_dir: root_dir.into(),
410            lock: Mutex::new(()),
411        }
412    }
413
414    pub fn root_dir(&self) -> &Path {
415        &self.root_dir
416    }
417
418    fn ensure_layout(&self) -> Result<(), EvolutionError> {
419        fs::create_dir_all(&self.root_dir).map_err(io_err)?;
420        let lock_path = self.root_dir.join("LOCK");
421        if !lock_path.exists() {
422            File::create(lock_path).map_err(io_err)?;
423        }
424        let events_path = self.events_path();
425        if !events_path.exists() {
426            File::create(events_path).map_err(io_err)?;
427        }
428        Ok(())
429    }
430
431    fn events_path(&self) -> PathBuf {
432        self.root_dir.join("events.jsonl")
433    }
434
435    fn genes_path(&self) -> PathBuf {
436        self.root_dir.join("genes.json")
437    }
438
439    fn capsules_path(&self) -> PathBuf {
440        self.root_dir.join("capsules.json")
441    }
442
443    fn read_all_events(&self) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
444        self.ensure_layout()?;
445        let file = File::open(self.events_path()).map_err(io_err)?;
446        let reader = BufReader::new(file);
447        let mut events = Vec::new();
448        for line in reader.lines() {
449            let line = line.map_err(io_err)?;
450            if line.trim().is_empty() {
451                continue;
452            }
453            let event = serde_json::from_str::<StoredEvolutionEvent>(&line)
454                .map_err(|err| EvolutionError::Serde(err.to_string()))?;
455            events.push(event);
456        }
457        verify_hash_chain(&events)?;
458        Ok(events)
459    }
460
461    fn write_projection_files(
462        &self,
463        projection: &EvolutionProjection,
464    ) -> Result<(), EvolutionError> {
465        write_json_atomic(&self.genes_path(), &projection.genes)?;
466        write_json_atomic(&self.capsules_path(), &projection.capsules)?;
467        Ok(())
468    }
469
470    fn append_event_locked(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
471        let existing = self.read_all_events()?;
472        let next_seq = existing.last().map(|entry| entry.seq + 1).unwrap_or(1);
473        let prev_hash = existing
474            .last()
475            .map(|entry| entry.record_hash.clone())
476            .unwrap_or_default();
477        let timestamp = Utc::now().to_rfc3339();
478        let record_hash = hash_record(next_seq, &timestamp, &prev_hash, &event)?;
479        let stored = StoredEvolutionEvent {
480            seq: next_seq,
481            timestamp,
482            prev_hash,
483            record_hash,
484            event,
485        };
486        let mut file = OpenOptions::new()
487            .create(true)
488            .append(true)
489            .open(self.events_path())
490            .map_err(io_err)?;
491        let line =
492            serde_json::to_string(&stored).map_err(|err| EvolutionError::Serde(err.to_string()))?;
493        file.write_all(line.as_bytes()).map_err(io_err)?;
494        file.write_all(b"\n").map_err(io_err)?;
495        file.sync_data().map_err(io_err)?;
496
497        let events = self.read_all_events()?;
498        let projection = rebuild_projection_from_events(&events);
499        self.write_projection_files(&projection)?;
500        Ok(next_seq)
501    }
502}
503
504impl EvolutionStore for JsonlEvolutionStore {
505    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
506        let _guard = self
507            .lock
508            .lock()
509            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
510        self.append_event_locked(event)
511    }
512
513    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
514        let _guard = self
515            .lock
516            .lock()
517            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
518        Ok(self
519            .read_all_events()?
520            .into_iter()
521            .filter(|entry| entry.seq >= from_seq)
522            .collect())
523    }
524
525    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
526        let _guard = self
527            .lock
528            .lock()
529            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
530        let projection = rebuild_projection_from_events(&self.read_all_events()?);
531        self.write_projection_files(&projection)?;
532        Ok(projection)
533    }
534
535    fn scan_projection(
536        &self,
537    ) -> Result<(Vec<StoredEvolutionEvent>, EvolutionProjection), EvolutionError> {
538        let _guard = self
539            .lock
540            .lock()
541            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
542        let events = self.read_all_events()?;
543        let projection = rebuild_projection_from_events(&events);
544        self.write_projection_files(&projection)?;
545        Ok((events, projection))
546    }
547}
548
549pub struct ProjectionSelector {
550    projection: EvolutionProjection,
551    now: DateTime<Utc>,
552}
553
554impl ProjectionSelector {
555    pub fn new(projection: EvolutionProjection) -> Self {
556        Self {
557            projection,
558            now: Utc::now(),
559        }
560    }
561
562    pub fn with_now(projection: EvolutionProjection, now: DateTime<Utc>) -> Self {
563        Self { projection, now }
564    }
565}
566
567impl Selector for ProjectionSelector {
568    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
569        let requested_spec_id = input
570            .spec_id
571            .as_deref()
572            .map(str::trim)
573            .filter(|value| !value.is_empty());
574        let mut out = Vec::new();
575        for gene in &self.projection.genes {
576            if gene.state != AssetState::Promoted {
577                continue;
578            }
579            if let Some(spec_id) = requested_spec_id {
580                let matches_spec = self
581                    .projection
582                    .spec_ids_by_gene
583                    .get(&gene.id)
584                    .map(|values| {
585                        values
586                            .iter()
587                            .any(|value| value.eq_ignore_ascii_case(spec_id))
588                    })
589                    .unwrap_or(false);
590                if !matches_spec {
591                    continue;
592                }
593            }
594            let capsules = self
595                .projection
596                .capsules
597                .iter()
598                .filter(|capsule| {
599                    capsule.gene_id == gene.id && capsule.state == AssetState::Promoted
600                })
601                .cloned()
602                .collect::<Vec<_>>();
603            if capsules.is_empty() {
604                continue;
605            }
606            let mut capsules = capsules;
607            capsules.sort_by(|left, right| {
608                environment_match_factor(&input.env, &right.env)
609                    .partial_cmp(&environment_match_factor(&input.env, &left.env))
610                    .unwrap_or(std::cmp::Ordering::Equal)
611                    .then_with(|| {
612                        right
613                            .confidence
614                            .partial_cmp(&left.confidence)
615                            .unwrap_or(std::cmp::Ordering::Equal)
616                    })
617                    .then_with(|| left.id.cmp(&right.id))
618            });
619            let env_match_factor = capsules
620                .first()
621                .map(|capsule| environment_match_factor(&input.env, &capsule.env))
622                .unwrap_or(0.0);
623
624            let successful_capsules = capsules.len() as f64;
625            let attempts = self
626                .projection
627                .attempt_counts
628                .get(&gene.id)
629                .copied()
630                .unwrap_or(capsules.len() as u64) as f64;
631            let success_rate = if attempts == 0.0 {
632                0.0
633            } else {
634                successful_capsules / attempts
635            };
636            let successful_reuses = self
637                .projection
638                .reuse_counts
639                .get(&gene.id)
640                .copied()
641                .unwrap_or(0) as f64;
642            let reuse_count_factor = 1.0 + (1.0 + successful_reuses).ln();
643            let signal_overlap = normalized_signal_overlap(&gene.signals, &input.signals);
644            let age_secs = self
645                .projection
646                .last_updated_at
647                .get(&gene.id)
648                .and_then(|value| seconds_since_timestamp(value, self.now));
649            let peak_confidence = capsules
650                .iter()
651                .map(|capsule| capsule.confidence)
652                .fold(0.0_f32, f32::max) as f64;
653            let freshness_confidence = capsules
654                .iter()
655                .map(|capsule| decayed_replay_confidence(capsule.confidence, age_secs))
656                .fold(0.0_f32, f32::max) as f64;
657            if freshness_confidence < MIN_REPLAY_CONFIDENCE as f64 {
658                continue;
659            }
660            let freshness_factor = if peak_confidence <= 0.0 {
661                0.0
662            } else {
663                (freshness_confidence / peak_confidence).clamp(0.0, 1.0)
664            };
665            let score = (success_rate
666                * reuse_count_factor
667                * env_match_factor
668                * freshness_factor
669                * signal_overlap) as f32;
670            if score < 0.35 {
671                continue;
672            }
673            out.push(GeneCandidate {
674                gene: gene.clone(),
675                score,
676                capsules,
677            });
678        }
679
680        out.sort_by(|left, right| {
681            right
682                .score
683                .partial_cmp(&left.score)
684                .unwrap_or(std::cmp::Ordering::Equal)
685                .then_with(|| left.gene.id.cmp(&right.gene.id))
686        });
687        out.truncate(input.limit.max(1));
688        out
689    }
690}
691
692pub struct StoreBackedSelector {
693    store: std::sync::Arc<dyn EvolutionStore>,
694}
695
696impl StoreBackedSelector {
697    pub fn new(store: std::sync::Arc<dyn EvolutionStore>) -> Self {
698        Self { store }
699    }
700}
701
702impl Selector for StoreBackedSelector {
703    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
704        match self.store.scan_projection() {
705            Ok((_, projection)) => ProjectionSelector::new(projection).select(input),
706            Err(_) => Vec::new(),
707        }
708    }
709}
710
711pub fn rebuild_projection_from_events(events: &[StoredEvolutionEvent]) -> EvolutionProjection {
712    let mut genes = BTreeMap::<GeneId, Gene>::new();
713    let mut capsules = BTreeMap::<CapsuleId, Capsule>::new();
714    let mut reuse_counts = BTreeMap::<GeneId, u64>::new();
715    let mut attempt_counts = BTreeMap::<GeneId, u64>::new();
716    let mut last_updated_at = BTreeMap::<GeneId, String>::new();
717    let mut spec_ids_by_gene = BTreeMap::<GeneId, BTreeSet<String>>::new();
718    let mut mutation_to_gene = HashMap::<MutationId, GeneId>::new();
719    let mut mutation_spec_ids = HashMap::<MutationId, String>::new();
720
721    for stored in events {
722        match &stored.event {
723            EvolutionEvent::MutationDeclared { mutation } => {
724                if let Some(spec_id) = mutation
725                    .intent
726                    .spec_id
727                    .as_ref()
728                    .map(|value| value.trim())
729                    .filter(|value| !value.is_empty())
730                {
731                    mutation_spec_ids.insert(mutation.intent.id.clone(), spec_id.to_string());
732                    if let Some(gene_id) = mutation_to_gene.get(&mutation.intent.id) {
733                        spec_ids_by_gene
734                            .entry(gene_id.clone())
735                            .or_default()
736                            .insert(spec_id.to_string());
737                    }
738                }
739            }
740            EvolutionEvent::SpecLinked {
741                mutation_id,
742                spec_id,
743            } => {
744                let spec_id = spec_id.trim();
745                if !spec_id.is_empty() {
746                    mutation_spec_ids.insert(mutation_id.clone(), spec_id.to_string());
747                    if let Some(gene_id) = mutation_to_gene.get(mutation_id) {
748                        spec_ids_by_gene
749                            .entry(gene_id.clone())
750                            .or_default()
751                            .insert(spec_id.to_string());
752                    }
753                }
754            }
755            EvolutionEvent::GeneProjected { gene } => {
756                genes.insert(gene.id.clone(), gene.clone());
757                last_updated_at.insert(gene.id.clone(), stored.timestamp.clone());
758            }
759            EvolutionEvent::GenePromoted { gene_id } => {
760                if let Some(gene) = genes.get_mut(gene_id) {
761                    gene.state = AssetState::Promoted;
762                }
763                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
764            }
765            EvolutionEvent::GeneRevoked { gene_id, .. } => {
766                if let Some(gene) = genes.get_mut(gene_id) {
767                    gene.state = AssetState::Revoked;
768                }
769                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
770            }
771            EvolutionEvent::GeneArchived { gene_id } => {
772                if let Some(gene) = genes.get_mut(gene_id) {
773                    gene.state = AssetState::Archived;
774                }
775                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
776            }
777            EvolutionEvent::PromotionEvaluated { gene_id, state, .. } => {
778                if let Some(gene) = genes.get_mut(gene_id) {
779                    gene.state = state.clone();
780                }
781                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
782            }
783            EvolutionEvent::CapsuleCommitted { capsule } => {
784                mutation_to_gene.insert(capsule.mutation_id.clone(), capsule.gene_id.clone());
785                capsules.insert(capsule.id.clone(), capsule.clone());
786                *attempt_counts.entry(capsule.gene_id.clone()).or_insert(0) += 1;
787                if let Some(spec_id) = mutation_spec_ids.get(&capsule.mutation_id) {
788                    spec_ids_by_gene
789                        .entry(capsule.gene_id.clone())
790                        .or_default()
791                        .insert(spec_id.clone());
792                }
793                last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
794            }
795            EvolutionEvent::CapsuleQuarantined { capsule_id } => {
796                if let Some(capsule) = capsules.get_mut(capsule_id) {
797                    capsule.state = AssetState::Quarantined;
798                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
799                }
800            }
801            EvolutionEvent::CapsuleReleased { capsule_id, state } => {
802                if let Some(capsule) = capsules.get_mut(capsule_id) {
803                    capsule.state = state.clone();
804                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
805                }
806            }
807            EvolutionEvent::CapsuleReused { gene_id, .. } => {
808                *reuse_counts.entry(gene_id.clone()).or_insert(0) += 1;
809                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
810            }
811            EvolutionEvent::ValidationFailed {
812                mutation_id,
813                gene_id,
814                ..
815            } => {
816                let id = gene_id
817                    .clone()
818                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
819                if let Some(gene_id) = id {
820                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
821                    last_updated_at.insert(gene_id, stored.timestamp.clone());
822                }
823            }
824            EvolutionEvent::ValidationPassed {
825                mutation_id,
826                gene_id,
827                ..
828            } => {
829                let id = gene_id
830                    .clone()
831                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
832                if let Some(gene_id) = id {
833                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
834                    last_updated_at.insert(gene_id, stored.timestamp.clone());
835                }
836            }
837            _ => {}
838        }
839    }
840
841    EvolutionProjection {
842        genes: genes.into_values().collect(),
843        capsules: capsules.into_values().collect(),
844        reuse_counts,
845        attempt_counts,
846        last_updated_at,
847        spec_ids_by_gene,
848    }
849}
850
851pub fn default_store_root() -> PathBuf {
852    PathBuf::from(".oris").join("evolution")
853}
854
855pub fn hash_string(input: &str) -> String {
856    let mut hasher = Sha256::new();
857    hasher.update(input.as_bytes());
858    hex::encode(hasher.finalize())
859}
860
861pub fn stable_hash_json<T: Serialize>(value: &T) -> Result<String, EvolutionError> {
862    let bytes = serde_json::to_vec(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
863    let mut hasher = Sha256::new();
864    hasher.update(bytes);
865    Ok(hex::encode(hasher.finalize()))
866}
867
868pub fn compute_artifact_hash(payload: &str) -> String {
869    hash_string(payload)
870}
871
872pub fn next_id(prefix: &str) -> String {
873    let nanos = SystemTime::now()
874        .duration_since(UNIX_EPOCH)
875        .unwrap_or_default()
876        .as_nanos();
877    format!("{prefix}-{nanos:x}")
878}
879
880pub fn decayed_replay_confidence(confidence: f32, age_secs: Option<u64>) -> f32 {
881    if confidence <= 0.0 {
882        return 0.0;
883    }
884    let age_hours = age_secs.unwrap_or(0) as f32 / 3600.0;
885    let decay = (-REPLAY_CONFIDENCE_DECAY_RATE_PER_HOUR * age_hours).exp();
886    (confidence * decay).clamp(0.0, 1.0)
887}
888
889fn normalized_signal_overlap(gene_signals: &[String], input_signals: &[String]) -> f64 {
890    let gene = canonical_signal_phrases(gene_signals);
891    let input = canonical_signal_phrases(input_signals);
892    if input.is_empty() || gene.is_empty() {
893        return 0.0;
894    }
895    let matched = input
896        .iter()
897        .map(|signal| best_signal_match(&gene, signal))
898        .sum::<f64>();
899    matched / input.len() as f64
900}
901
902#[derive(Clone, Debug, PartialEq, Eq)]
903struct CanonicalSignal {
904    phrase: String,
905    tokens: BTreeSet<String>,
906}
907
908fn canonical_signal_phrases(signals: &[String]) -> Vec<CanonicalSignal> {
909    signals
910        .iter()
911        .filter_map(|signal| canonical_signal_phrase(signal))
912        .collect()
913}
914
915fn canonical_signal_phrase(input: &str) -> Option<CanonicalSignal> {
916    let tokens = input
917        .split(|ch: char| !ch.is_ascii_alphanumeric())
918        .filter_map(canonical_signal_token)
919        .collect::<BTreeSet<_>>();
920    if tokens.is_empty() {
921        return None;
922    }
923    let phrase = tokens.iter().cloned().collect::<Vec<_>>().join(" ");
924    Some(CanonicalSignal { phrase, tokens })
925}
926
927fn canonical_signal_token(token: &str) -> Option<String> {
928    let normalized = token.trim().to_ascii_lowercase();
929    if normalized.len() < 3 {
930        return None;
931    }
932    if normalized.chars().all(|ch| ch.is_ascii_digit()) {
933        return None;
934    }
935    match normalized.as_str() {
936        "absent" | "unavailable" | "vanished" => Some("missing".into()),
937        "file" | "files" | "error" | "errors" => None,
938        _ => Some(normalized),
939    }
940}
941
942fn best_signal_match(gene_signals: &[CanonicalSignal], input: &CanonicalSignal) -> f64 {
943    gene_signals
944        .iter()
945        .map(|candidate| deterministic_phrase_match(candidate, input))
946        .fold(0.0, f64::max)
947}
948
949fn deterministic_phrase_match(candidate: &CanonicalSignal, input: &CanonicalSignal) -> f64 {
950    if candidate.phrase == input.phrase {
951        return 1.0;
952    }
953    if candidate.tokens.len() < 2 || input.tokens.len() < 2 {
954        return 0.0;
955    }
956    let shared = candidate.tokens.intersection(&input.tokens).count();
957    if shared < 2 {
958        return 0.0;
959    }
960    let overlap = shared as f64 / candidate.tokens.len().min(input.tokens.len()) as f64;
961    if overlap >= 0.67 {
962        overlap
963    } else {
964        0.0
965    }
966}
967fn seconds_since_timestamp(timestamp: &str, now: DateTime<Utc>) -> Option<u64> {
968    let parsed = DateTime::parse_from_rfc3339(timestamp)
969        .ok()?
970        .with_timezone(&Utc);
971    let elapsed = now.signed_duration_since(parsed);
972    if elapsed < Duration::zero() {
973        Some(0)
974    } else {
975        u64::try_from(elapsed.num_seconds()).ok()
976    }
977}
978fn environment_match_factor(input: &EnvFingerprint, candidate: &EnvFingerprint) -> f64 {
979    let fields = [
980        input
981            .rustc_version
982            .eq_ignore_ascii_case(&candidate.rustc_version),
983        input
984            .cargo_lock_hash
985            .eq_ignore_ascii_case(&candidate.cargo_lock_hash),
986        input
987            .target_triple
988            .eq_ignore_ascii_case(&candidate.target_triple),
989        input.os.eq_ignore_ascii_case(&candidate.os),
990    ];
991    let matched_fields = fields.into_iter().filter(|matched| *matched).count() as f64;
992    0.5 + ((matched_fields / 4.0) * 0.5)
993}
994
995fn hash_record(
996    seq: u64,
997    timestamp: &str,
998    prev_hash: &str,
999    event: &EvolutionEvent,
1000) -> Result<String, EvolutionError> {
1001    stable_hash_json(&(seq, timestamp, prev_hash, event))
1002}
1003
1004fn verify_hash_chain(events: &[StoredEvolutionEvent]) -> Result<(), EvolutionError> {
1005    let mut previous_hash = String::new();
1006    let mut expected_seq = 1u64;
1007    for event in events {
1008        if event.seq != expected_seq {
1009            return Err(EvolutionError::HashChain(format!(
1010                "expected seq {}, found {}",
1011                expected_seq, event.seq
1012            )));
1013        }
1014        if event.prev_hash != previous_hash {
1015            return Err(EvolutionError::HashChain(format!(
1016                "event {} prev_hash mismatch",
1017                event.seq
1018            )));
1019        }
1020        let actual_hash = hash_record(event.seq, &event.timestamp, &event.prev_hash, &event.event)?;
1021        if actual_hash != event.record_hash {
1022            return Err(EvolutionError::HashChain(format!(
1023                "event {} record_hash mismatch",
1024                event.seq
1025            )));
1026        }
1027        previous_hash = event.record_hash.clone();
1028        expected_seq += 1;
1029    }
1030    Ok(())
1031}
1032
1033fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), EvolutionError> {
1034    let tmp_path = path.with_extension("tmp");
1035    let bytes =
1036        serde_json::to_vec_pretty(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
1037    fs::write(&tmp_path, bytes).map_err(io_err)?;
1038    fs::rename(&tmp_path, path).map_err(io_err)?;
1039    Ok(())
1040}
1041
1042fn io_err(err: std::io::Error) -> EvolutionError {
1043    EvolutionError::Io(err.to_string())
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049
1050    fn temp_root(name: &str) -> PathBuf {
1051        std::env::temp_dir().join(format!("oris-evolution-{name}-{}", next_id("t")))
1052    }
1053
1054    fn sample_mutation() -> PreparedMutation {
1055        PreparedMutation {
1056            intent: MutationIntent {
1057                id: "mutation-1".into(),
1058                intent: "tighten borrow scope".into(),
1059                target: MutationTarget::Paths {
1060                    allow: vec!["crates/oris-kernel".into()],
1061                },
1062                expected_effect: "cargo check passes".into(),
1063                risk: RiskLevel::Low,
1064                signals: vec!["rust borrow error".into()],
1065                spec_id: None,
1066            },
1067            artifact: MutationArtifact {
1068                encoding: ArtifactEncoding::UnifiedDiff,
1069                payload: "diff --git a/foo b/foo".into(),
1070                base_revision: Some("HEAD".into()),
1071                content_hash: compute_artifact_hash("diff --git a/foo b/foo"),
1072            },
1073        }
1074    }
1075
1076    #[test]
1077    fn evomap_asset_state_mapping_archived_is_rejected() {
1078        assert_eq!(
1079            asset_state_to_evomap_compat(&AssetState::Archived),
1080            "rejected"
1081        );
1082        assert_eq!(
1083            asset_state_to_evomap_compat(&AssetState::Quarantined),
1084            "quarantined"
1085        );
1086        assert_eq!(
1087            asset_state_to_evomap_compat(&AssetState::ShadowValidated),
1088            "candidate"
1089        );
1090    }
1091
1092    #[test]
1093    fn append_event_assigns_monotonic_seq() {
1094        let root = temp_root("seq");
1095        let store = JsonlEvolutionStore::new(root);
1096        let first = store
1097            .append_event(EvolutionEvent::MutationDeclared {
1098                mutation: sample_mutation(),
1099            })
1100            .unwrap();
1101        let second = store
1102            .append_event(EvolutionEvent::MutationRejected {
1103                mutation_id: "mutation-1".into(),
1104                reason: "no-op".into(),
1105                reason_code: None,
1106                recovery_hint: None,
1107                fail_closed: true,
1108            })
1109            .unwrap();
1110        assert_eq!(first, 1);
1111        assert_eq!(second, 2);
1112    }
1113
1114    #[test]
1115    fn tampered_hash_chain_is_rejected() {
1116        let root = temp_root("tamper");
1117        let store = JsonlEvolutionStore::new(&root);
1118        store
1119            .append_event(EvolutionEvent::MutationDeclared {
1120                mutation: sample_mutation(),
1121            })
1122            .unwrap();
1123        let path = root.join("events.jsonl");
1124        let contents = fs::read_to_string(&path).unwrap();
1125        let mutated = contents.replace("tighten borrow scope", "tampered");
1126        fs::write(&path, mutated).unwrap();
1127        let result = store.scan(1);
1128        assert!(matches!(result, Err(EvolutionError::HashChain(_))));
1129    }
1130
1131    #[test]
1132    fn rebuild_projection_after_cache_deletion() {
1133        let root = temp_root("projection");
1134        let store = JsonlEvolutionStore::new(&root);
1135        let gene = Gene {
1136            id: "gene-1".into(),
1137            signals: vec!["rust borrow error".into()],
1138            strategy: vec!["crates".into()],
1139            validation: vec!["oris-default".into()],
1140            state: AssetState::Promoted,
1141        };
1142        let capsule = Capsule {
1143            id: "capsule-1".into(),
1144            gene_id: gene.id.clone(),
1145            mutation_id: "mutation-1".into(),
1146            run_id: "run-1".into(),
1147            diff_hash: "abc".into(),
1148            confidence: 0.7,
1149            env: EnvFingerprint {
1150                rustc_version: "rustc 1.80".into(),
1151                cargo_lock_hash: "lock".into(),
1152                target_triple: "x86_64-unknown-linux-gnu".into(),
1153                os: "linux".into(),
1154            },
1155            outcome: Outcome {
1156                success: true,
1157                validation_profile: "oris-default".into(),
1158                validation_duration_ms: 100,
1159                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1160                validator_hash: "vh".into(),
1161                lines_changed: 1,
1162                replay_verified: false,
1163            },
1164            state: AssetState::Promoted,
1165        };
1166        store
1167            .append_event(EvolutionEvent::GeneProjected { gene })
1168            .unwrap();
1169        store
1170            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1171            .unwrap();
1172        fs::remove_file(root.join("genes.json")).unwrap();
1173        fs::remove_file(root.join("capsules.json")).unwrap();
1174        let projection = store.rebuild_projection().unwrap();
1175        assert_eq!(projection.genes.len(), 1);
1176        assert_eq!(projection.capsules.len(), 1);
1177    }
1178
1179    #[test]
1180    fn rebuild_projection_tracks_spec_ids_for_genes() {
1181        let root = temp_root("projection-spec");
1182        let store = JsonlEvolutionStore::new(&root);
1183        let mut mutation = sample_mutation();
1184        mutation.intent.id = "mutation-spec".into();
1185        mutation.intent.spec_id = Some("spec-repair-1".into());
1186        let gene = Gene {
1187            id: "gene-spec".into(),
1188            signals: vec!["rust borrow error".into()],
1189            strategy: vec!["crates".into()],
1190            validation: vec!["oris-default".into()],
1191            state: AssetState::Promoted,
1192        };
1193        let capsule = Capsule {
1194            id: "capsule-spec".into(),
1195            gene_id: gene.id.clone(),
1196            mutation_id: mutation.intent.id.clone(),
1197            run_id: "run-spec".into(),
1198            diff_hash: "abc".into(),
1199            confidence: 0.7,
1200            env: EnvFingerprint {
1201                rustc_version: "rustc 1.80".into(),
1202                cargo_lock_hash: "lock".into(),
1203                target_triple: "x86_64-unknown-linux-gnu".into(),
1204                os: "linux".into(),
1205            },
1206            outcome: Outcome {
1207                success: true,
1208                validation_profile: "oris-default".into(),
1209                validation_duration_ms: 100,
1210                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1211                validator_hash: "vh".into(),
1212                lines_changed: 1,
1213                replay_verified: false,
1214            },
1215            state: AssetState::Promoted,
1216        };
1217        store
1218            .append_event(EvolutionEvent::MutationDeclared { mutation })
1219            .unwrap();
1220        store
1221            .append_event(EvolutionEvent::GeneProjected { gene })
1222            .unwrap();
1223        store
1224            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1225            .unwrap();
1226
1227        let projection = store.rebuild_projection().unwrap();
1228        let spec_ids = projection.spec_ids_by_gene.get("gene-spec").unwrap();
1229        assert!(spec_ids.contains("spec-repair-1"));
1230    }
1231
1232    #[test]
1233    fn rebuild_projection_tracks_spec_ids_from_spec_linked_events() {
1234        let root = temp_root("projection-spec-linked");
1235        let store = JsonlEvolutionStore::new(&root);
1236        let mut mutation = sample_mutation();
1237        mutation.intent.id = "mutation-spec-linked".into();
1238        mutation.intent.spec_id = None;
1239        let gene = Gene {
1240            id: "gene-spec-linked".into(),
1241            signals: vec!["rust borrow error".into()],
1242            strategy: vec!["crates".into()],
1243            validation: vec!["oris-default".into()],
1244            state: AssetState::Promoted,
1245        };
1246        let capsule = Capsule {
1247            id: "capsule-spec-linked".into(),
1248            gene_id: gene.id.clone(),
1249            mutation_id: mutation.intent.id.clone(),
1250            run_id: "run-spec-linked".into(),
1251            diff_hash: "abc".into(),
1252            confidence: 0.7,
1253            env: EnvFingerprint {
1254                rustc_version: "rustc 1.80".into(),
1255                cargo_lock_hash: "lock".into(),
1256                target_triple: "x86_64-unknown-linux-gnu".into(),
1257                os: "linux".into(),
1258            },
1259            outcome: Outcome {
1260                success: true,
1261                validation_profile: "oris-default".into(),
1262                validation_duration_ms: 100,
1263                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1264                validator_hash: "vh".into(),
1265                lines_changed: 1,
1266                replay_verified: false,
1267            },
1268            state: AssetState::Promoted,
1269        };
1270        store
1271            .append_event(EvolutionEvent::MutationDeclared { mutation })
1272            .unwrap();
1273        store
1274            .append_event(EvolutionEvent::GeneProjected { gene })
1275            .unwrap();
1276        store
1277            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1278            .unwrap();
1279        store
1280            .append_event(EvolutionEvent::SpecLinked {
1281                mutation_id: "mutation-spec-linked".into(),
1282                spec_id: "spec-repair-linked".into(),
1283            })
1284            .unwrap();
1285
1286        let projection = store.rebuild_projection().unwrap();
1287        let spec_ids = projection.spec_ids_by_gene.get("gene-spec-linked").unwrap();
1288        assert!(spec_ids.contains("spec-repair-linked"));
1289    }
1290
1291    #[test]
1292    fn rebuild_projection_tracks_inline_spec_ids_even_when_declared_late() {
1293        let root = temp_root("projection-spec-inline-late");
1294        let store = JsonlEvolutionStore::new(&root);
1295        let mut mutation = sample_mutation();
1296        mutation.intent.id = "mutation-inline-late".into();
1297        mutation.intent.spec_id = Some("spec-inline-late".into());
1298        let gene = Gene {
1299            id: "gene-inline-late".into(),
1300            signals: vec!["rust borrow error".into()],
1301            strategy: vec!["crates".into()],
1302            validation: vec!["oris-default".into()],
1303            state: AssetState::Promoted,
1304        };
1305        let capsule = Capsule {
1306            id: "capsule-inline-late".into(),
1307            gene_id: gene.id.clone(),
1308            mutation_id: mutation.intent.id.clone(),
1309            run_id: "run-inline-late".into(),
1310            diff_hash: "abc".into(),
1311            confidence: 0.7,
1312            env: EnvFingerprint {
1313                rustc_version: "rustc 1.80".into(),
1314                cargo_lock_hash: "lock".into(),
1315                target_triple: "x86_64-unknown-linux-gnu".into(),
1316                os: "linux".into(),
1317            },
1318            outcome: Outcome {
1319                success: true,
1320                validation_profile: "oris-default".into(),
1321                validation_duration_ms: 100,
1322                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1323                validator_hash: "vh".into(),
1324                lines_changed: 1,
1325                replay_verified: false,
1326            },
1327            state: AssetState::Promoted,
1328        };
1329        store
1330            .append_event(EvolutionEvent::GeneProjected { gene })
1331            .unwrap();
1332        store
1333            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1334            .unwrap();
1335        store
1336            .append_event(EvolutionEvent::MutationDeclared { mutation })
1337            .unwrap();
1338
1339        let projection = store.rebuild_projection().unwrap();
1340        let spec_ids = projection.spec_ids_by_gene.get("gene-inline-late").unwrap();
1341        assert!(spec_ids.contains("spec-inline-late"));
1342    }
1343
1344    #[test]
1345    fn scan_projection_recreates_projection_files() {
1346        let root = temp_root("scan-projection");
1347        let store = JsonlEvolutionStore::new(&root);
1348        let mutation = sample_mutation();
1349        let gene = Gene {
1350            id: "gene-scan".into(),
1351            signals: vec!["rust borrow error".into()],
1352            strategy: vec!["crates".into()],
1353            validation: vec!["oris-default".into()],
1354            state: AssetState::Promoted,
1355        };
1356        let capsule = Capsule {
1357            id: "capsule-scan".into(),
1358            gene_id: gene.id.clone(),
1359            mutation_id: mutation.intent.id.clone(),
1360            run_id: "run-scan".into(),
1361            diff_hash: "abc".into(),
1362            confidence: 0.7,
1363            env: EnvFingerprint {
1364                rustc_version: "rustc 1.80".into(),
1365                cargo_lock_hash: "lock".into(),
1366                target_triple: "x86_64-unknown-linux-gnu".into(),
1367                os: "linux".into(),
1368            },
1369            outcome: Outcome {
1370                success: true,
1371                validation_profile: "oris-default".into(),
1372                validation_duration_ms: 100,
1373                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1374                validator_hash: "vh".into(),
1375                lines_changed: 1,
1376                replay_verified: false,
1377            },
1378            state: AssetState::Promoted,
1379        };
1380        store
1381            .append_event(EvolutionEvent::MutationDeclared { mutation })
1382            .unwrap();
1383        store
1384            .append_event(EvolutionEvent::GeneProjected { gene })
1385            .unwrap();
1386        store
1387            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1388            .unwrap();
1389        fs::remove_file(root.join("genes.json")).unwrap();
1390        fs::remove_file(root.join("capsules.json")).unwrap();
1391
1392        let (events, projection) = store.scan_projection().unwrap();
1393
1394        assert_eq!(events.len(), 3);
1395        assert_eq!(projection.genes.len(), 1);
1396        assert_eq!(projection.capsules.len(), 1);
1397        assert!(root.join("genes.json").exists());
1398        assert!(root.join("capsules.json").exists());
1399    }
1400
1401    #[test]
1402    fn default_scan_projection_uses_single_event_snapshot() {
1403        struct InconsistentSnapshotStore {
1404            scanned_events: Vec<StoredEvolutionEvent>,
1405            rebuilt_projection: EvolutionProjection,
1406        }
1407
1408        impl EvolutionStore for InconsistentSnapshotStore {
1409            fn append_event(&self, _event: EvolutionEvent) -> Result<u64, EvolutionError> {
1410                Err(EvolutionError::Io("unused in test".into()))
1411            }
1412
1413            fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
1414                Ok(self
1415                    .scanned_events
1416                    .iter()
1417                    .filter(|stored| stored.seq >= from_seq)
1418                    .cloned()
1419                    .collect())
1420            }
1421
1422            fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
1423                Ok(self.rebuilt_projection.clone())
1424            }
1425        }
1426
1427        let scanned_gene = Gene {
1428            id: "gene-scanned".into(),
1429            signals: vec!["signal".into()],
1430            strategy: vec!["a".into()],
1431            validation: vec!["oris-default".into()],
1432            state: AssetState::Promoted,
1433        };
1434        let store = InconsistentSnapshotStore {
1435            scanned_events: vec![StoredEvolutionEvent {
1436                seq: 1,
1437                timestamp: "2026-03-04T00:00:00Z".into(),
1438                prev_hash: String::new(),
1439                record_hash: "hash".into(),
1440                event: EvolutionEvent::GeneProjected {
1441                    gene: scanned_gene.clone(),
1442                },
1443            }],
1444            rebuilt_projection: EvolutionProjection {
1445                genes: vec![Gene {
1446                    id: "gene-rebuilt".into(),
1447                    signals: vec!["other".into()],
1448                    strategy: vec!["b".into()],
1449                    validation: vec!["oris-default".into()],
1450                    state: AssetState::Promoted,
1451                }],
1452                ..Default::default()
1453            },
1454        };
1455
1456        let (events, projection) = store.scan_projection().unwrap();
1457
1458        assert_eq!(events.len(), 1);
1459        assert_eq!(projection.genes.len(), 1);
1460        assert_eq!(projection.genes[0].id, scanned_gene.id);
1461    }
1462
1463    #[test]
1464    fn store_backed_selector_uses_scan_projection_contract() {
1465        struct InconsistentSnapshotStore {
1466            scanned_events: Vec<StoredEvolutionEvent>,
1467            rebuilt_projection: EvolutionProjection,
1468        }
1469
1470        impl EvolutionStore for InconsistentSnapshotStore {
1471            fn append_event(&self, _event: EvolutionEvent) -> Result<u64, EvolutionError> {
1472                Err(EvolutionError::Io("unused in test".into()))
1473            }
1474
1475            fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
1476                Ok(self
1477                    .scanned_events
1478                    .iter()
1479                    .filter(|stored| stored.seq >= from_seq)
1480                    .cloned()
1481                    .collect())
1482            }
1483
1484            fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
1485                Ok(self.rebuilt_projection.clone())
1486            }
1487        }
1488
1489        let scanned_gene = Gene {
1490            id: "gene-scanned".into(),
1491            signals: vec!["signal".into()],
1492            strategy: vec!["a".into()],
1493            validation: vec!["oris-default".into()],
1494            state: AssetState::Promoted,
1495        };
1496        let scanned_capsule = Capsule {
1497            id: "capsule-scanned".into(),
1498            gene_id: scanned_gene.id.clone(),
1499            mutation_id: "mutation-scanned".into(),
1500            run_id: "run-scanned".into(),
1501            diff_hash: "hash".into(),
1502            confidence: 0.8,
1503            env: EnvFingerprint {
1504                rustc_version: "rustc 1.80".into(),
1505                cargo_lock_hash: "lock".into(),
1506                target_triple: "x86_64-unknown-linux-gnu".into(),
1507                os: "linux".into(),
1508            },
1509            outcome: Outcome {
1510                success: true,
1511                validation_profile: "oris-default".into(),
1512                validation_duration_ms: 100,
1513                changed_files: vec!["file.rs".into()],
1514                validator_hash: "validator".into(),
1515                lines_changed: 1,
1516                replay_verified: false,
1517            },
1518            state: AssetState::Promoted,
1519        };
1520        let fresh_ts = Utc::now().to_rfc3339();
1521        let store = std::sync::Arc::new(InconsistentSnapshotStore {
1522            scanned_events: vec![
1523                StoredEvolutionEvent {
1524                    seq: 1,
1525                    timestamp: fresh_ts.clone(),
1526                    prev_hash: String::new(),
1527                    record_hash: "hash-1".into(),
1528                    event: EvolutionEvent::GeneProjected {
1529                        gene: scanned_gene.clone(),
1530                    },
1531                },
1532                StoredEvolutionEvent {
1533                    seq: 2,
1534                    timestamp: fresh_ts,
1535                    prev_hash: "hash-1".into(),
1536                    record_hash: "hash-2".into(),
1537                    event: EvolutionEvent::CapsuleCommitted {
1538                        capsule: scanned_capsule.clone(),
1539                    },
1540                },
1541            ],
1542            rebuilt_projection: EvolutionProjection {
1543                genes: vec![Gene {
1544                    id: "gene-rebuilt".into(),
1545                    signals: vec!["other".into()],
1546                    strategy: vec!["b".into()],
1547                    validation: vec!["oris-default".into()],
1548                    state: AssetState::Promoted,
1549                }],
1550                ..Default::default()
1551            },
1552        });
1553        let selector = StoreBackedSelector::new(store);
1554        let input = SelectorInput {
1555            signals: vec!["signal".into()],
1556            env: scanned_capsule.env.clone(),
1557            spec_id: None,
1558            limit: 1,
1559        };
1560
1561        let candidates = selector.select(&input);
1562
1563        assert_eq!(candidates.len(), 1);
1564        assert_eq!(candidates[0].gene.id, scanned_gene.id);
1565        assert_eq!(candidates[0].capsules[0].id, scanned_capsule.id);
1566    }
1567
1568    #[test]
1569    fn selector_orders_results_stably() {
1570        let projection = EvolutionProjection {
1571            genes: vec![
1572                Gene {
1573                    id: "gene-a".into(),
1574                    signals: vec!["signal".into()],
1575                    strategy: vec!["a".into()],
1576                    validation: vec!["oris-default".into()],
1577                    state: AssetState::Promoted,
1578                },
1579                Gene {
1580                    id: "gene-b".into(),
1581                    signals: vec!["signal".into()],
1582                    strategy: vec!["b".into()],
1583                    validation: vec!["oris-default".into()],
1584                    state: AssetState::Promoted,
1585                },
1586            ],
1587            capsules: vec![
1588                Capsule {
1589                    id: "capsule-a".into(),
1590                    gene_id: "gene-a".into(),
1591                    mutation_id: "m1".into(),
1592                    run_id: "r1".into(),
1593                    diff_hash: "1".into(),
1594                    confidence: 0.7,
1595                    env: EnvFingerprint {
1596                        rustc_version: "rustc".into(),
1597                        cargo_lock_hash: "lock".into(),
1598                        target_triple: "x86_64-unknown-linux-gnu".into(),
1599                        os: "linux".into(),
1600                    },
1601                    outcome: Outcome {
1602                        success: true,
1603                        validation_profile: "oris-default".into(),
1604                        validation_duration_ms: 1,
1605                        changed_files: vec!["crates/oris-kernel".into()],
1606                        validator_hash: "v".into(),
1607                        lines_changed: 1,
1608                        replay_verified: false,
1609                    },
1610                    state: AssetState::Promoted,
1611                },
1612                Capsule {
1613                    id: "capsule-b".into(),
1614                    gene_id: "gene-b".into(),
1615                    mutation_id: "m2".into(),
1616                    run_id: "r2".into(),
1617                    diff_hash: "2".into(),
1618                    confidence: 0.7,
1619                    env: EnvFingerprint {
1620                        rustc_version: "rustc".into(),
1621                        cargo_lock_hash: "lock".into(),
1622                        target_triple: "x86_64-unknown-linux-gnu".into(),
1623                        os: "linux".into(),
1624                    },
1625                    outcome: Outcome {
1626                        success: true,
1627                        validation_profile: "oris-default".into(),
1628                        validation_duration_ms: 1,
1629                        changed_files: vec!["crates/oris-kernel".into()],
1630                        validator_hash: "v".into(),
1631                        lines_changed: 1,
1632                        replay_verified: false,
1633                    },
1634                    state: AssetState::Promoted,
1635                },
1636            ],
1637            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1638            attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1639            last_updated_at: BTreeMap::from([
1640                ("gene-a".into(), Utc::now().to_rfc3339()),
1641                ("gene-b".into(), Utc::now().to_rfc3339()),
1642            ]),
1643            spec_ids_by_gene: BTreeMap::new(),
1644        };
1645        let selector = ProjectionSelector::new(projection);
1646        let input = SelectorInput {
1647            signals: vec!["signal".into()],
1648            env: EnvFingerprint {
1649                rustc_version: "rustc".into(),
1650                cargo_lock_hash: "lock".into(),
1651                target_triple: "x86_64-unknown-linux-gnu".into(),
1652                os: "linux".into(),
1653            },
1654            spec_id: None,
1655            limit: 2,
1656        };
1657        let first = selector.select(&input);
1658        let second = selector.select(&input);
1659        assert_eq!(first.len(), 2);
1660        assert_eq!(
1661            first
1662                .iter()
1663                .map(|candidate| candidate.gene.id.clone())
1664                .collect::<Vec<_>>(),
1665            second
1666                .iter()
1667                .map(|candidate| candidate.gene.id.clone())
1668                .collect::<Vec<_>>()
1669        );
1670    }
1671
1672    #[test]
1673    fn selector_can_narrow_by_spec_id() {
1674        let projection = EvolutionProjection {
1675            genes: vec![
1676                Gene {
1677                    id: "gene-a".into(),
1678                    signals: vec!["signal".into()],
1679                    strategy: vec!["a".into()],
1680                    validation: vec!["oris-default".into()],
1681                    state: AssetState::Promoted,
1682                },
1683                Gene {
1684                    id: "gene-b".into(),
1685                    signals: vec!["signal".into()],
1686                    strategy: vec!["b".into()],
1687                    validation: vec!["oris-default".into()],
1688                    state: AssetState::Promoted,
1689                },
1690            ],
1691            capsules: vec![
1692                Capsule {
1693                    id: "capsule-a".into(),
1694                    gene_id: "gene-a".into(),
1695                    mutation_id: "m1".into(),
1696                    run_id: "r1".into(),
1697                    diff_hash: "1".into(),
1698                    confidence: 0.7,
1699                    env: EnvFingerprint {
1700                        rustc_version: "rustc".into(),
1701                        cargo_lock_hash: "lock".into(),
1702                        target_triple: "x86_64-unknown-linux-gnu".into(),
1703                        os: "linux".into(),
1704                    },
1705                    outcome: Outcome {
1706                        success: true,
1707                        validation_profile: "oris-default".into(),
1708                        validation_duration_ms: 1,
1709                        changed_files: vec!["crates/oris-kernel".into()],
1710                        validator_hash: "v".into(),
1711                        lines_changed: 1,
1712                        replay_verified: false,
1713                    },
1714                    state: AssetState::Promoted,
1715                },
1716                Capsule {
1717                    id: "capsule-b".into(),
1718                    gene_id: "gene-b".into(),
1719                    mutation_id: "m2".into(),
1720                    run_id: "r2".into(),
1721                    diff_hash: "2".into(),
1722                    confidence: 0.7,
1723                    env: EnvFingerprint {
1724                        rustc_version: "rustc".into(),
1725                        cargo_lock_hash: "lock".into(),
1726                        target_triple: "x86_64-unknown-linux-gnu".into(),
1727                        os: "linux".into(),
1728                    },
1729                    outcome: Outcome {
1730                        success: true,
1731                        validation_profile: "oris-default".into(),
1732                        validation_duration_ms: 1,
1733                        changed_files: vec!["crates/oris-kernel".into()],
1734                        validator_hash: "v".into(),
1735                        lines_changed: 1,
1736                        replay_verified: false,
1737                    },
1738                    state: AssetState::Promoted,
1739                },
1740            ],
1741            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1742            attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1743            last_updated_at: BTreeMap::from([
1744                ("gene-a".into(), Utc::now().to_rfc3339()),
1745                ("gene-b".into(), Utc::now().to_rfc3339()),
1746            ]),
1747            spec_ids_by_gene: BTreeMap::from([
1748                ("gene-a".into(), BTreeSet::from(["spec-a".to_string()])),
1749                ("gene-b".into(), BTreeSet::from(["spec-b".to_string()])),
1750            ]),
1751        };
1752        let selector = ProjectionSelector::new(projection);
1753        let input = SelectorInput {
1754            signals: vec!["signal".into()],
1755            env: EnvFingerprint {
1756                rustc_version: "rustc".into(),
1757                cargo_lock_hash: "lock".into(),
1758                target_triple: "x86_64-unknown-linux-gnu".into(),
1759                os: "linux".into(),
1760            },
1761            spec_id: Some("spec-b".into()),
1762            limit: 2,
1763        };
1764        let selected = selector.select(&input);
1765        assert_eq!(selected.len(), 1);
1766        assert_eq!(selected[0].gene.id, "gene-b");
1767    }
1768
1769    #[test]
1770    fn selector_prefers_closest_environment_match() {
1771        let projection = EvolutionProjection {
1772            genes: vec![
1773                Gene {
1774                    id: "gene-a".into(),
1775                    signals: vec!["signal".into()],
1776                    strategy: vec!["a".into()],
1777                    validation: vec!["oris-default".into()],
1778                    state: AssetState::Promoted,
1779                },
1780                Gene {
1781                    id: "gene-b".into(),
1782                    signals: vec!["signal".into()],
1783                    strategy: vec!["b".into()],
1784                    validation: vec!["oris-default".into()],
1785                    state: AssetState::Promoted,
1786                },
1787            ],
1788            capsules: vec![
1789                Capsule {
1790                    id: "capsule-a-stale".into(),
1791                    gene_id: "gene-a".into(),
1792                    mutation_id: "m1".into(),
1793                    run_id: "r1".into(),
1794                    diff_hash: "1".into(),
1795                    confidence: 0.2,
1796                    env: EnvFingerprint {
1797                        rustc_version: "old-rustc".into(),
1798                        cargo_lock_hash: "other-lock".into(),
1799                        target_triple: "aarch64-apple-darwin".into(),
1800                        os: "macos".into(),
1801                    },
1802                    outcome: Outcome {
1803                        success: true,
1804                        validation_profile: "oris-default".into(),
1805                        validation_duration_ms: 1,
1806                        changed_files: vec!["crates/oris-kernel".into()],
1807                        validator_hash: "v".into(),
1808                        lines_changed: 1,
1809                        replay_verified: false,
1810                    },
1811                    state: AssetState::Promoted,
1812                },
1813                Capsule {
1814                    id: "capsule-a-best".into(),
1815                    gene_id: "gene-a".into(),
1816                    mutation_id: "m2".into(),
1817                    run_id: "r2".into(),
1818                    diff_hash: "2".into(),
1819                    confidence: 0.9,
1820                    env: EnvFingerprint {
1821                        rustc_version: "rustc".into(),
1822                        cargo_lock_hash: "lock".into(),
1823                        target_triple: "x86_64-unknown-linux-gnu".into(),
1824                        os: "linux".into(),
1825                    },
1826                    outcome: Outcome {
1827                        success: true,
1828                        validation_profile: "oris-default".into(),
1829                        validation_duration_ms: 1,
1830                        changed_files: vec!["crates/oris-kernel".into()],
1831                        validator_hash: "v".into(),
1832                        lines_changed: 1,
1833                        replay_verified: false,
1834                    },
1835                    state: AssetState::Promoted,
1836                },
1837                Capsule {
1838                    id: "capsule-b".into(),
1839                    gene_id: "gene-b".into(),
1840                    mutation_id: "m3".into(),
1841                    run_id: "r3".into(),
1842                    diff_hash: "3".into(),
1843                    confidence: 0.7,
1844                    env: EnvFingerprint {
1845                        rustc_version: "rustc".into(),
1846                        cargo_lock_hash: "different-lock".into(),
1847                        target_triple: "x86_64-unknown-linux-gnu".into(),
1848                        os: "linux".into(),
1849                    },
1850                    outcome: Outcome {
1851                        success: true,
1852                        validation_profile: "oris-default".into(),
1853                        validation_duration_ms: 1,
1854                        changed_files: vec!["crates/oris-kernel".into()],
1855                        validator_hash: "v".into(),
1856                        lines_changed: 1,
1857                        replay_verified: false,
1858                    },
1859                    state: AssetState::Promoted,
1860                },
1861            ],
1862            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1863            attempt_counts: BTreeMap::from([("gene-a".into(), 2), ("gene-b".into(), 1)]),
1864            last_updated_at: BTreeMap::from([
1865                ("gene-a".into(), Utc::now().to_rfc3339()),
1866                ("gene-b".into(), Utc::now().to_rfc3339()),
1867            ]),
1868            spec_ids_by_gene: BTreeMap::new(),
1869        };
1870        let selector = ProjectionSelector::new(projection);
1871        let input = SelectorInput {
1872            signals: vec!["signal".into()],
1873            env: EnvFingerprint {
1874                rustc_version: "rustc".into(),
1875                cargo_lock_hash: "lock".into(),
1876                target_triple: "x86_64-unknown-linux-gnu".into(),
1877                os: "linux".into(),
1878            },
1879            spec_id: None,
1880            limit: 2,
1881        };
1882
1883        let selected = selector.select(&input);
1884
1885        assert_eq!(selected.len(), 2);
1886        assert_eq!(selected[0].gene.id, "gene-a");
1887        assert_eq!(selected[0].capsules[0].id, "capsule-a-best");
1888        assert!(selected[0].score > selected[1].score);
1889    }
1890
1891    #[test]
1892    fn selector_preserves_fresh_candidate_scores_while_ranking_by_confidence() {
1893        let now = Utc::now();
1894        let projection = EvolutionProjection {
1895            genes: vec![Gene {
1896                id: "gene-fresh".into(),
1897                signals: vec!["missing".into()],
1898                strategy: vec!["a".into()],
1899                validation: vec!["oris-default".into()],
1900                state: AssetState::Promoted,
1901            }],
1902            capsules: vec![Capsule {
1903                id: "capsule-fresh".into(),
1904                gene_id: "gene-fresh".into(),
1905                mutation_id: "m1".into(),
1906                run_id: "r1".into(),
1907                diff_hash: "1".into(),
1908                confidence: 0.7,
1909                env: EnvFingerprint {
1910                    rustc_version: "rustc".into(),
1911                    cargo_lock_hash: "lock".into(),
1912                    target_triple: "x86_64-unknown-linux-gnu".into(),
1913                    os: "linux".into(),
1914                },
1915                outcome: Outcome {
1916                    success: true,
1917                    validation_profile: "oris-default".into(),
1918                    validation_duration_ms: 1,
1919                    changed_files: vec!["README.md".into()],
1920                    validator_hash: "v".into(),
1921                    lines_changed: 1,
1922                    replay_verified: false,
1923                },
1924                state: AssetState::Promoted,
1925            }],
1926            reuse_counts: BTreeMap::from([("gene-fresh".into(), 1)]),
1927            attempt_counts: BTreeMap::from([("gene-fresh".into(), 1)]),
1928            last_updated_at: BTreeMap::from([("gene-fresh".into(), now.to_rfc3339())]),
1929            spec_ids_by_gene: BTreeMap::new(),
1930        };
1931        let selector = ProjectionSelector::with_now(projection, now);
1932        let input = SelectorInput {
1933            signals: vec![
1934                "missing".into(),
1935                "token-a".into(),
1936                "token-b".into(),
1937                "token-c".into(),
1938            ],
1939            env: EnvFingerprint {
1940                rustc_version: "rustc".into(),
1941                cargo_lock_hash: "lock".into(),
1942                target_triple: "x86_64-unknown-linux-gnu".into(),
1943                os: "linux".into(),
1944            },
1945            spec_id: None,
1946            limit: 1,
1947        };
1948
1949        let selected = selector.select(&input);
1950
1951        assert_eq!(selected.len(), 1);
1952        assert_eq!(selected[0].gene.id, "gene-fresh");
1953        assert!(selected[0].score > 0.35);
1954    }
1955
1956    #[test]
1957    fn selector_skips_stale_candidates_after_confidence_decay() {
1958        let now = Utc::now();
1959        let projection = EvolutionProjection {
1960            genes: vec![Gene {
1961                id: "gene-stale".into(),
1962                signals: vec!["missing readme".into()],
1963                strategy: vec!["a".into()],
1964                validation: vec!["oris-default".into()],
1965                state: AssetState::Promoted,
1966            }],
1967            capsules: vec![Capsule {
1968                id: "capsule-stale".into(),
1969                gene_id: "gene-stale".into(),
1970                mutation_id: "m1".into(),
1971                run_id: "r1".into(),
1972                diff_hash: "1".into(),
1973                confidence: 0.8,
1974                env: EnvFingerprint {
1975                    rustc_version: "rustc".into(),
1976                    cargo_lock_hash: "lock".into(),
1977                    target_triple: "x86_64-unknown-linux-gnu".into(),
1978                    os: "linux".into(),
1979                },
1980                outcome: Outcome {
1981                    success: true,
1982                    validation_profile: "oris-default".into(),
1983                    validation_duration_ms: 1,
1984                    changed_files: vec!["README.md".into()],
1985                    validator_hash: "v".into(),
1986                    lines_changed: 1,
1987                    replay_verified: false,
1988                },
1989                state: AssetState::Promoted,
1990            }],
1991            reuse_counts: BTreeMap::from([("gene-stale".into(), 2)]),
1992            attempt_counts: BTreeMap::from([("gene-stale".into(), 1)]),
1993            last_updated_at: BTreeMap::from([(
1994                "gene-stale".into(),
1995                (now - chrono::Duration::hours(48)).to_rfc3339(),
1996            )]),
1997            spec_ids_by_gene: BTreeMap::new(),
1998        };
1999        let selector = ProjectionSelector::with_now(projection, now);
2000        let input = SelectorInput {
2001            signals: vec!["missing readme".into()],
2002            env: EnvFingerprint {
2003                rustc_version: "rustc".into(),
2004                cargo_lock_hash: "lock".into(),
2005                target_triple: "x86_64-unknown-linux-gnu".into(),
2006                os: "linux".into(),
2007            },
2008            spec_id: None,
2009            limit: 1,
2010        };
2011
2012        let selected = selector.select(&input);
2013
2014        assert!(selected.is_empty());
2015        assert!(decayed_replay_confidence(0.8, Some(48 * 60 * 60)) < MIN_REPLAY_CONFIDENCE);
2016    }
2017
2018    #[test]
2019    fn legacy_capsule_reused_events_deserialize_without_replay_run_id() {
2020        let serialized = r#"{
2021  "seq": 1,
2022  "timestamp": "2026-03-04T00:00:00Z",
2023  "prev_hash": "",
2024  "record_hash": "hash",
2025  "event": {
2026    "kind": "capsule_reused",
2027    "capsule_id": "capsule-1",
2028    "gene_id": "gene-1",
2029    "run_id": "run-1"
2030  }
2031}"#;
2032
2033        let stored = serde_json::from_str::<StoredEvolutionEvent>(serialized).unwrap();
2034
2035        match stored.event {
2036            EvolutionEvent::CapsuleReused {
2037                capsule_id,
2038                gene_id,
2039                run_id,
2040                replay_run_id,
2041            } => {
2042                assert_eq!(capsule_id, "capsule-1");
2043                assert_eq!(gene_id, "gene-1");
2044                assert_eq!(run_id, "run-1");
2045                assert_eq!(replay_run_id, None);
2046            }
2047            other => panic!("unexpected event: {other:?}"),
2048        }
2049    }
2050
2051    #[test]
2052    fn legacy_remote_asset_imported_events_deserialize_without_sender_id() {
2053        let serialized = r#"{
2054  "seq": 1,
2055  "timestamp": "2026-03-04T00:00:00Z",
2056  "prev_hash": "",
2057  "record_hash": "hash",
2058  "event": {
2059    "kind": "remote_asset_imported",
2060    "source": "Remote",
2061    "asset_ids": ["gene-1"]
2062  }
2063}"#;
2064
2065        let stored = serde_json::from_str::<StoredEvolutionEvent>(serialized).unwrap();
2066
2067        match stored.event {
2068            EvolutionEvent::RemoteAssetImported {
2069                source,
2070                asset_ids,
2071                sender_id,
2072            } => {
2073                assert_eq!(source, CandidateSource::Remote);
2074                assert_eq!(asset_ids, vec!["gene-1"]);
2075                assert_eq!(sender_id, None);
2076            }
2077            other => panic!("unexpected event: {other:?}"),
2078        }
2079    }
2080
2081    #[test]
2082    fn normalized_signal_overlap_accepts_semantic_multisignal_variants() {
2083        let overlap = normalized_signal_overlap(
2084            &["missing readme".into(), "route beijing shanghai".into()],
2085            &[
2086                "README file absent".into(),
2087                "travel route beijing shanghai".into(),
2088            ],
2089        );
2090
2091        assert!(overlap >= 0.99, "expected strong overlap, got {overlap}");
2092    }
2093
2094    #[test]
2095    fn normalized_signal_overlap_rejects_single_shared_token_false_positives() {
2096        let overlap =
2097            normalized_signal_overlap(&["missing readme".into()], &["missing cargo".into()]);
2098
2099        assert_eq!(overlap, 0.0);
2100    }
2101}