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    },
211    SpecLinked {
212        mutation_id: MutationId,
213        spec_id: String,
214    },
215}
216
217#[derive(Clone, Debug, Serialize, Deserialize)]
218pub struct StoredEvolutionEvent {
219    pub seq: u64,
220    pub timestamp: String,
221    pub prev_hash: String,
222    pub record_hash: String,
223    pub event: EvolutionEvent,
224}
225
226#[derive(Clone, Debug, Default, Serialize, Deserialize)]
227pub struct EvolutionProjection {
228    pub genes: Vec<Gene>,
229    pub capsules: Vec<Capsule>,
230    pub reuse_counts: BTreeMap<GeneId, u64>,
231    pub attempt_counts: BTreeMap<GeneId, u64>,
232    pub last_updated_at: BTreeMap<GeneId, String>,
233    pub spec_ids_by_gene: BTreeMap<GeneId, BTreeSet<String>>,
234}
235
236#[derive(Clone, Debug)]
237pub struct SelectorInput {
238    pub signals: Vec<String>,
239    pub env: EnvFingerprint,
240    pub spec_id: Option<String>,
241    pub limit: usize,
242}
243
244#[derive(Clone, Debug)]
245pub struct GeneCandidate {
246    pub gene: Gene,
247    pub score: f32,
248    pub capsules: Vec<Capsule>,
249}
250
251pub trait Selector: Send + Sync {
252    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate>;
253}
254
255pub trait EvolutionStore: Send + Sync {
256    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError>;
257    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError>;
258    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError>;
259}
260
261#[derive(Debug, Error)]
262pub enum EvolutionError {
263    #[error("I/O error: {0}")]
264    Io(String),
265    #[error("Serialization error: {0}")]
266    Serde(String),
267    #[error("Hash chain validation failed: {0}")]
268    HashChain(String),
269}
270
271pub struct JsonlEvolutionStore {
272    root_dir: PathBuf,
273    lock: Mutex<()>,
274}
275
276impl JsonlEvolutionStore {
277    pub fn new<P: Into<PathBuf>>(root_dir: P) -> Self {
278        Self {
279            root_dir: root_dir.into(),
280            lock: Mutex::new(()),
281        }
282    }
283
284    pub fn root_dir(&self) -> &Path {
285        &self.root_dir
286    }
287
288    fn ensure_layout(&self) -> Result<(), EvolutionError> {
289        fs::create_dir_all(&self.root_dir).map_err(io_err)?;
290        let lock_path = self.root_dir.join("LOCK");
291        if !lock_path.exists() {
292            File::create(lock_path).map_err(io_err)?;
293        }
294        let events_path = self.events_path();
295        if !events_path.exists() {
296            File::create(events_path).map_err(io_err)?;
297        }
298        Ok(())
299    }
300
301    fn events_path(&self) -> PathBuf {
302        self.root_dir.join("events.jsonl")
303    }
304
305    fn genes_path(&self) -> PathBuf {
306        self.root_dir.join("genes.json")
307    }
308
309    fn capsules_path(&self) -> PathBuf {
310        self.root_dir.join("capsules.json")
311    }
312
313    fn read_all_events(&self) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
314        self.ensure_layout()?;
315        let file = File::open(self.events_path()).map_err(io_err)?;
316        let reader = BufReader::new(file);
317        let mut events = Vec::new();
318        for line in reader.lines() {
319            let line = line.map_err(io_err)?;
320            if line.trim().is_empty() {
321                continue;
322            }
323            let event = serde_json::from_str::<StoredEvolutionEvent>(&line)
324                .map_err(|err| EvolutionError::Serde(err.to_string()))?;
325            events.push(event);
326        }
327        verify_hash_chain(&events)?;
328        Ok(events)
329    }
330
331    fn write_projection_files(
332        &self,
333        projection: &EvolutionProjection,
334    ) -> Result<(), EvolutionError> {
335        write_json_atomic(&self.genes_path(), &projection.genes)?;
336        write_json_atomic(&self.capsules_path(), &projection.capsules)?;
337        Ok(())
338    }
339
340    fn append_event_locked(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
341        let existing = self.read_all_events()?;
342        let next_seq = existing.last().map(|entry| entry.seq + 1).unwrap_or(1);
343        let prev_hash = existing
344            .last()
345            .map(|entry| entry.record_hash.clone())
346            .unwrap_or_default();
347        let timestamp = Utc::now().to_rfc3339();
348        let record_hash = hash_record(next_seq, &timestamp, &prev_hash, &event)?;
349        let stored = StoredEvolutionEvent {
350            seq: next_seq,
351            timestamp,
352            prev_hash,
353            record_hash,
354            event,
355        };
356        let mut file = OpenOptions::new()
357            .create(true)
358            .append(true)
359            .open(self.events_path())
360            .map_err(io_err)?;
361        let line =
362            serde_json::to_string(&stored).map_err(|err| EvolutionError::Serde(err.to_string()))?;
363        file.write_all(line.as_bytes()).map_err(io_err)?;
364        file.write_all(b"\n").map_err(io_err)?;
365        file.sync_data().map_err(io_err)?;
366
367        let events = self.read_all_events()?;
368        let projection = rebuild_projection_from_events(&events);
369        self.write_projection_files(&projection)?;
370        Ok(next_seq)
371    }
372}
373
374impl EvolutionStore for JsonlEvolutionStore {
375    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
376        let _guard = self
377            .lock
378            .lock()
379            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
380        self.append_event_locked(event)
381    }
382
383    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
384        let _guard = self
385            .lock
386            .lock()
387            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
388        Ok(self
389            .read_all_events()?
390            .into_iter()
391            .filter(|entry| entry.seq >= from_seq)
392            .collect())
393    }
394
395    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
396        let _guard = self
397            .lock
398            .lock()
399            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
400        let projection = rebuild_projection_from_events(&self.read_all_events()?);
401        self.write_projection_files(&projection)?;
402        Ok(projection)
403    }
404}
405
406pub struct ProjectionSelector {
407    projection: EvolutionProjection,
408    now: DateTime<Utc>,
409}
410
411impl ProjectionSelector {
412    pub fn new(projection: EvolutionProjection) -> Self {
413        Self {
414            projection,
415            now: Utc::now(),
416        }
417    }
418
419    pub fn with_now(projection: EvolutionProjection, now: DateTime<Utc>) -> Self {
420        Self { projection, now }
421    }
422}
423
424impl Selector for ProjectionSelector {
425    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
426        let requested_spec_id = input
427            .spec_id
428            .as_deref()
429            .map(str::trim)
430            .filter(|value| !value.is_empty());
431        let mut out = Vec::new();
432        for gene in &self.projection.genes {
433            if gene.state != AssetState::Promoted {
434                continue;
435            }
436            if let Some(spec_id) = requested_spec_id {
437                let matches_spec = self
438                    .projection
439                    .spec_ids_by_gene
440                    .get(&gene.id)
441                    .map(|values| {
442                        values
443                            .iter()
444                            .any(|value| value.eq_ignore_ascii_case(spec_id))
445                    })
446                    .unwrap_or(false);
447                if !matches_spec {
448                    continue;
449                }
450            }
451            let capsules = self
452                .projection
453                .capsules
454                .iter()
455                .filter(|capsule| {
456                    capsule.gene_id == gene.id && capsule.state == AssetState::Promoted
457                })
458                .cloned()
459                .collect::<Vec<_>>();
460            if capsules.is_empty() {
461                continue;
462            }
463            let mut capsules = capsules;
464            capsules.sort_by(|left, right| {
465                environment_match_factor(&input.env, &right.env)
466                    .partial_cmp(&environment_match_factor(&input.env, &left.env))
467                    .unwrap_or(std::cmp::Ordering::Equal)
468                    .then_with(|| {
469                        right
470                            .confidence
471                            .partial_cmp(&left.confidence)
472                            .unwrap_or(std::cmp::Ordering::Equal)
473                    })
474                    .then_with(|| left.id.cmp(&right.id))
475            });
476            let env_match_factor = capsules
477                .first()
478                .map(|capsule| environment_match_factor(&input.env, &capsule.env))
479                .unwrap_or(0.0);
480
481            let successful_capsules = capsules.len() as f64;
482            let attempts = self
483                .projection
484                .attempt_counts
485                .get(&gene.id)
486                .copied()
487                .unwrap_or(capsules.len() as u64) as f64;
488            let success_rate = if attempts == 0.0 {
489                0.0
490            } else {
491                successful_capsules / attempts
492            };
493            let successful_reuses = self
494                .projection
495                .reuse_counts
496                .get(&gene.id)
497                .copied()
498                .unwrap_or(0) as f64;
499            let reuse_count_factor = 1.0 + (1.0 + successful_reuses).ln();
500            let signal_overlap = normalized_signal_overlap(&gene.signals, &input.signals);
501            let recency_decay = self
502                .projection
503                .last_updated_at
504                .get(&gene.id)
505                .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
506                .map(|dt| {
507                    let age_days = (self.now - dt.with_timezone(&Utc)).num_days().max(0) as f64;
508                    E.powf(-age_days / 30.0)
509                })
510                .unwrap_or(0.0);
511            let score = (success_rate
512                * reuse_count_factor
513                * env_match_factor
514                * recency_decay
515                * signal_overlap) as f32;
516            if score < 0.35 {
517                continue;
518            }
519            out.push(GeneCandidate {
520                gene: gene.clone(),
521                score,
522                capsules,
523            });
524        }
525
526        out.sort_by(|left, right| {
527            right
528                .score
529                .partial_cmp(&left.score)
530                .unwrap_or(std::cmp::Ordering::Equal)
531                .then_with(|| left.gene.id.cmp(&right.gene.id))
532        });
533        out.truncate(input.limit.max(1));
534        out
535    }
536}
537
538pub struct StoreBackedSelector {
539    store: std::sync::Arc<dyn EvolutionStore>,
540}
541
542impl StoreBackedSelector {
543    pub fn new(store: std::sync::Arc<dyn EvolutionStore>) -> Self {
544        Self { store }
545    }
546}
547
548impl Selector for StoreBackedSelector {
549    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
550        match self.store.rebuild_projection() {
551            Ok(projection) => ProjectionSelector::new(projection).select(input),
552            Err(_) => Vec::new(),
553        }
554    }
555}
556
557pub fn rebuild_projection_from_events(events: &[StoredEvolutionEvent]) -> EvolutionProjection {
558    let mut genes = BTreeMap::<GeneId, Gene>::new();
559    let mut capsules = BTreeMap::<CapsuleId, Capsule>::new();
560    let mut reuse_counts = BTreeMap::<GeneId, u64>::new();
561    let mut attempt_counts = BTreeMap::<GeneId, u64>::new();
562    let mut last_updated_at = BTreeMap::<GeneId, String>::new();
563    let mut spec_ids_by_gene = BTreeMap::<GeneId, BTreeSet<String>>::new();
564    let mut mutation_to_gene = HashMap::<MutationId, GeneId>::new();
565    let mut mutation_spec_ids = HashMap::<MutationId, String>::new();
566
567    for stored in events {
568        match &stored.event {
569            EvolutionEvent::MutationDeclared { mutation } => {
570                if let Some(spec_id) = mutation
571                    .intent
572                    .spec_id
573                    .as_ref()
574                    .map(|value| value.trim())
575                    .filter(|value| !value.is_empty())
576                {
577                    mutation_spec_ids.insert(mutation.intent.id.clone(), spec_id.to_string());
578                }
579            }
580            EvolutionEvent::GeneProjected { gene } => {
581                genes.insert(gene.id.clone(), gene.clone());
582                last_updated_at.insert(gene.id.clone(), stored.timestamp.clone());
583            }
584            EvolutionEvent::GenePromoted { gene_id } => {
585                if let Some(gene) = genes.get_mut(gene_id) {
586                    gene.state = AssetState::Promoted;
587                }
588                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
589            }
590            EvolutionEvent::GeneRevoked { gene_id, .. } => {
591                if let Some(gene) = genes.get_mut(gene_id) {
592                    gene.state = AssetState::Revoked;
593                }
594                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
595            }
596            EvolutionEvent::GeneArchived { gene_id } => {
597                if let Some(gene) = genes.get_mut(gene_id) {
598                    gene.state = AssetState::Archived;
599                }
600                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
601            }
602            EvolutionEvent::PromotionEvaluated { gene_id, state, .. } => {
603                if let Some(gene) = genes.get_mut(gene_id) {
604                    gene.state = state.clone();
605                }
606                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
607            }
608            EvolutionEvent::CapsuleCommitted { capsule } => {
609                mutation_to_gene.insert(capsule.mutation_id.clone(), capsule.gene_id.clone());
610                capsules.insert(capsule.id.clone(), capsule.clone());
611                *attempt_counts.entry(capsule.gene_id.clone()).or_insert(0) += 1;
612                if let Some(spec_id) = mutation_spec_ids.get(&capsule.mutation_id) {
613                    spec_ids_by_gene
614                        .entry(capsule.gene_id.clone())
615                        .or_default()
616                        .insert(spec_id.clone());
617                }
618                last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
619            }
620            EvolutionEvent::CapsuleQuarantined { capsule_id } => {
621                if let Some(capsule) = capsules.get_mut(capsule_id) {
622                    capsule.state = AssetState::Quarantined;
623                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
624                }
625            }
626            EvolutionEvent::CapsuleReleased { capsule_id, state } => {
627                if let Some(capsule) = capsules.get_mut(capsule_id) {
628                    capsule.state = state.clone();
629                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
630                }
631            }
632            EvolutionEvent::CapsuleReused { gene_id, .. } => {
633                *reuse_counts.entry(gene_id.clone()).or_insert(0) += 1;
634                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
635            }
636            EvolutionEvent::ValidationFailed {
637                mutation_id,
638                gene_id,
639                ..
640            } => {
641                let id = gene_id
642                    .clone()
643                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
644                if let Some(gene_id) = id {
645                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
646                    last_updated_at.insert(gene_id, stored.timestamp.clone());
647                }
648            }
649            EvolutionEvent::ValidationPassed {
650                mutation_id,
651                gene_id,
652                ..
653            } => {
654                let id = gene_id
655                    .clone()
656                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
657                if let Some(gene_id) = id {
658                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
659                    last_updated_at.insert(gene_id, stored.timestamp.clone());
660                }
661            }
662            _ => {}
663        }
664    }
665
666    EvolutionProjection {
667        genes: genes.into_values().collect(),
668        capsules: capsules.into_values().collect(),
669        reuse_counts,
670        attempt_counts,
671        last_updated_at,
672        spec_ids_by_gene,
673    }
674}
675
676pub fn default_store_root() -> PathBuf {
677    PathBuf::from(".oris").join("evolution")
678}
679
680pub fn hash_string(input: &str) -> String {
681    let mut hasher = Sha256::new();
682    hasher.update(input.as_bytes());
683    hex::encode(hasher.finalize())
684}
685
686pub fn stable_hash_json<T: Serialize>(value: &T) -> Result<String, EvolutionError> {
687    let bytes = serde_json::to_vec(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
688    let mut hasher = Sha256::new();
689    hasher.update(bytes);
690    Ok(hex::encode(hasher.finalize()))
691}
692
693pub fn compute_artifact_hash(payload: &str) -> String {
694    hash_string(payload)
695}
696
697pub fn next_id(prefix: &str) -> String {
698    let nanos = SystemTime::now()
699        .duration_since(UNIX_EPOCH)
700        .unwrap_or_default()
701        .as_nanos();
702    format!("{prefix}-{nanos:x}")
703}
704
705fn normalized_signal_overlap(gene_signals: &[String], input_signals: &[String]) -> f64 {
706    if input_signals.is_empty() {
707        return 0.0;
708    }
709    let gene = gene_signals
710        .iter()
711        .map(|signal| signal.to_ascii_lowercase())
712        .collect::<BTreeSet<_>>();
713    let input = input_signals
714        .iter()
715        .map(|signal| signal.to_ascii_lowercase())
716        .collect::<BTreeSet<_>>();
717    let matched = input.iter().filter(|signal| gene.contains(*signal)).count() as f64;
718    matched / input.len() as f64
719}
720
721fn environment_match_factor(input: &EnvFingerprint, candidate: &EnvFingerprint) -> f64 {
722    let fields = [
723        input
724            .rustc_version
725            .eq_ignore_ascii_case(&candidate.rustc_version),
726        input
727            .cargo_lock_hash
728            .eq_ignore_ascii_case(&candidate.cargo_lock_hash),
729        input
730            .target_triple
731            .eq_ignore_ascii_case(&candidate.target_triple),
732        input.os.eq_ignore_ascii_case(&candidate.os),
733    ];
734    let matched_fields = fields.into_iter().filter(|matched| *matched).count() as f64;
735    0.5 + ((matched_fields / 4.0) * 0.5)
736}
737
738fn hash_record(
739    seq: u64,
740    timestamp: &str,
741    prev_hash: &str,
742    event: &EvolutionEvent,
743) -> Result<String, EvolutionError> {
744    stable_hash_json(&(seq, timestamp, prev_hash, event))
745}
746
747fn verify_hash_chain(events: &[StoredEvolutionEvent]) -> Result<(), EvolutionError> {
748    let mut previous_hash = String::new();
749    let mut expected_seq = 1u64;
750    for event in events {
751        if event.seq != expected_seq {
752            return Err(EvolutionError::HashChain(format!(
753                "expected seq {}, found {}",
754                expected_seq, event.seq
755            )));
756        }
757        if event.prev_hash != previous_hash {
758            return Err(EvolutionError::HashChain(format!(
759                "event {} prev_hash mismatch",
760                event.seq
761            )));
762        }
763        let actual_hash = hash_record(event.seq, &event.timestamp, &event.prev_hash, &event.event)?;
764        if actual_hash != event.record_hash {
765            return Err(EvolutionError::HashChain(format!(
766                "event {} record_hash mismatch",
767                event.seq
768            )));
769        }
770        previous_hash = event.record_hash.clone();
771        expected_seq += 1;
772    }
773    Ok(())
774}
775
776fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), EvolutionError> {
777    let tmp_path = path.with_extension("tmp");
778    let bytes =
779        serde_json::to_vec_pretty(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
780    fs::write(&tmp_path, bytes).map_err(io_err)?;
781    fs::rename(&tmp_path, path).map_err(io_err)?;
782    Ok(())
783}
784
785fn io_err(err: std::io::Error) -> EvolutionError {
786    EvolutionError::Io(err.to_string())
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    fn temp_root(name: &str) -> PathBuf {
794        std::env::temp_dir().join(format!("oris-evolution-{name}-{}", next_id("t")))
795    }
796
797    fn sample_mutation() -> PreparedMutation {
798        PreparedMutation {
799            intent: MutationIntent {
800                id: "mutation-1".into(),
801                intent: "tighten borrow scope".into(),
802                target: MutationTarget::Paths {
803                    allow: vec!["crates/oris-kernel".into()],
804                },
805                expected_effect: "cargo check passes".into(),
806                risk: RiskLevel::Low,
807                signals: vec!["rust borrow error".into()],
808                spec_id: None,
809            },
810            artifact: MutationArtifact {
811                encoding: ArtifactEncoding::UnifiedDiff,
812                payload: "diff --git a/foo b/foo".into(),
813                base_revision: Some("HEAD".into()),
814                content_hash: compute_artifact_hash("diff --git a/foo b/foo"),
815            },
816        }
817    }
818
819    #[test]
820    fn append_event_assigns_monotonic_seq() {
821        let root = temp_root("seq");
822        let store = JsonlEvolutionStore::new(root);
823        let first = store
824            .append_event(EvolutionEvent::MutationDeclared {
825                mutation: sample_mutation(),
826            })
827            .unwrap();
828        let second = store
829            .append_event(EvolutionEvent::MutationRejected {
830                mutation_id: "mutation-1".into(),
831                reason: "no-op".into(),
832            })
833            .unwrap();
834        assert_eq!(first, 1);
835        assert_eq!(second, 2);
836    }
837
838    #[test]
839    fn tampered_hash_chain_is_rejected() {
840        let root = temp_root("tamper");
841        let store = JsonlEvolutionStore::new(&root);
842        store
843            .append_event(EvolutionEvent::MutationDeclared {
844                mutation: sample_mutation(),
845            })
846            .unwrap();
847        let path = root.join("events.jsonl");
848        let contents = fs::read_to_string(&path).unwrap();
849        let mutated = contents.replace("tighten borrow scope", "tampered");
850        fs::write(&path, mutated).unwrap();
851        let result = store.scan(1);
852        assert!(matches!(result, Err(EvolutionError::HashChain(_))));
853    }
854
855    #[test]
856    fn rebuild_projection_after_cache_deletion() {
857        let root = temp_root("projection");
858        let store = JsonlEvolutionStore::new(&root);
859        let gene = Gene {
860            id: "gene-1".into(),
861            signals: vec!["rust borrow error".into()],
862            strategy: vec!["crates".into()],
863            validation: vec!["oris-default".into()],
864            state: AssetState::Promoted,
865        };
866        let capsule = Capsule {
867            id: "capsule-1".into(),
868            gene_id: gene.id.clone(),
869            mutation_id: "mutation-1".into(),
870            run_id: "run-1".into(),
871            diff_hash: "abc".into(),
872            confidence: 0.7,
873            env: EnvFingerprint {
874                rustc_version: "rustc 1.80".into(),
875                cargo_lock_hash: "lock".into(),
876                target_triple: "x86_64-unknown-linux-gnu".into(),
877                os: "linux".into(),
878            },
879            outcome: Outcome {
880                success: true,
881                validation_profile: "oris-default".into(),
882                validation_duration_ms: 100,
883                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
884                validator_hash: "vh".into(),
885                lines_changed: 1,
886                replay_verified: false,
887            },
888            state: AssetState::Promoted,
889        };
890        store
891            .append_event(EvolutionEvent::GeneProjected { gene })
892            .unwrap();
893        store
894            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
895            .unwrap();
896        fs::remove_file(root.join("genes.json")).unwrap();
897        fs::remove_file(root.join("capsules.json")).unwrap();
898        let projection = store.rebuild_projection().unwrap();
899        assert_eq!(projection.genes.len(), 1);
900        assert_eq!(projection.capsules.len(), 1);
901    }
902
903    #[test]
904    fn rebuild_projection_tracks_spec_ids_for_genes() {
905        let root = temp_root("projection-spec");
906        let store = JsonlEvolutionStore::new(&root);
907        let mut mutation = sample_mutation();
908        mutation.intent.id = "mutation-spec".into();
909        mutation.intent.spec_id = Some("spec-repair-1".into());
910        let gene = Gene {
911            id: "gene-spec".into(),
912            signals: vec!["rust borrow error".into()],
913            strategy: vec!["crates".into()],
914            validation: vec!["oris-default".into()],
915            state: AssetState::Promoted,
916        };
917        let capsule = Capsule {
918            id: "capsule-spec".into(),
919            gene_id: gene.id.clone(),
920            mutation_id: mutation.intent.id.clone(),
921            run_id: "run-spec".into(),
922            diff_hash: "abc".into(),
923            confidence: 0.7,
924            env: EnvFingerprint {
925                rustc_version: "rustc 1.80".into(),
926                cargo_lock_hash: "lock".into(),
927                target_triple: "x86_64-unknown-linux-gnu".into(),
928                os: "linux".into(),
929            },
930            outcome: Outcome {
931                success: true,
932                validation_profile: "oris-default".into(),
933                validation_duration_ms: 100,
934                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
935                validator_hash: "vh".into(),
936                lines_changed: 1,
937                replay_verified: false,
938            },
939            state: AssetState::Promoted,
940        };
941        store
942            .append_event(EvolutionEvent::MutationDeclared { mutation })
943            .unwrap();
944        store
945            .append_event(EvolutionEvent::GeneProjected { gene })
946            .unwrap();
947        store
948            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
949            .unwrap();
950
951        let projection = store.rebuild_projection().unwrap();
952        let spec_ids = projection.spec_ids_by_gene.get("gene-spec").unwrap();
953        assert!(spec_ids.contains("spec-repair-1"));
954    }
955
956    #[test]
957    fn selector_orders_results_stably() {
958        let projection = EvolutionProjection {
959            genes: vec![
960                Gene {
961                    id: "gene-a".into(),
962                    signals: vec!["signal".into()],
963                    strategy: vec!["a".into()],
964                    validation: vec!["oris-default".into()],
965                    state: AssetState::Promoted,
966                },
967                Gene {
968                    id: "gene-b".into(),
969                    signals: vec!["signal".into()],
970                    strategy: vec!["b".into()],
971                    validation: vec!["oris-default".into()],
972                    state: AssetState::Promoted,
973                },
974            ],
975            capsules: vec![
976                Capsule {
977                    id: "capsule-a".into(),
978                    gene_id: "gene-a".into(),
979                    mutation_id: "m1".into(),
980                    run_id: "r1".into(),
981                    diff_hash: "1".into(),
982                    confidence: 0.7,
983                    env: EnvFingerprint {
984                        rustc_version: "rustc".into(),
985                        cargo_lock_hash: "lock".into(),
986                        target_triple: "x86_64-unknown-linux-gnu".into(),
987                        os: "linux".into(),
988                    },
989                    outcome: Outcome {
990                        success: true,
991                        validation_profile: "oris-default".into(),
992                        validation_duration_ms: 1,
993                        changed_files: vec!["crates/oris-kernel".into()],
994                        validator_hash: "v".into(),
995                        lines_changed: 1,
996                        replay_verified: false,
997                    },
998                    state: AssetState::Promoted,
999                },
1000                Capsule {
1001                    id: "capsule-b".into(),
1002                    gene_id: "gene-b".into(),
1003                    mutation_id: "m2".into(),
1004                    run_id: "r2".into(),
1005                    diff_hash: "2".into(),
1006                    confidence: 0.7,
1007                    env: EnvFingerprint {
1008                        rustc_version: "rustc".into(),
1009                        cargo_lock_hash: "lock".into(),
1010                        target_triple: "x86_64-unknown-linux-gnu".into(),
1011                        os: "linux".into(),
1012                    },
1013                    outcome: Outcome {
1014                        success: true,
1015                        validation_profile: "oris-default".into(),
1016                        validation_duration_ms: 1,
1017                        changed_files: vec!["crates/oris-kernel".into()],
1018                        validator_hash: "v".into(),
1019                        lines_changed: 1,
1020                        replay_verified: false,
1021                    },
1022                    state: AssetState::Promoted,
1023                },
1024            ],
1025            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1026            attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1027            last_updated_at: BTreeMap::from([
1028                ("gene-a".into(), Utc::now().to_rfc3339()),
1029                ("gene-b".into(), Utc::now().to_rfc3339()),
1030            ]),
1031            spec_ids_by_gene: BTreeMap::new(),
1032        };
1033        let selector = ProjectionSelector::new(projection);
1034        let input = SelectorInput {
1035            signals: vec!["signal".into()],
1036            env: EnvFingerprint {
1037                rustc_version: "rustc".into(),
1038                cargo_lock_hash: "lock".into(),
1039                target_triple: "x86_64-unknown-linux-gnu".into(),
1040                os: "linux".into(),
1041            },
1042            spec_id: None,
1043            limit: 2,
1044        };
1045        let first = selector.select(&input);
1046        let second = selector.select(&input);
1047        assert_eq!(first.len(), 2);
1048        assert_eq!(
1049            first
1050                .iter()
1051                .map(|candidate| candidate.gene.id.clone())
1052                .collect::<Vec<_>>(),
1053            second
1054                .iter()
1055                .map(|candidate| candidate.gene.id.clone())
1056                .collect::<Vec<_>>()
1057        );
1058    }
1059
1060    #[test]
1061    fn selector_can_narrow_by_spec_id() {
1062        let projection = EvolutionProjection {
1063            genes: vec![
1064                Gene {
1065                    id: "gene-a".into(),
1066                    signals: vec!["signal".into()],
1067                    strategy: vec!["a".into()],
1068                    validation: vec!["oris-default".into()],
1069                    state: AssetState::Promoted,
1070                },
1071                Gene {
1072                    id: "gene-b".into(),
1073                    signals: vec!["signal".into()],
1074                    strategy: vec!["b".into()],
1075                    validation: vec!["oris-default".into()],
1076                    state: AssetState::Promoted,
1077                },
1078            ],
1079            capsules: vec![
1080                Capsule {
1081                    id: "capsule-a".into(),
1082                    gene_id: "gene-a".into(),
1083                    mutation_id: "m1".into(),
1084                    run_id: "r1".into(),
1085                    diff_hash: "1".into(),
1086                    confidence: 0.7,
1087                    env: EnvFingerprint {
1088                        rustc_version: "rustc".into(),
1089                        cargo_lock_hash: "lock".into(),
1090                        target_triple: "x86_64-unknown-linux-gnu".into(),
1091                        os: "linux".into(),
1092                    },
1093                    outcome: Outcome {
1094                        success: true,
1095                        validation_profile: "oris-default".into(),
1096                        validation_duration_ms: 1,
1097                        changed_files: vec!["crates/oris-kernel".into()],
1098                        validator_hash: "v".into(),
1099                        lines_changed: 1,
1100                        replay_verified: false,
1101                    },
1102                    state: AssetState::Promoted,
1103                },
1104                Capsule {
1105                    id: "capsule-b".into(),
1106                    gene_id: "gene-b".into(),
1107                    mutation_id: "m2".into(),
1108                    run_id: "r2".into(),
1109                    diff_hash: "2".into(),
1110                    confidence: 0.7,
1111                    env: EnvFingerprint {
1112                        rustc_version: "rustc".into(),
1113                        cargo_lock_hash: "lock".into(),
1114                        target_triple: "x86_64-unknown-linux-gnu".into(),
1115                        os: "linux".into(),
1116                    },
1117                    outcome: Outcome {
1118                        success: true,
1119                        validation_profile: "oris-default".into(),
1120                        validation_duration_ms: 1,
1121                        changed_files: vec!["crates/oris-kernel".into()],
1122                        validator_hash: "v".into(),
1123                        lines_changed: 1,
1124                        replay_verified: false,
1125                    },
1126                    state: AssetState::Promoted,
1127                },
1128            ],
1129            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1130            attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1131            last_updated_at: BTreeMap::from([
1132                ("gene-a".into(), Utc::now().to_rfc3339()),
1133                ("gene-b".into(), Utc::now().to_rfc3339()),
1134            ]),
1135            spec_ids_by_gene: BTreeMap::from([
1136                ("gene-a".into(), BTreeSet::from(["spec-a".to_string()])),
1137                ("gene-b".into(), BTreeSet::from(["spec-b".to_string()])),
1138            ]),
1139        };
1140        let selector = ProjectionSelector::new(projection);
1141        let input = SelectorInput {
1142            signals: vec!["signal".into()],
1143            env: EnvFingerprint {
1144                rustc_version: "rustc".into(),
1145                cargo_lock_hash: "lock".into(),
1146                target_triple: "x86_64-unknown-linux-gnu".into(),
1147                os: "linux".into(),
1148            },
1149            spec_id: Some("spec-b".into()),
1150            limit: 2,
1151        };
1152        let selected = selector.select(&input);
1153        assert_eq!(selected.len(), 1);
1154        assert_eq!(selected[0].gene.id, "gene-b");
1155    }
1156
1157    #[test]
1158    fn selector_prefers_closest_environment_match() {
1159        let projection = EvolutionProjection {
1160            genes: vec![
1161                Gene {
1162                    id: "gene-a".into(),
1163                    signals: vec!["signal".into()],
1164                    strategy: vec!["a".into()],
1165                    validation: vec!["oris-default".into()],
1166                    state: AssetState::Promoted,
1167                },
1168                Gene {
1169                    id: "gene-b".into(),
1170                    signals: vec!["signal".into()],
1171                    strategy: vec!["b".into()],
1172                    validation: vec!["oris-default".into()],
1173                    state: AssetState::Promoted,
1174                },
1175            ],
1176            capsules: vec![
1177                Capsule {
1178                    id: "capsule-a-stale".into(),
1179                    gene_id: "gene-a".into(),
1180                    mutation_id: "m1".into(),
1181                    run_id: "r1".into(),
1182                    diff_hash: "1".into(),
1183                    confidence: 0.2,
1184                    env: EnvFingerprint {
1185                        rustc_version: "old-rustc".into(),
1186                        cargo_lock_hash: "other-lock".into(),
1187                        target_triple: "aarch64-apple-darwin".into(),
1188                        os: "macos".into(),
1189                    },
1190                    outcome: Outcome {
1191                        success: true,
1192                        validation_profile: "oris-default".into(),
1193                        validation_duration_ms: 1,
1194                        changed_files: vec!["crates/oris-kernel".into()],
1195                        validator_hash: "v".into(),
1196                        lines_changed: 1,
1197                        replay_verified: false,
1198                    },
1199                    state: AssetState::Promoted,
1200                },
1201                Capsule {
1202                    id: "capsule-a-best".into(),
1203                    gene_id: "gene-a".into(),
1204                    mutation_id: "m2".into(),
1205                    run_id: "r2".into(),
1206                    diff_hash: "2".into(),
1207                    confidence: 0.9,
1208                    env: EnvFingerprint {
1209                        rustc_version: "rustc".into(),
1210                        cargo_lock_hash: "lock".into(),
1211                        target_triple: "x86_64-unknown-linux-gnu".into(),
1212                        os: "linux".into(),
1213                    },
1214                    outcome: Outcome {
1215                        success: true,
1216                        validation_profile: "oris-default".into(),
1217                        validation_duration_ms: 1,
1218                        changed_files: vec!["crates/oris-kernel".into()],
1219                        validator_hash: "v".into(),
1220                        lines_changed: 1,
1221                        replay_verified: false,
1222                    },
1223                    state: AssetState::Promoted,
1224                },
1225                Capsule {
1226                    id: "capsule-b".into(),
1227                    gene_id: "gene-b".into(),
1228                    mutation_id: "m3".into(),
1229                    run_id: "r3".into(),
1230                    diff_hash: "3".into(),
1231                    confidence: 0.7,
1232                    env: EnvFingerprint {
1233                        rustc_version: "rustc".into(),
1234                        cargo_lock_hash: "different-lock".into(),
1235                        target_triple: "x86_64-unknown-linux-gnu".into(),
1236                        os: "linux".into(),
1237                    },
1238                    outcome: Outcome {
1239                        success: true,
1240                        validation_profile: "oris-default".into(),
1241                        validation_duration_ms: 1,
1242                        changed_files: vec!["crates/oris-kernel".into()],
1243                        validator_hash: "v".into(),
1244                        lines_changed: 1,
1245                        replay_verified: false,
1246                    },
1247                    state: AssetState::Promoted,
1248                },
1249            ],
1250            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1251            attempt_counts: BTreeMap::from([("gene-a".into(), 2), ("gene-b".into(), 1)]),
1252            last_updated_at: BTreeMap::from([
1253                ("gene-a".into(), Utc::now().to_rfc3339()),
1254                ("gene-b".into(), Utc::now().to_rfc3339()),
1255            ]),
1256            spec_ids_by_gene: BTreeMap::new(),
1257        };
1258        let selector = ProjectionSelector::new(projection);
1259        let input = SelectorInput {
1260            signals: vec!["signal".into()],
1261            env: EnvFingerprint {
1262                rustc_version: "rustc".into(),
1263                cargo_lock_hash: "lock".into(),
1264                target_triple: "x86_64-unknown-linux-gnu".into(),
1265                os: "linux".into(),
1266            },
1267            spec_id: None,
1268            limit: 2,
1269        };
1270
1271        let selected = selector.select(&input);
1272
1273        assert_eq!(selected.len(), 2);
1274        assert_eq!(selected[0].gene.id, "gene-a");
1275        assert_eq!(selected[0].capsules[0].id, "capsule-a-best");
1276        assert!(selected[0].score > selected[1].score);
1277    }
1278
1279    #[test]
1280    fn legacy_capsule_reused_events_deserialize_without_replay_run_id() {
1281        let serialized = r#"{
1282  "seq": 1,
1283  "timestamp": "2026-03-04T00:00:00Z",
1284  "prev_hash": "",
1285  "record_hash": "hash",
1286  "event": {
1287    "kind": "capsule_reused",
1288    "capsule_id": "capsule-1",
1289    "gene_id": "gene-1",
1290    "run_id": "run-1"
1291  }
1292}"#;
1293
1294        let stored = serde_json::from_str::<StoredEvolutionEvent>(serialized).unwrap();
1295
1296        match stored.event {
1297            EvolutionEvent::CapsuleReused {
1298                capsule_id,
1299                gene_id,
1300                run_id,
1301                replay_run_id,
1302            } => {
1303                assert_eq!(capsule_id, "capsule-1");
1304                assert_eq!(gene_id, "gene-1");
1305                assert_eq!(run_id, "run-1");
1306                assert_eq!(replay_run_id, None);
1307            }
1308            other => panic!("unexpected event: {other:?}"),
1309        }
1310    }
1311}