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