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