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