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}
227
228#[derive(Clone, Debug)]
229pub struct SelectorInput {
230    pub signals: Vec<String>,
231    pub env: EnvFingerprint,
232    pub limit: usize,
233}
234
235#[derive(Clone, Debug)]
236pub struct GeneCandidate {
237    pub gene: Gene,
238    pub score: f32,
239    pub capsules: Vec<Capsule>,
240}
241
242pub trait Selector: Send + Sync {
243    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate>;
244}
245
246pub trait EvolutionStore: Send + Sync {
247    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError>;
248    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError>;
249    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError>;
250}
251
252#[derive(Debug, Error)]
253pub enum EvolutionError {
254    #[error("I/O error: {0}")]
255    Io(String),
256    #[error("Serialization error: {0}")]
257    Serde(String),
258    #[error("Hash chain validation failed: {0}")]
259    HashChain(String),
260}
261
262pub struct JsonlEvolutionStore {
263    root_dir: PathBuf,
264    lock: Mutex<()>,
265}
266
267impl JsonlEvolutionStore {
268    pub fn new<P: Into<PathBuf>>(root_dir: P) -> Self {
269        Self {
270            root_dir: root_dir.into(),
271            lock: Mutex::new(()),
272        }
273    }
274
275    pub fn root_dir(&self) -> &Path {
276        &self.root_dir
277    }
278
279    fn ensure_layout(&self) -> Result<(), EvolutionError> {
280        fs::create_dir_all(&self.root_dir).map_err(io_err)?;
281        let lock_path = self.root_dir.join("LOCK");
282        if !lock_path.exists() {
283            File::create(lock_path).map_err(io_err)?;
284        }
285        let events_path = self.events_path();
286        if !events_path.exists() {
287            File::create(events_path).map_err(io_err)?;
288        }
289        Ok(())
290    }
291
292    fn events_path(&self) -> PathBuf {
293        self.root_dir.join("events.jsonl")
294    }
295
296    fn genes_path(&self) -> PathBuf {
297        self.root_dir.join("genes.json")
298    }
299
300    fn capsules_path(&self) -> PathBuf {
301        self.root_dir.join("capsules.json")
302    }
303
304    fn read_all_events(&self) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
305        self.ensure_layout()?;
306        let file = File::open(self.events_path()).map_err(io_err)?;
307        let reader = BufReader::new(file);
308        let mut events = Vec::new();
309        for line in reader.lines() {
310            let line = line.map_err(io_err)?;
311            if line.trim().is_empty() {
312                continue;
313            }
314            let event = serde_json::from_str::<StoredEvolutionEvent>(&line)
315                .map_err(|err| EvolutionError::Serde(err.to_string()))?;
316            events.push(event);
317        }
318        verify_hash_chain(&events)?;
319        Ok(events)
320    }
321
322    fn write_projection_files(
323        &self,
324        projection: &EvolutionProjection,
325    ) -> Result<(), EvolutionError> {
326        write_json_atomic(&self.genes_path(), &projection.genes)?;
327        write_json_atomic(&self.capsules_path(), &projection.capsules)?;
328        Ok(())
329    }
330
331    fn append_event_locked(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
332        let existing = self.read_all_events()?;
333        let next_seq = existing.last().map(|entry| entry.seq + 1).unwrap_or(1);
334        let prev_hash = existing
335            .last()
336            .map(|entry| entry.record_hash.clone())
337            .unwrap_or_default();
338        let timestamp = Utc::now().to_rfc3339();
339        let record_hash = hash_record(next_seq, &timestamp, &prev_hash, &event)?;
340        let stored = StoredEvolutionEvent {
341            seq: next_seq,
342            timestamp,
343            prev_hash,
344            record_hash,
345            event,
346        };
347        let mut file = OpenOptions::new()
348            .create(true)
349            .append(true)
350            .open(self.events_path())
351            .map_err(io_err)?;
352        let line =
353            serde_json::to_string(&stored).map_err(|err| EvolutionError::Serde(err.to_string()))?;
354        file.write_all(line.as_bytes()).map_err(io_err)?;
355        file.write_all(b"\n").map_err(io_err)?;
356        file.sync_data().map_err(io_err)?;
357
358        let events = self.read_all_events()?;
359        let projection = rebuild_projection_from_events(&events);
360        self.write_projection_files(&projection)?;
361        Ok(next_seq)
362    }
363}
364
365impl EvolutionStore for JsonlEvolutionStore {
366    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
367        let _guard = self
368            .lock
369            .lock()
370            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
371        self.append_event_locked(event)
372    }
373
374    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
375        let _guard = self
376            .lock
377            .lock()
378            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
379        Ok(self
380            .read_all_events()?
381            .into_iter()
382            .filter(|entry| entry.seq >= from_seq)
383            .collect())
384    }
385
386    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
387        let _guard = self
388            .lock
389            .lock()
390            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
391        let projection = rebuild_projection_from_events(&self.read_all_events()?);
392        self.write_projection_files(&projection)?;
393        Ok(projection)
394    }
395}
396
397pub struct ProjectionSelector {
398    projection: EvolutionProjection,
399    now: DateTime<Utc>,
400}
401
402impl ProjectionSelector {
403    pub fn new(projection: EvolutionProjection) -> Self {
404        Self {
405            projection,
406            now: Utc::now(),
407        }
408    }
409
410    pub fn with_now(projection: EvolutionProjection, now: DateTime<Utc>) -> Self {
411        Self { projection, now }
412    }
413}
414
415impl Selector for ProjectionSelector {
416    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
417        let mut out = Vec::new();
418        for gene in &self.projection.genes {
419            if gene.state != AssetState::Promoted {
420                continue;
421            }
422            let capsules = self
423                .projection
424                .capsules
425                .iter()
426                .filter(|capsule| {
427                    capsule.gene_id == gene.id && capsule.state == AssetState::Promoted
428                })
429                .cloned()
430                .collect::<Vec<_>>();
431            if capsules.is_empty() {
432                continue;
433            }
434
435            let successful_capsules = capsules.len() as f64;
436            let attempts = self
437                .projection
438                .attempt_counts
439                .get(&gene.id)
440                .copied()
441                .unwrap_or(capsules.len() as u64) as f64;
442            let success_rate = if attempts == 0.0 {
443                0.0
444            } else {
445                successful_capsules / attempts
446            };
447            let successful_reuses = self
448                .projection
449                .reuse_counts
450                .get(&gene.id)
451                .copied()
452                .unwrap_or(0) as f64;
453            let reuse_count_factor = 1.0 + (1.0 + successful_reuses).ln();
454            let env_fingerprints = capsules
455                .iter()
456                .map(|capsule| fingerprint_key(&capsule.env))
457                .collect::<BTreeSet<_>>()
458                .len() as f64;
459            let env_diversity = (env_fingerprints / 5.0).min(1.0);
460            let signal_overlap = normalized_signal_overlap(&gene.signals, &input.signals);
461            let recency_decay = self
462                .projection
463                .last_updated_at
464                .get(&gene.id)
465                .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
466                .map(|dt| {
467                    let age_days = (self.now - dt.with_timezone(&Utc)).num_days().max(0) as f64;
468                    E.powf(-age_days / 30.0)
469                })
470                .unwrap_or(0.0);
471            let score = (success_rate
472                * reuse_count_factor
473                * env_diversity
474                * recency_decay
475                * signal_overlap) as f32;
476            if score < 0.35 {
477                continue;
478            }
479            out.push(GeneCandidate {
480                gene: gene.clone(),
481                score,
482                capsules,
483            });
484        }
485
486        out.sort_by(|left, right| {
487            right
488                .score
489                .partial_cmp(&left.score)
490                .unwrap_or(std::cmp::Ordering::Equal)
491                .then_with(|| left.gene.id.cmp(&right.gene.id))
492        });
493        out.truncate(input.limit.max(1));
494        out
495    }
496}
497
498pub struct StoreBackedSelector {
499    store: std::sync::Arc<dyn EvolutionStore>,
500}
501
502impl StoreBackedSelector {
503    pub fn new(store: std::sync::Arc<dyn EvolutionStore>) -> Self {
504        Self { store }
505    }
506}
507
508impl Selector for StoreBackedSelector {
509    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
510        match self.store.rebuild_projection() {
511            Ok(projection) => ProjectionSelector::new(projection).select(input),
512            Err(_) => Vec::new(),
513        }
514    }
515}
516
517pub fn rebuild_projection_from_events(events: &[StoredEvolutionEvent]) -> EvolutionProjection {
518    let mut genes = BTreeMap::<GeneId, Gene>::new();
519    let mut capsules = BTreeMap::<CapsuleId, Capsule>::new();
520    let mut reuse_counts = BTreeMap::<GeneId, u64>::new();
521    let mut attempt_counts = BTreeMap::<GeneId, u64>::new();
522    let mut last_updated_at = BTreeMap::<GeneId, String>::new();
523    let mut mutation_to_gene = HashMap::<MutationId, GeneId>::new();
524
525    for stored in events {
526        match &stored.event {
527            EvolutionEvent::GeneProjected { gene } => {
528                genes.insert(gene.id.clone(), gene.clone());
529                last_updated_at.insert(gene.id.clone(), stored.timestamp.clone());
530            }
531            EvolutionEvent::GenePromoted { gene_id } => {
532                if let Some(gene) = genes.get_mut(gene_id) {
533                    gene.state = AssetState::Promoted;
534                }
535                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
536            }
537            EvolutionEvent::GeneRevoked { gene_id, .. } => {
538                if let Some(gene) = genes.get_mut(gene_id) {
539                    gene.state = AssetState::Revoked;
540                }
541                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
542            }
543            EvolutionEvent::GeneArchived { gene_id } => {
544                if let Some(gene) = genes.get_mut(gene_id) {
545                    gene.state = AssetState::Archived;
546                }
547                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
548            }
549            EvolutionEvent::PromotionEvaluated { gene_id, state, .. } => {
550                if let Some(gene) = genes.get_mut(gene_id) {
551                    gene.state = state.clone();
552                }
553                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
554            }
555            EvolutionEvent::CapsuleCommitted { capsule } => {
556                mutation_to_gene.insert(capsule.mutation_id.clone(), capsule.gene_id.clone());
557                capsules.insert(capsule.id.clone(), capsule.clone());
558                *attempt_counts.entry(capsule.gene_id.clone()).or_insert(0) += 1;
559                last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
560            }
561            EvolutionEvent::CapsuleQuarantined { capsule_id } => {
562                if let Some(capsule) = capsules.get_mut(capsule_id) {
563                    capsule.state = AssetState::Quarantined;
564                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
565                }
566            }
567            EvolutionEvent::CapsuleReleased { capsule_id, state } => {
568                if let Some(capsule) = capsules.get_mut(capsule_id) {
569                    capsule.state = state.clone();
570                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
571                }
572            }
573            EvolutionEvent::CapsuleReused { gene_id, .. } => {
574                *reuse_counts.entry(gene_id.clone()).or_insert(0) += 1;
575                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
576            }
577            EvolutionEvent::ValidationFailed {
578                mutation_id,
579                gene_id,
580                ..
581            } => {
582                let id = gene_id
583                    .clone()
584                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
585                if let Some(gene_id) = id {
586                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
587                    last_updated_at.insert(gene_id, stored.timestamp.clone());
588                }
589            }
590            EvolutionEvent::ValidationPassed {
591                mutation_id,
592                gene_id,
593                ..
594            } => {
595                let id = gene_id
596                    .clone()
597                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
598                if let Some(gene_id) = id {
599                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
600                    last_updated_at.insert(gene_id, stored.timestamp.clone());
601                }
602            }
603            _ => {}
604        }
605    }
606
607    EvolutionProjection {
608        genes: genes.into_values().collect(),
609        capsules: capsules.into_values().collect(),
610        reuse_counts,
611        attempt_counts,
612        last_updated_at,
613    }
614}
615
616pub fn default_store_root() -> PathBuf {
617    PathBuf::from(".oris").join("evolution")
618}
619
620pub fn hash_string(input: &str) -> String {
621    let mut hasher = Sha256::new();
622    hasher.update(input.as_bytes());
623    hex::encode(hasher.finalize())
624}
625
626pub fn stable_hash_json<T: Serialize>(value: &T) -> Result<String, EvolutionError> {
627    let bytes = serde_json::to_vec(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
628    let mut hasher = Sha256::new();
629    hasher.update(bytes);
630    Ok(hex::encode(hasher.finalize()))
631}
632
633pub fn compute_artifact_hash(payload: &str) -> String {
634    hash_string(payload)
635}
636
637pub fn next_id(prefix: &str) -> String {
638    let nanos = SystemTime::now()
639        .duration_since(UNIX_EPOCH)
640        .unwrap_or_default()
641        .as_nanos();
642    format!("{prefix}-{nanos:x}")
643}
644
645fn normalized_signal_overlap(gene_signals: &[String], input_signals: &[String]) -> f64 {
646    if input_signals.is_empty() {
647        return 0.0;
648    }
649    let gene = gene_signals
650        .iter()
651        .map(|signal| signal.to_ascii_lowercase())
652        .collect::<BTreeSet<_>>();
653    let input = input_signals
654        .iter()
655        .map(|signal| signal.to_ascii_lowercase())
656        .collect::<BTreeSet<_>>();
657    let matched = input.iter().filter(|signal| gene.contains(*signal)).count() as f64;
658    matched / input.len() as f64
659}
660
661fn fingerprint_key(env: &EnvFingerprint) -> String {
662    format!(
663        "{}|{}|{}|{}",
664        env.rustc_version, env.cargo_lock_hash, env.target_triple, env.os
665    )
666}
667
668fn hash_record(
669    seq: u64,
670    timestamp: &str,
671    prev_hash: &str,
672    event: &EvolutionEvent,
673) -> Result<String, EvolutionError> {
674    stable_hash_json(&(seq, timestamp, prev_hash, event))
675}
676
677fn verify_hash_chain(events: &[StoredEvolutionEvent]) -> Result<(), EvolutionError> {
678    let mut previous_hash = String::new();
679    let mut expected_seq = 1u64;
680    for event in events {
681        if event.seq != expected_seq {
682            return Err(EvolutionError::HashChain(format!(
683                "expected seq {}, found {}",
684                expected_seq, event.seq
685            )));
686        }
687        if event.prev_hash != previous_hash {
688            return Err(EvolutionError::HashChain(format!(
689                "event {} prev_hash mismatch",
690                event.seq
691            )));
692        }
693        let actual_hash = hash_record(event.seq, &event.timestamp, &event.prev_hash, &event.event)?;
694        if actual_hash != event.record_hash {
695            return Err(EvolutionError::HashChain(format!(
696                "event {} record_hash mismatch",
697                event.seq
698            )));
699        }
700        previous_hash = event.record_hash.clone();
701        expected_seq += 1;
702    }
703    Ok(())
704}
705
706fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), EvolutionError> {
707    let tmp_path = path.with_extension("tmp");
708    let bytes =
709        serde_json::to_vec_pretty(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
710    fs::write(&tmp_path, bytes).map_err(io_err)?;
711    fs::rename(&tmp_path, path).map_err(io_err)?;
712    Ok(())
713}
714
715fn io_err(err: std::io::Error) -> EvolutionError {
716    EvolutionError::Io(err.to_string())
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    fn temp_root(name: &str) -> PathBuf {
724        std::env::temp_dir().join(format!("oris-evolution-{name}-{}", next_id("t")))
725    }
726
727    fn sample_mutation() -> PreparedMutation {
728        PreparedMutation {
729            intent: MutationIntent {
730                id: "mutation-1".into(),
731                intent: "tighten borrow scope".into(),
732                target: MutationTarget::Paths {
733                    allow: vec!["crates/oris-kernel".into()],
734                },
735                expected_effect: "cargo check passes".into(),
736                risk: RiskLevel::Low,
737                signals: vec!["rust borrow error".into()],
738                spec_id: None,
739            },
740            artifact: MutationArtifact {
741                encoding: ArtifactEncoding::UnifiedDiff,
742                payload: "diff --git a/foo b/foo".into(),
743                base_revision: Some("HEAD".into()),
744                content_hash: compute_artifact_hash("diff --git a/foo b/foo"),
745            },
746        }
747    }
748
749    #[test]
750    fn append_event_assigns_monotonic_seq() {
751        let root = temp_root("seq");
752        let store = JsonlEvolutionStore::new(root);
753        let first = store
754            .append_event(EvolutionEvent::MutationDeclared {
755                mutation: sample_mutation(),
756            })
757            .unwrap();
758        let second = store
759            .append_event(EvolutionEvent::MutationRejected {
760                mutation_id: "mutation-1".into(),
761                reason: "no-op".into(),
762            })
763            .unwrap();
764        assert_eq!(first, 1);
765        assert_eq!(second, 2);
766    }
767
768    #[test]
769    fn tampered_hash_chain_is_rejected() {
770        let root = temp_root("tamper");
771        let store = JsonlEvolutionStore::new(&root);
772        store
773            .append_event(EvolutionEvent::MutationDeclared {
774                mutation: sample_mutation(),
775            })
776            .unwrap();
777        let path = root.join("events.jsonl");
778        let contents = fs::read_to_string(&path).unwrap();
779        let mutated = contents.replace("tighten borrow scope", "tampered");
780        fs::write(&path, mutated).unwrap();
781        let result = store.scan(1);
782        assert!(matches!(result, Err(EvolutionError::HashChain(_))));
783    }
784
785    #[test]
786    fn rebuild_projection_after_cache_deletion() {
787        let root = temp_root("projection");
788        let store = JsonlEvolutionStore::new(&root);
789        let gene = Gene {
790            id: "gene-1".into(),
791            signals: vec!["rust borrow error".into()],
792            strategy: vec!["crates".into()],
793            validation: vec!["oris-default".into()],
794            state: AssetState::Promoted,
795        };
796        let capsule = Capsule {
797            id: "capsule-1".into(),
798            gene_id: gene.id.clone(),
799            mutation_id: "mutation-1".into(),
800            run_id: "run-1".into(),
801            diff_hash: "abc".into(),
802            confidence: 0.7,
803            env: EnvFingerprint {
804                rustc_version: "rustc 1.80".into(),
805                cargo_lock_hash: "lock".into(),
806                target_triple: "x86_64-unknown-linux-gnu".into(),
807                os: "linux".into(),
808            },
809            outcome: Outcome {
810                success: true,
811                validation_profile: "oris-default".into(),
812                validation_duration_ms: 100,
813                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
814                validator_hash: "vh".into(),
815                lines_changed: 1,
816                replay_verified: false,
817            },
818            state: AssetState::Promoted,
819        };
820        store
821            .append_event(EvolutionEvent::GeneProjected { gene })
822            .unwrap();
823        store
824            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
825            .unwrap();
826        fs::remove_file(root.join("genes.json")).unwrap();
827        fs::remove_file(root.join("capsules.json")).unwrap();
828        let projection = store.rebuild_projection().unwrap();
829        assert_eq!(projection.genes.len(), 1);
830        assert_eq!(projection.capsules.len(), 1);
831    }
832
833    #[test]
834    fn selector_orders_results_stably() {
835        let projection = EvolutionProjection {
836            genes: vec![
837                Gene {
838                    id: "gene-a".into(),
839                    signals: vec!["signal".into()],
840                    strategy: vec!["a".into()],
841                    validation: vec!["oris-default".into()],
842                    state: AssetState::Promoted,
843                },
844                Gene {
845                    id: "gene-b".into(),
846                    signals: vec!["signal".into()],
847                    strategy: vec!["b".into()],
848                    validation: vec!["oris-default".into()],
849                    state: AssetState::Promoted,
850                },
851            ],
852            capsules: vec![
853                Capsule {
854                    id: "capsule-a".into(),
855                    gene_id: "gene-a".into(),
856                    mutation_id: "m1".into(),
857                    run_id: "r1".into(),
858                    diff_hash: "1".into(),
859                    confidence: 0.7,
860                    env: EnvFingerprint {
861                        rustc_version: "rustc".into(),
862                        cargo_lock_hash: "lock".into(),
863                        target_triple: "x86_64-unknown-linux-gnu".into(),
864                        os: "linux".into(),
865                    },
866                    outcome: Outcome {
867                        success: true,
868                        validation_profile: "oris-default".into(),
869                        validation_duration_ms: 1,
870                        changed_files: vec!["crates/oris-kernel".into()],
871                        validator_hash: "v".into(),
872                        lines_changed: 1,
873                        replay_verified: false,
874                    },
875                    state: AssetState::Promoted,
876                },
877                Capsule {
878                    id: "capsule-b".into(),
879                    gene_id: "gene-b".into(),
880                    mutation_id: "m2".into(),
881                    run_id: "r2".into(),
882                    diff_hash: "2".into(),
883                    confidence: 0.7,
884                    env: EnvFingerprint {
885                        rustc_version: "rustc".into(),
886                        cargo_lock_hash: "lock".into(),
887                        target_triple: "x86_64-unknown-linux-gnu".into(),
888                        os: "linux".into(),
889                    },
890                    outcome: Outcome {
891                        success: true,
892                        validation_profile: "oris-default".into(),
893                        validation_duration_ms: 1,
894                        changed_files: vec!["crates/oris-kernel".into()],
895                        validator_hash: "v".into(),
896                        lines_changed: 1,
897                        replay_verified: false,
898                    },
899                    state: AssetState::Promoted,
900                },
901            ],
902            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
903            attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
904            last_updated_at: BTreeMap::from([
905                ("gene-a".into(), Utc::now().to_rfc3339()),
906                ("gene-b".into(), Utc::now().to_rfc3339()),
907            ]),
908        };
909        let selector = ProjectionSelector::new(projection);
910        let input = SelectorInput {
911            signals: vec!["signal".into()],
912            env: EnvFingerprint {
913                rustc_version: "rustc".into(),
914                cargo_lock_hash: "lock".into(),
915                target_triple: "x86_64-unknown-linux-gnu".into(),
916                os: "linux".into(),
917            },
918            limit: 2,
919        };
920        let first = selector.select(&input);
921        let second = selector.select(&input);
922        assert_eq!(first.len(), 2);
923        assert_eq!(
924            first
925                .iter()
926                .map(|candidate| candidate.gene.id.clone())
927                .collect::<Vec<_>>(),
928            second
929                .iter()
930                .map(|candidate| candidate.gene.id.clone())
931                .collect::<Vec<_>>()
932        );
933    }
934}