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