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