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::fs::{self, File, OpenOptions};
5use std::io::{BufRead, BufReader, Write};
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use chrono::{DateTime, Duration, Utc};
11use oris_kernel::RunId;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15
16pub type MutationId = String;
17pub type GeneId = String;
18pub type CapsuleId = String;
19
20pub const REPLAY_CONFIDENCE_DECAY_RATE_PER_HOUR: f32 = 0.05;
21pub const MIN_REPLAY_CONFIDENCE: f32 = 0.35;
22
23#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
24pub enum AssetState {
25    Candidate,
26    #[default]
27    Promoted,
28    Revoked,
29    Archived,
30    Quarantined,
31    ShadowValidated,
32}
33
34/// Convert Oris AssetState to EvoMap-compatible state string.
35/// This mapping preserves the EvoMap terminology without modifying the core enum.
36pub fn asset_state_to_evomap_compat(state: &AssetState) -> &'static str {
37    match state {
38        AssetState::Candidate => "candidate",
39        AssetState::Promoted => "promoted",
40        AssetState::Revoked => "revoked",
41        AssetState::Archived => "rejected", // Archive maps to rejected in EvoMap terms
42        AssetState::Quarantined => "quarantined",
43        // EvoMap does not yet model shadow trust directly, so map it to candidate semantics.
44        AssetState::ShadowValidated => "candidate",
45    }
46}
47
48#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
49pub enum CandidateSource {
50    #[default]
51    Local,
52    Remote,
53}
54
55#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum TransitionReasonCode {
58    #[default]
59    Unspecified,
60    PromotionSuccessThreshold,
61    PromotionRemoteReplayValidated,
62    PromotionBuiltinColdStartCompatibility,
63    PromotionTrustedLocalReport,
64    RevalidationConfidenceDecay,
65    DowngradeReplayRegression,
66    DowngradeConfidenceRegression,
67    DowngradeRemoteRequiresLocalValidation,
68    DowngradeBootstrapRequiresLocalValidation,
69    DowngradeBuiltinRequiresValidation,
70    CandidateRateLimited,
71    CandidateCoolingWindow,
72    CandidateBlastRadiusExceeded,
73    CandidateCollectingEvidence,
74    PromotionShadowValidationPassed,
75    PromotionShadowThresholdPassed,
76    ShadowCollectingReplayEvidence,
77}
78
79#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81pub enum ReplayRoiReasonCode {
82    #[default]
83    Unspecified,
84    ReplayHit,
85    ReplayMissNoMatchingGene,
86    ReplayMissScoreBelowThreshold,
87    ReplayMissCandidateHasNoCapsule,
88    ReplayMissMutationPayloadMissing,
89    ReplayMissPatchApplyFailed,
90    ReplayMissValidationFailed,
91}
92
93#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
94pub struct TransitionEvidence {
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub replay_attempts: Option<u64>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub replay_successes: Option<u64>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub replay_success_rate: Option<f32>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub environment_match_factor: Option<f32>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub decayed_confidence: Option<f32>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub confidence_decay_ratio: Option<f32>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub summary: Option<String>,
109}
110
111#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
112pub struct ReplayRoiEvidence {
113    pub success: bool,
114    #[serde(default)]
115    pub reason_code: ReplayRoiReasonCode,
116    pub task_class_id: String,
117    pub task_label: String,
118    pub reasoning_avoided_tokens: u64,
119    pub replay_fallback_cost: u64,
120    pub replay_roi: f64,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub asset_origin: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub source_sender_id: Option<String>,
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub context_dimensions: Vec<String>,
127}
128
129#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
130pub struct BlastRadius {
131    pub files_changed: usize,
132    pub lines_changed: usize,
133}
134
135#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
136pub enum RiskLevel {
137    Low,
138    Medium,
139    High,
140}
141
142#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
143pub enum ArtifactEncoding {
144    UnifiedDiff,
145}
146
147#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
148pub enum MutationTarget {
149    WorkspaceRoot,
150    Crate { name: String },
151    Paths { allow: Vec<String> },
152}
153
154#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
155pub struct MutationIntent {
156    pub id: MutationId,
157    pub intent: String,
158    pub target: MutationTarget,
159    pub expected_effect: String,
160    pub risk: RiskLevel,
161    pub signals: Vec<String>,
162    #[serde(default)]
163    pub spec_id: Option<String>,
164}
165
166#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
167pub struct MutationArtifact {
168    pub encoding: ArtifactEncoding,
169    pub payload: String,
170    pub base_revision: Option<String>,
171    pub content_hash: String,
172}
173
174#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
175pub struct PreparedMutation {
176    pub intent: MutationIntent,
177    pub artifact: MutationArtifact,
178}
179
180#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
181pub struct ValidationSnapshot {
182    pub success: bool,
183    pub profile: String,
184    pub duration_ms: u64,
185    pub summary: String,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
189pub struct Outcome {
190    pub success: bool,
191    pub validation_profile: String,
192    pub validation_duration_ms: u64,
193    pub changed_files: Vec<String>,
194    pub validator_hash: String,
195    #[serde(default)]
196    pub lines_changed: usize,
197    #[serde(default)]
198    pub replay_verified: bool,
199}
200
201#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
202pub struct EnvFingerprint {
203    pub rustc_version: String,
204    pub cargo_lock_hash: String,
205    pub target_triple: String,
206    pub os: String,
207}
208
209#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
210pub struct Capsule {
211    pub id: CapsuleId,
212    pub gene_id: GeneId,
213    pub mutation_id: MutationId,
214    pub run_id: RunId,
215    pub diff_hash: String,
216    pub confidence: f32,
217    pub env: EnvFingerprint,
218    pub outcome: Outcome,
219    #[serde(default)]
220    pub state: AssetState,
221}
222
223#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
224pub struct Gene {
225    pub id: GeneId,
226    pub signals: Vec<String>,
227    pub strategy: Vec<String>,
228    pub validation: Vec<String>,
229    #[serde(default)]
230    pub state: AssetState,
231    /// Optional task-class ID from `oris_evolution::task_class::TaskClass`.
232    ///
233    /// When set, the `TaskClassAwareSelector` can match this gene for any
234    /// incoming signal list that classifies to the same task-class, enabling
235    /// semantic-equivalent task reuse beyond exact signal matching.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub task_class_id: Option<String>,
238}
239
240#[derive(Clone, Debug, Serialize, Deserialize)]
241#[serde(tag = "kind", rename_all = "snake_case")]
242pub enum EvolutionEvent {
243    MutationDeclared {
244        mutation: PreparedMutation,
245    },
246    MutationApplied {
247        mutation_id: MutationId,
248        patch_hash: String,
249        changed_files: Vec<String>,
250    },
251    SignalsExtracted {
252        mutation_id: MutationId,
253        hash: String,
254        signals: Vec<String>,
255    },
256    MutationRejected {
257        mutation_id: MutationId,
258        reason: String,
259        #[serde(default, skip_serializing_if = "Option::is_none")]
260        reason_code: Option<String>,
261        #[serde(default, skip_serializing_if = "Option::is_none")]
262        recovery_hint: Option<String>,
263        #[serde(default)]
264        fail_closed: bool,
265    },
266    ValidationPassed {
267        mutation_id: MutationId,
268        report: ValidationSnapshot,
269        gene_id: Option<GeneId>,
270    },
271    ValidationFailed {
272        mutation_id: MutationId,
273        report: ValidationSnapshot,
274        gene_id: Option<GeneId>,
275    },
276    CapsuleCommitted {
277        capsule: Capsule,
278    },
279    CapsuleQuarantined {
280        capsule_id: CapsuleId,
281    },
282    CapsuleReleased {
283        capsule_id: CapsuleId,
284        state: AssetState,
285    },
286    CapsuleReused {
287        capsule_id: CapsuleId,
288        gene_id: GeneId,
289        run_id: RunId,
290        #[serde(default, skip_serializing_if = "Option::is_none")]
291        replay_run_id: Option<RunId>,
292    },
293    GeneProjected {
294        gene: Gene,
295    },
296    GenePromoted {
297        gene_id: GeneId,
298    },
299    GeneRevoked {
300        gene_id: GeneId,
301        reason: String,
302    },
303    GeneArchived {
304        gene_id: GeneId,
305    },
306    PromotionEvaluated {
307        gene_id: GeneId,
308        state: AssetState,
309        reason: String,
310        #[serde(default)]
311        reason_code: TransitionReasonCode,
312        #[serde(default, skip_serializing_if = "Option::is_none")]
313        evidence: Option<TransitionEvidence>,
314    },
315    ReplayEconomicsRecorded {
316        #[serde(default, skip_serializing_if = "Option::is_none")]
317        gene_id: Option<GeneId>,
318        #[serde(default, skip_serializing_if = "Option::is_none")]
319        capsule_id: Option<CapsuleId>,
320        #[serde(default, skip_serializing_if = "Option::is_none")]
321        replay_run_id: Option<RunId>,
322        evidence: ReplayRoiEvidence,
323    },
324    RemoteAssetImported {
325        source: CandidateSource,
326        asset_ids: Vec<String>,
327        #[serde(default, skip_serializing_if = "Option::is_none")]
328        sender_id: Option<String>,
329    },
330    ManifestValidated {
331        accepted: bool,
332        reason: String,
333        #[serde(default, skip_serializing_if = "Option::is_none")]
334        sender_id: Option<String>,
335        #[serde(default, skip_serializing_if = "Option::is_none")]
336        publisher: Option<String>,
337        #[serde(default)]
338        asset_ids: Vec<String>,
339    },
340    SpecLinked {
341        mutation_id: MutationId,
342        spec_id: String,
343    },
344    DeliveryPrepared {
345        task_id: String,
346        branch_name: String,
347        pr_title: String,
348        pr_summary: String,
349        delivery_summary: String,
350        delivery_status: String,
351        approval_state: String,
352        reason_code: String,
353    },
354    AcceptanceGateEvaluated {
355        task_id: String,
356        issue_number: u64,
357        acceptance_gate_summary: String,
358        audit_consistency_result: String,
359        approval_evidence: String,
360        delivery_outcome: String,
361        reason_code_matrix: String,
362        fail_closed: bool,
363        reason_code: String,
364    },
365}
366
367#[derive(Clone, Debug, Serialize, Deserialize)]
368pub struct StoredEvolutionEvent {
369    pub seq: u64,
370    pub timestamp: String,
371    pub prev_hash: String,
372    pub record_hash: String,
373    pub event: EvolutionEvent,
374}
375
376#[derive(Clone, Debug, Default, Serialize, Deserialize)]
377pub struct EvolutionProjection {
378    pub genes: Vec<Gene>,
379    pub capsules: Vec<Capsule>,
380    pub reuse_counts: BTreeMap<GeneId, u64>,
381    pub attempt_counts: BTreeMap<GeneId, u64>,
382    pub last_updated_at: BTreeMap<GeneId, String>,
383    pub spec_ids_by_gene: BTreeMap<GeneId, BTreeSet<String>>,
384}
385
386#[derive(Clone, Debug)]
387pub struct SelectorInput {
388    pub signals: Vec<String>,
389    pub env: EnvFingerprint,
390    pub spec_id: Option<String>,
391    pub limit: usize,
392}
393
394#[derive(Clone, Debug)]
395pub struct GeneCandidate {
396    pub gene: Gene,
397    pub score: f32,
398    pub capsules: Vec<Capsule>,
399}
400
401pub trait Selector: Send + Sync {
402    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate>;
403}
404
405pub trait EvolutionStore: Send + Sync {
406    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError>;
407    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError>;
408    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError>;
409
410    fn scan_projection(
411        &self,
412    ) -> Result<(Vec<StoredEvolutionEvent>, EvolutionProjection), EvolutionError> {
413        let events = self.scan(1)?;
414        let projection = rebuild_projection_from_events(&events);
415        Ok((events, projection))
416    }
417}
418
419#[derive(Debug, Error)]
420pub enum EvolutionError {
421    #[error("I/O error: {0}")]
422    Io(String),
423    #[error("Serialization error: {0}")]
424    Serde(String),
425    #[error("Hash chain validation failed: {0}")]
426    HashChain(String),
427}
428
429pub struct JsonlEvolutionStore {
430    root_dir: PathBuf,
431    lock: Mutex<()>,
432}
433
434impl JsonlEvolutionStore {
435    pub fn new<P: Into<PathBuf>>(root_dir: P) -> Self {
436        Self {
437            root_dir: root_dir.into(),
438            lock: Mutex::new(()),
439        }
440    }
441
442    pub fn root_dir(&self) -> &Path {
443        &self.root_dir
444    }
445
446    fn ensure_layout(&self) -> Result<(), EvolutionError> {
447        fs::create_dir_all(&self.root_dir).map_err(io_err)?;
448        let lock_path = self.root_dir.join("LOCK");
449        if !lock_path.exists() {
450            File::create(lock_path).map_err(io_err)?;
451        }
452        let events_path = self.events_path();
453        if !events_path.exists() {
454            File::create(events_path).map_err(io_err)?;
455        }
456        Ok(())
457    }
458
459    fn events_path(&self) -> PathBuf {
460        self.root_dir.join("events.jsonl")
461    }
462
463    fn genes_path(&self) -> PathBuf {
464        self.root_dir.join("genes.json")
465    }
466
467    fn capsules_path(&self) -> PathBuf {
468        self.root_dir.join("capsules.json")
469    }
470
471    fn read_all_events(&self) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
472        self.ensure_layout()?;
473        let file = File::open(self.events_path()).map_err(io_err)?;
474        let reader = BufReader::new(file);
475        let mut events = Vec::new();
476        for line in reader.lines() {
477            let line = line.map_err(io_err)?;
478            if line.trim().is_empty() {
479                continue;
480            }
481            let event = serde_json::from_str::<StoredEvolutionEvent>(&line)
482                .map_err(|err| EvolutionError::Serde(err.to_string()))?;
483            events.push(event);
484        }
485        verify_hash_chain(&events)?;
486        Ok(events)
487    }
488
489    fn write_projection_files(
490        &self,
491        projection: &EvolutionProjection,
492    ) -> Result<(), EvolutionError> {
493        write_json_atomic(&self.genes_path(), &projection.genes)?;
494        write_json_atomic(&self.capsules_path(), &projection.capsules)?;
495        Ok(())
496    }
497
498    fn append_event_locked(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
499        let existing = self.read_all_events()?;
500        let next_seq = existing.last().map(|entry| entry.seq + 1).unwrap_or(1);
501        let prev_hash = existing
502            .last()
503            .map(|entry| entry.record_hash.clone())
504            .unwrap_or_default();
505        let timestamp = Utc::now().to_rfc3339();
506        let record_hash = hash_record(next_seq, &timestamp, &prev_hash, &event)?;
507        let stored = StoredEvolutionEvent {
508            seq: next_seq,
509            timestamp,
510            prev_hash,
511            record_hash,
512            event,
513        };
514        let mut file = OpenOptions::new()
515            .create(true)
516            .append(true)
517            .open(self.events_path())
518            .map_err(io_err)?;
519        let line =
520            serde_json::to_string(&stored).map_err(|err| EvolutionError::Serde(err.to_string()))?;
521        file.write_all(line.as_bytes()).map_err(io_err)?;
522        file.write_all(b"\n").map_err(io_err)?;
523        file.sync_data().map_err(io_err)?;
524
525        let events = self.read_all_events()?;
526        let projection = rebuild_projection_from_events(&events);
527        self.write_projection_files(&projection)?;
528        Ok(next_seq)
529    }
530}
531
532impl EvolutionStore for JsonlEvolutionStore {
533    fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
534        let _guard = self
535            .lock
536            .lock()
537            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
538        self.append_event_locked(event)
539    }
540
541    fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
542        let _guard = self
543            .lock
544            .lock()
545            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
546        Ok(self
547            .read_all_events()?
548            .into_iter()
549            .filter(|entry| entry.seq >= from_seq)
550            .collect())
551    }
552
553    fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
554        let _guard = self
555            .lock
556            .lock()
557            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
558        let projection = rebuild_projection_from_events(&self.read_all_events()?);
559        self.write_projection_files(&projection)?;
560        Ok(projection)
561    }
562
563    fn scan_projection(
564        &self,
565    ) -> Result<(Vec<StoredEvolutionEvent>, EvolutionProjection), EvolutionError> {
566        let _guard = self
567            .lock
568            .lock()
569            .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
570        let events = self.read_all_events()?;
571        let projection = rebuild_projection_from_events(&events);
572        self.write_projection_files(&projection)?;
573        Ok((events, projection))
574    }
575}
576
577pub struct ProjectionSelector {
578    projection: EvolutionProjection,
579    now: DateTime<Utc>,
580}
581
582impl ProjectionSelector {
583    pub fn new(projection: EvolutionProjection) -> Self {
584        Self {
585            projection,
586            now: Utc::now(),
587        }
588    }
589
590    pub fn with_now(projection: EvolutionProjection, now: DateTime<Utc>) -> Self {
591        Self { projection, now }
592    }
593}
594
595impl Selector for ProjectionSelector {
596    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
597        let requested_spec_id = input
598            .spec_id
599            .as_deref()
600            .map(str::trim)
601            .filter(|value| !value.is_empty());
602        let mut out = Vec::new();
603        for gene in &self.projection.genes {
604            if gene.state != AssetState::Promoted {
605                continue;
606            }
607            if let Some(spec_id) = requested_spec_id {
608                let matches_spec = self
609                    .projection
610                    .spec_ids_by_gene
611                    .get(&gene.id)
612                    .map(|values| {
613                        values
614                            .iter()
615                            .any(|value| value.eq_ignore_ascii_case(spec_id))
616                    })
617                    .unwrap_or(false);
618                if !matches_spec {
619                    continue;
620                }
621            }
622            let capsules = self
623                .projection
624                .capsules
625                .iter()
626                .filter(|capsule| {
627                    capsule.gene_id == gene.id && capsule.state == AssetState::Promoted
628                })
629                .cloned()
630                .collect::<Vec<_>>();
631            if capsules.is_empty() {
632                continue;
633            }
634            let mut capsules = capsules;
635            capsules.sort_by(|left, right| {
636                environment_match_factor(&input.env, &right.env)
637                    .partial_cmp(&environment_match_factor(&input.env, &left.env))
638                    .unwrap_or(std::cmp::Ordering::Equal)
639                    .then_with(|| {
640                        right
641                            .confidence
642                            .partial_cmp(&left.confidence)
643                            .unwrap_or(std::cmp::Ordering::Equal)
644                    })
645                    .then_with(|| left.id.cmp(&right.id))
646            });
647            let env_match_factor = capsules
648                .first()
649                .map(|capsule| environment_match_factor(&input.env, &capsule.env))
650                .unwrap_or(0.0);
651
652            let successful_capsules = capsules.len() as f64;
653            let attempts = self
654                .projection
655                .attempt_counts
656                .get(&gene.id)
657                .copied()
658                .unwrap_or(capsules.len() as u64) as f64;
659            let success_rate = if attempts == 0.0 {
660                0.0
661            } else {
662                successful_capsules / attempts
663            };
664            let successful_reuses = self
665                .projection
666                .reuse_counts
667                .get(&gene.id)
668                .copied()
669                .unwrap_or(0) as f64;
670            let reuse_count_factor = 1.0 + (1.0 + successful_reuses).ln();
671            let signal_overlap = normalized_signal_overlap(&gene.signals, &input.signals);
672            let age_secs = self
673                .projection
674                .last_updated_at
675                .get(&gene.id)
676                .and_then(|value| seconds_since_timestamp(value, self.now));
677            let peak_confidence = capsules
678                .iter()
679                .map(|capsule| capsule.confidence)
680                .fold(0.0_f32, f32::max) as f64;
681            let freshness_confidence = capsules
682                .iter()
683                .map(|capsule| decayed_replay_confidence(capsule.confidence, age_secs))
684                .fold(0.0_f32, f32::max) as f64;
685            if freshness_confidence < MIN_REPLAY_CONFIDENCE as f64 {
686                continue;
687            }
688            let freshness_factor = if peak_confidence <= 0.0 {
689                0.0
690            } else {
691                (freshness_confidence / peak_confidence).clamp(0.0, 1.0)
692            };
693            let score = (success_rate
694                * reuse_count_factor
695                * env_match_factor
696                * freshness_factor
697                * signal_overlap) as f32;
698            if score < 0.35 {
699                continue;
700            }
701            out.push(GeneCandidate {
702                gene: gene.clone(),
703                score,
704                capsules,
705            });
706        }
707
708        out.sort_by(|left, right| {
709            right
710                .score
711                .partial_cmp(&left.score)
712                .unwrap_or(std::cmp::Ordering::Equal)
713                .then_with(|| left.gene.id.cmp(&right.gene.id))
714        });
715        out.truncate(input.limit.max(1));
716        out
717    }
718}
719
720pub struct StoreBackedSelector {
721    store: std::sync::Arc<dyn EvolutionStore>,
722}
723
724impl StoreBackedSelector {
725    pub fn new(store: std::sync::Arc<dyn EvolutionStore>) -> Self {
726        Self { store }
727    }
728}
729
730impl Selector for StoreBackedSelector {
731    fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
732        match self.store.scan_projection() {
733            Ok((_, projection)) => ProjectionSelector::new(projection).select(input),
734            Err(_) => Vec::new(),
735        }
736    }
737}
738
739pub fn rebuild_projection_from_events(events: &[StoredEvolutionEvent]) -> EvolutionProjection {
740    let mut genes = BTreeMap::<GeneId, Gene>::new();
741    let mut capsules = BTreeMap::<CapsuleId, Capsule>::new();
742    let mut reuse_counts = BTreeMap::<GeneId, u64>::new();
743    let mut attempt_counts = BTreeMap::<GeneId, u64>::new();
744    let mut last_updated_at = BTreeMap::<GeneId, String>::new();
745    let mut spec_ids_by_gene = BTreeMap::<GeneId, BTreeSet<String>>::new();
746    let mut mutation_to_gene = HashMap::<MutationId, GeneId>::new();
747    let mut mutation_spec_ids = HashMap::<MutationId, String>::new();
748
749    for stored in events {
750        match &stored.event {
751            EvolutionEvent::MutationDeclared { mutation } => {
752                if let Some(spec_id) = mutation
753                    .intent
754                    .spec_id
755                    .as_ref()
756                    .map(|value| value.trim())
757                    .filter(|value| !value.is_empty())
758                {
759                    mutation_spec_ids.insert(mutation.intent.id.clone(), spec_id.to_string());
760                    if let Some(gene_id) = mutation_to_gene.get(&mutation.intent.id) {
761                        spec_ids_by_gene
762                            .entry(gene_id.clone())
763                            .or_default()
764                            .insert(spec_id.to_string());
765                    }
766                }
767            }
768            EvolutionEvent::SpecLinked {
769                mutation_id,
770                spec_id,
771            } => {
772                let spec_id = spec_id.trim();
773                if !spec_id.is_empty() {
774                    mutation_spec_ids.insert(mutation_id.clone(), spec_id.to_string());
775                    if let Some(gene_id) = mutation_to_gene.get(mutation_id) {
776                        spec_ids_by_gene
777                            .entry(gene_id.clone())
778                            .or_default()
779                            .insert(spec_id.to_string());
780                    }
781                }
782            }
783            EvolutionEvent::GeneProjected { gene } => {
784                genes.insert(gene.id.clone(), gene.clone());
785                last_updated_at.insert(gene.id.clone(), stored.timestamp.clone());
786            }
787            EvolutionEvent::GenePromoted { gene_id } => {
788                if let Some(gene) = genes.get_mut(gene_id) {
789                    gene.state = AssetState::Promoted;
790                }
791                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
792            }
793            EvolutionEvent::GeneRevoked { gene_id, .. } => {
794                if let Some(gene) = genes.get_mut(gene_id) {
795                    gene.state = AssetState::Revoked;
796                }
797                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
798            }
799            EvolutionEvent::GeneArchived { gene_id } => {
800                if let Some(gene) = genes.get_mut(gene_id) {
801                    gene.state = AssetState::Archived;
802                }
803                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
804            }
805            EvolutionEvent::PromotionEvaluated { gene_id, state, .. } => {
806                if let Some(gene) = genes.get_mut(gene_id) {
807                    gene.state = state.clone();
808                }
809                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
810            }
811            EvolutionEvent::CapsuleCommitted { capsule } => {
812                mutation_to_gene.insert(capsule.mutation_id.clone(), capsule.gene_id.clone());
813                capsules.insert(capsule.id.clone(), capsule.clone());
814                *attempt_counts.entry(capsule.gene_id.clone()).or_insert(0) += 1;
815                if let Some(spec_id) = mutation_spec_ids.get(&capsule.mutation_id) {
816                    spec_ids_by_gene
817                        .entry(capsule.gene_id.clone())
818                        .or_default()
819                        .insert(spec_id.clone());
820                }
821                last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
822            }
823            EvolutionEvent::CapsuleQuarantined { capsule_id } => {
824                if let Some(capsule) = capsules.get_mut(capsule_id) {
825                    capsule.state = AssetState::Quarantined;
826                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
827                }
828            }
829            EvolutionEvent::CapsuleReleased { capsule_id, state } => {
830                if let Some(capsule) = capsules.get_mut(capsule_id) {
831                    capsule.state = state.clone();
832                    last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
833                }
834            }
835            EvolutionEvent::CapsuleReused { gene_id, .. } => {
836                *reuse_counts.entry(gene_id.clone()).or_insert(0) += 1;
837                last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
838            }
839            EvolutionEvent::ValidationFailed {
840                mutation_id,
841                gene_id,
842                ..
843            } => {
844                let id = gene_id
845                    .clone()
846                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
847                if let Some(gene_id) = id {
848                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
849                    last_updated_at.insert(gene_id, stored.timestamp.clone());
850                }
851            }
852            EvolutionEvent::ValidationPassed {
853                mutation_id,
854                gene_id,
855                ..
856            } => {
857                let id = gene_id
858                    .clone()
859                    .or_else(|| mutation_to_gene.get(mutation_id).cloned());
860                if let Some(gene_id) = id {
861                    *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
862                    last_updated_at.insert(gene_id, stored.timestamp.clone());
863                }
864            }
865            _ => {}
866        }
867    }
868
869    EvolutionProjection {
870        genes: genes.into_values().collect(),
871        capsules: capsules.into_values().collect(),
872        reuse_counts,
873        attempt_counts,
874        last_updated_at,
875        spec_ids_by_gene,
876    }
877}
878
879pub fn default_store_root() -> PathBuf {
880    PathBuf::from(".oris").join("evolution")
881}
882
883pub fn hash_string(input: &str) -> String {
884    let mut hasher = Sha256::new();
885    hasher.update(input.as_bytes());
886    hex::encode(hasher.finalize())
887}
888
889pub fn stable_hash_json<T: Serialize>(value: &T) -> Result<String, EvolutionError> {
890    let bytes = serde_json::to_vec(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
891    let mut hasher = Sha256::new();
892    hasher.update(bytes);
893    Ok(hex::encode(hasher.finalize()))
894}
895
896pub fn compute_artifact_hash(payload: &str) -> String {
897    hash_string(payload)
898}
899
900pub fn next_id(prefix: &str) -> String {
901    let nanos = SystemTime::now()
902        .duration_since(UNIX_EPOCH)
903        .unwrap_or_default()
904        .as_nanos();
905    format!("{prefix}-{nanos:x}")
906}
907
908pub fn decayed_replay_confidence(confidence: f32, age_secs: Option<u64>) -> f32 {
909    if confidence <= 0.0 {
910        return 0.0;
911    }
912    let age_hours = age_secs.unwrap_or(0) as f32 / 3600.0;
913    let decay = (-REPLAY_CONFIDENCE_DECAY_RATE_PER_HOUR * age_hours).exp();
914    (confidence * decay).clamp(0.0, 1.0)
915}
916
917fn normalized_signal_overlap(gene_signals: &[String], input_signals: &[String]) -> f64 {
918    let gene = canonical_signal_phrases(gene_signals);
919    let input = canonical_signal_phrases(input_signals);
920    if input.is_empty() || gene.is_empty() {
921        return 0.0;
922    }
923    let matched = input
924        .iter()
925        .map(|signal| best_signal_match(&gene, signal))
926        .sum::<f64>();
927    matched / input.len() as f64
928}
929
930#[derive(Clone, Debug, PartialEq, Eq)]
931struct CanonicalSignal {
932    phrase: String,
933    tokens: BTreeSet<String>,
934}
935
936fn canonical_signal_phrases(signals: &[String]) -> Vec<CanonicalSignal> {
937    signals
938        .iter()
939        .filter_map(|signal| canonical_signal_phrase(signal))
940        .collect()
941}
942
943fn canonical_signal_phrase(input: &str) -> Option<CanonicalSignal> {
944    let tokens = input
945        .split(|ch: char| !ch.is_ascii_alphanumeric())
946        .filter_map(canonical_signal_token)
947        .collect::<BTreeSet<_>>();
948    if tokens.is_empty() {
949        return None;
950    }
951    let phrase = tokens.iter().cloned().collect::<Vec<_>>().join(" ");
952    Some(CanonicalSignal { phrase, tokens })
953}
954
955fn canonical_signal_token(token: &str) -> Option<String> {
956    let normalized = token.trim().to_ascii_lowercase();
957    if normalized.len() < 3 {
958        return None;
959    }
960    if normalized.chars().all(|ch| ch.is_ascii_digit()) {
961        return None;
962    }
963    match normalized.as_str() {
964        "absent" | "unavailable" | "vanished" => Some("missing".into()),
965        "file" | "files" | "error" | "errors" => None,
966        _ => Some(normalized),
967    }
968}
969
970fn best_signal_match(gene_signals: &[CanonicalSignal], input: &CanonicalSignal) -> f64 {
971    gene_signals
972        .iter()
973        .map(|candidate| deterministic_phrase_match(candidate, input))
974        .fold(0.0, f64::max)
975}
976
977fn deterministic_phrase_match(candidate: &CanonicalSignal, input: &CanonicalSignal) -> f64 {
978    if candidate.phrase == input.phrase {
979        return 1.0;
980    }
981    if candidate.tokens.len() < 2 || input.tokens.len() < 2 {
982        return 0.0;
983    }
984    let shared = candidate.tokens.intersection(&input.tokens).count();
985    if shared < 2 {
986        return 0.0;
987    }
988    let overlap = shared as f64 / candidate.tokens.len().min(input.tokens.len()) as f64;
989    if overlap >= 0.67 {
990        overlap
991    } else {
992        0.0
993    }
994}
995fn seconds_since_timestamp(timestamp: &str, now: DateTime<Utc>) -> Option<u64> {
996    let parsed = DateTime::parse_from_rfc3339(timestamp)
997        .ok()?
998        .with_timezone(&Utc);
999    let elapsed = now.signed_duration_since(parsed);
1000    if elapsed < Duration::zero() {
1001        Some(0)
1002    } else {
1003        u64::try_from(elapsed.num_seconds()).ok()
1004    }
1005}
1006fn environment_match_factor(input: &EnvFingerprint, candidate: &EnvFingerprint) -> f64 {
1007    let fields = [
1008        input
1009            .rustc_version
1010            .eq_ignore_ascii_case(&candidate.rustc_version),
1011        input
1012            .cargo_lock_hash
1013            .eq_ignore_ascii_case(&candidate.cargo_lock_hash),
1014        input
1015            .target_triple
1016            .eq_ignore_ascii_case(&candidate.target_triple),
1017        input.os.eq_ignore_ascii_case(&candidate.os),
1018    ];
1019    let matched_fields = fields.into_iter().filter(|matched| *matched).count() as f64;
1020    0.5 + ((matched_fields / 4.0) * 0.5)
1021}
1022
1023fn hash_record(
1024    seq: u64,
1025    timestamp: &str,
1026    prev_hash: &str,
1027    event: &EvolutionEvent,
1028) -> Result<String, EvolutionError> {
1029    stable_hash_json(&(seq, timestamp, prev_hash, event))
1030}
1031
1032fn verify_hash_chain(events: &[StoredEvolutionEvent]) -> Result<(), EvolutionError> {
1033    let mut previous_hash = String::new();
1034    let mut expected_seq = 1u64;
1035    for event in events {
1036        if event.seq != expected_seq {
1037            return Err(EvolutionError::HashChain(format!(
1038                "expected seq {}, found {}",
1039                expected_seq, event.seq
1040            )));
1041        }
1042        if event.prev_hash != previous_hash {
1043            return Err(EvolutionError::HashChain(format!(
1044                "event {} prev_hash mismatch",
1045                event.seq
1046            )));
1047        }
1048        let actual_hash = hash_record(event.seq, &event.timestamp, &event.prev_hash, &event.event)?;
1049        if actual_hash != event.record_hash {
1050            return Err(EvolutionError::HashChain(format!(
1051                "event {} record_hash mismatch",
1052                event.seq
1053            )));
1054        }
1055        previous_hash = event.record_hash.clone();
1056        expected_seq += 1;
1057    }
1058    Ok(())
1059}
1060
1061fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), EvolutionError> {
1062    let tmp_path = path.with_extension("tmp");
1063    let bytes =
1064        serde_json::to_vec_pretty(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
1065    fs::write(&tmp_path, bytes).map_err(io_err)?;
1066    fs::rename(&tmp_path, path).map_err(io_err)?;
1067    Ok(())
1068}
1069
1070fn io_err(err: std::io::Error) -> EvolutionError {
1071    EvolutionError::Io(err.to_string())
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077
1078    fn temp_root(name: &str) -> PathBuf {
1079        std::env::temp_dir().join(format!("oris-evolution-{name}-{}", next_id("t")))
1080    }
1081
1082    fn sample_mutation() -> PreparedMutation {
1083        PreparedMutation {
1084            intent: MutationIntent {
1085                id: "mutation-1".into(),
1086                intent: "tighten borrow scope".into(),
1087                target: MutationTarget::Paths {
1088                    allow: vec!["crates/oris-kernel".into()],
1089                },
1090                expected_effect: "cargo check passes".into(),
1091                risk: RiskLevel::Low,
1092                signals: vec!["rust borrow error".into()],
1093                spec_id: None,
1094            },
1095            artifact: MutationArtifact {
1096                encoding: ArtifactEncoding::UnifiedDiff,
1097                payload: "diff --git a/foo b/foo".into(),
1098                base_revision: Some("HEAD".into()),
1099                content_hash: compute_artifact_hash("diff --git a/foo b/foo"),
1100            },
1101        }
1102    }
1103
1104    #[test]
1105    fn evomap_asset_state_mapping_archived_is_rejected() {
1106        assert_eq!(
1107            asset_state_to_evomap_compat(&AssetState::Archived),
1108            "rejected"
1109        );
1110        assert_eq!(
1111            asset_state_to_evomap_compat(&AssetState::Quarantined),
1112            "quarantined"
1113        );
1114        assert_eq!(
1115            asset_state_to_evomap_compat(&AssetState::ShadowValidated),
1116            "candidate"
1117        );
1118    }
1119
1120    #[test]
1121    fn append_event_assigns_monotonic_seq() {
1122        let root = temp_root("seq");
1123        let store = JsonlEvolutionStore::new(root);
1124        let first = store
1125            .append_event(EvolutionEvent::MutationDeclared {
1126                mutation: sample_mutation(),
1127            })
1128            .unwrap();
1129        let second = store
1130            .append_event(EvolutionEvent::MutationRejected {
1131                mutation_id: "mutation-1".into(),
1132                reason: "no-op".into(),
1133                reason_code: None,
1134                recovery_hint: None,
1135                fail_closed: true,
1136            })
1137            .unwrap();
1138        assert_eq!(first, 1);
1139        assert_eq!(second, 2);
1140    }
1141
1142    #[test]
1143    fn tampered_hash_chain_is_rejected() {
1144        let root = temp_root("tamper");
1145        let store = JsonlEvolutionStore::new(&root);
1146        store
1147            .append_event(EvolutionEvent::MutationDeclared {
1148                mutation: sample_mutation(),
1149            })
1150            .unwrap();
1151        let path = root.join("events.jsonl");
1152        let contents = fs::read_to_string(&path).unwrap();
1153        let mutated = contents.replace("tighten borrow scope", "tampered");
1154        fs::write(&path, mutated).unwrap();
1155        let result = store.scan(1);
1156        assert!(matches!(result, Err(EvolutionError::HashChain(_))));
1157    }
1158
1159    #[test]
1160    fn rebuild_projection_after_cache_deletion() {
1161        let root = temp_root("projection");
1162        let store = JsonlEvolutionStore::new(&root);
1163        let gene = Gene {
1164            id: "gene-1".into(),
1165            signals: vec!["rust borrow error".into()],
1166            strategy: vec!["crates".into()],
1167            validation: vec!["oris-default".into()],
1168            state: AssetState::Promoted,
1169            task_class_id: None,
1170        };
1171        let capsule = Capsule {
1172            id: "capsule-1".into(),
1173            gene_id: gene.id.clone(),
1174            mutation_id: "mutation-1".into(),
1175            run_id: "run-1".into(),
1176            diff_hash: "abc".into(),
1177            confidence: 0.7,
1178            env: EnvFingerprint {
1179                rustc_version: "rustc 1.80".into(),
1180                cargo_lock_hash: "lock".into(),
1181                target_triple: "x86_64-unknown-linux-gnu".into(),
1182                os: "linux".into(),
1183            },
1184            outcome: Outcome {
1185                success: true,
1186                validation_profile: "oris-default".into(),
1187                validation_duration_ms: 100,
1188                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1189                validator_hash: "vh".into(),
1190                lines_changed: 1,
1191                replay_verified: false,
1192            },
1193            state: AssetState::Promoted,
1194        };
1195        store
1196            .append_event(EvolutionEvent::GeneProjected { gene })
1197            .unwrap();
1198        store
1199            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1200            .unwrap();
1201        fs::remove_file(root.join("genes.json")).unwrap();
1202        fs::remove_file(root.join("capsules.json")).unwrap();
1203        let projection = store.rebuild_projection().unwrap();
1204        assert_eq!(projection.genes.len(), 1);
1205        assert_eq!(projection.capsules.len(), 1);
1206    }
1207
1208    #[test]
1209    fn rebuild_projection_tracks_spec_ids_for_genes() {
1210        let root = temp_root("projection-spec");
1211        let store = JsonlEvolutionStore::new(&root);
1212        let mut mutation = sample_mutation();
1213        mutation.intent.id = "mutation-spec".into();
1214        mutation.intent.spec_id = Some("spec-repair-1".into());
1215        let gene = Gene {
1216            id: "gene-spec".into(),
1217            signals: vec!["rust borrow error".into()],
1218            strategy: vec!["crates".into()],
1219            validation: vec!["oris-default".into()],
1220            state: AssetState::Promoted,
1221            task_class_id: None,
1222        };
1223        let capsule = Capsule {
1224            id: "capsule-spec".into(),
1225            gene_id: gene.id.clone(),
1226            mutation_id: mutation.intent.id.clone(),
1227            run_id: "run-spec".into(),
1228            diff_hash: "abc".into(),
1229            confidence: 0.7,
1230            env: EnvFingerprint {
1231                rustc_version: "rustc 1.80".into(),
1232                cargo_lock_hash: "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: 100,
1240                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1241                validator_hash: "vh".into(),
1242                lines_changed: 1,
1243                replay_verified: false,
1244            },
1245            state: AssetState::Promoted,
1246        };
1247        store
1248            .append_event(EvolutionEvent::MutationDeclared { mutation })
1249            .unwrap();
1250        store
1251            .append_event(EvolutionEvent::GeneProjected { gene })
1252            .unwrap();
1253        store
1254            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1255            .unwrap();
1256
1257        let projection = store.rebuild_projection().unwrap();
1258        let spec_ids = projection.spec_ids_by_gene.get("gene-spec").unwrap();
1259        assert!(spec_ids.contains("spec-repair-1"));
1260    }
1261
1262    #[test]
1263    fn rebuild_projection_tracks_spec_ids_from_spec_linked_events() {
1264        let root = temp_root("projection-spec-linked");
1265        let store = JsonlEvolutionStore::new(&root);
1266        let mut mutation = sample_mutation();
1267        mutation.intent.id = "mutation-spec-linked".into();
1268        mutation.intent.spec_id = None;
1269        let gene = Gene {
1270            id: "gene-spec-linked".into(),
1271            signals: vec!["rust borrow error".into()],
1272            strategy: vec!["crates".into()],
1273            validation: vec!["oris-default".into()],
1274            state: AssetState::Promoted,
1275            task_class_id: None,
1276        };
1277        let capsule = Capsule {
1278            id: "capsule-spec-linked".into(),
1279            gene_id: gene.id.clone(),
1280            mutation_id: mutation.intent.id.clone(),
1281            run_id: "run-spec-linked".into(),
1282            diff_hash: "abc".into(),
1283            confidence: 0.7,
1284            env: EnvFingerprint {
1285                rustc_version: "rustc 1.80".into(),
1286                cargo_lock_hash: "lock".into(),
1287                target_triple: "x86_64-unknown-linux-gnu".into(),
1288                os: "linux".into(),
1289            },
1290            outcome: Outcome {
1291                success: true,
1292                validation_profile: "oris-default".into(),
1293                validation_duration_ms: 100,
1294                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1295                validator_hash: "vh".into(),
1296                lines_changed: 1,
1297                replay_verified: false,
1298            },
1299            state: AssetState::Promoted,
1300        };
1301        store
1302            .append_event(EvolutionEvent::MutationDeclared { mutation })
1303            .unwrap();
1304        store
1305            .append_event(EvolutionEvent::GeneProjected { gene })
1306            .unwrap();
1307        store
1308            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1309            .unwrap();
1310        store
1311            .append_event(EvolutionEvent::SpecLinked {
1312                mutation_id: "mutation-spec-linked".into(),
1313                spec_id: "spec-repair-linked".into(),
1314            })
1315            .unwrap();
1316
1317        let projection = store.rebuild_projection().unwrap();
1318        let spec_ids = projection.spec_ids_by_gene.get("gene-spec-linked").unwrap();
1319        assert!(spec_ids.contains("spec-repair-linked"));
1320    }
1321
1322    #[test]
1323    fn rebuild_projection_tracks_inline_spec_ids_even_when_declared_late() {
1324        let root = temp_root("projection-spec-inline-late");
1325        let store = JsonlEvolutionStore::new(&root);
1326        let mut mutation = sample_mutation();
1327        mutation.intent.id = "mutation-inline-late".into();
1328        mutation.intent.spec_id = Some("spec-inline-late".into());
1329        let gene = Gene {
1330            id: "gene-inline-late".into(),
1331            signals: vec!["rust borrow error".into()],
1332            strategy: vec!["crates".into()],
1333            validation: vec!["oris-default".into()],
1334            state: AssetState::Promoted,
1335            task_class_id: None,
1336        };
1337        let capsule = Capsule {
1338            id: "capsule-inline-late".into(),
1339            gene_id: gene.id.clone(),
1340            mutation_id: mutation.intent.id.clone(),
1341            run_id: "run-inline-late".into(),
1342            diff_hash: "abc".into(),
1343            confidence: 0.7,
1344            env: EnvFingerprint {
1345                rustc_version: "rustc 1.80".into(),
1346                cargo_lock_hash: "lock".into(),
1347                target_triple: "x86_64-unknown-linux-gnu".into(),
1348                os: "linux".into(),
1349            },
1350            outcome: Outcome {
1351                success: true,
1352                validation_profile: "oris-default".into(),
1353                validation_duration_ms: 100,
1354                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1355                validator_hash: "vh".into(),
1356                lines_changed: 1,
1357                replay_verified: false,
1358            },
1359            state: AssetState::Promoted,
1360        };
1361        store
1362            .append_event(EvolutionEvent::GeneProjected { gene })
1363            .unwrap();
1364        store
1365            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1366            .unwrap();
1367        store
1368            .append_event(EvolutionEvent::MutationDeclared { mutation })
1369            .unwrap();
1370
1371        let projection = store.rebuild_projection().unwrap();
1372        let spec_ids = projection.spec_ids_by_gene.get("gene-inline-late").unwrap();
1373        assert!(spec_ids.contains("spec-inline-late"));
1374    }
1375
1376    #[test]
1377    fn scan_projection_recreates_projection_files() {
1378        let root = temp_root("scan-projection");
1379        let store = JsonlEvolutionStore::new(&root);
1380        let mutation = sample_mutation();
1381        let gene = Gene {
1382            id: "gene-scan".into(),
1383            signals: vec!["rust borrow error".into()],
1384            strategy: vec!["crates".into()],
1385            validation: vec!["oris-default".into()],
1386            state: AssetState::Promoted,
1387            task_class_id: None,
1388        };
1389        let capsule = Capsule {
1390            id: "capsule-scan".into(),
1391            gene_id: gene.id.clone(),
1392            mutation_id: mutation.intent.id.clone(),
1393            run_id: "run-scan".into(),
1394            diff_hash: "abc".into(),
1395            confidence: 0.7,
1396            env: EnvFingerprint {
1397                rustc_version: "rustc 1.80".into(),
1398                cargo_lock_hash: "lock".into(),
1399                target_triple: "x86_64-unknown-linux-gnu".into(),
1400                os: "linux".into(),
1401            },
1402            outcome: Outcome {
1403                success: true,
1404                validation_profile: "oris-default".into(),
1405                validation_duration_ms: 100,
1406                changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
1407                validator_hash: "vh".into(),
1408                lines_changed: 1,
1409                replay_verified: false,
1410            },
1411            state: AssetState::Promoted,
1412        };
1413        store
1414            .append_event(EvolutionEvent::MutationDeclared { mutation })
1415            .unwrap();
1416        store
1417            .append_event(EvolutionEvent::GeneProjected { gene })
1418            .unwrap();
1419        store
1420            .append_event(EvolutionEvent::CapsuleCommitted { capsule })
1421            .unwrap();
1422        fs::remove_file(root.join("genes.json")).unwrap();
1423        fs::remove_file(root.join("capsules.json")).unwrap();
1424
1425        let (events, projection) = store.scan_projection().unwrap();
1426
1427        assert_eq!(events.len(), 3);
1428        assert_eq!(projection.genes.len(), 1);
1429        assert_eq!(projection.capsules.len(), 1);
1430        assert!(root.join("genes.json").exists());
1431        assert!(root.join("capsules.json").exists());
1432    }
1433
1434    #[test]
1435    fn default_scan_projection_uses_single_event_snapshot() {
1436        struct InconsistentSnapshotStore {
1437            scanned_events: Vec<StoredEvolutionEvent>,
1438            rebuilt_projection: EvolutionProjection,
1439        }
1440
1441        impl EvolutionStore for InconsistentSnapshotStore {
1442            fn append_event(&self, _event: EvolutionEvent) -> Result<u64, EvolutionError> {
1443                Err(EvolutionError::Io("unused in test".into()))
1444            }
1445
1446            fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
1447                Ok(self
1448                    .scanned_events
1449                    .iter()
1450                    .filter(|stored| stored.seq >= from_seq)
1451                    .cloned()
1452                    .collect())
1453            }
1454
1455            fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
1456                Ok(self.rebuilt_projection.clone())
1457            }
1458        }
1459
1460        let scanned_gene = Gene {
1461            id: "gene-scanned".into(),
1462            signals: vec!["signal".into()],
1463            strategy: vec!["a".into()],
1464            validation: vec!["oris-default".into()],
1465            state: AssetState::Promoted,
1466            task_class_id: None,
1467        };
1468        let store = InconsistentSnapshotStore {
1469            scanned_events: vec![StoredEvolutionEvent {
1470                seq: 1,
1471                timestamp: "2026-03-04T00:00:00Z".into(),
1472                prev_hash: String::new(),
1473                record_hash: "hash".into(),
1474                event: EvolutionEvent::GeneProjected {
1475                    gene: scanned_gene.clone(),
1476                },
1477            }],
1478            rebuilt_projection: EvolutionProjection {
1479                genes: vec![Gene {
1480                    id: "gene-rebuilt".into(),
1481                    signals: vec!["other".into()],
1482                    strategy: vec!["b".into()],
1483                    validation: vec!["oris-default".into()],
1484                    state: AssetState::Promoted,
1485                    task_class_id: None,
1486                }],
1487                ..Default::default()
1488            },
1489        };
1490
1491        let (events, projection) = store.scan_projection().unwrap();
1492
1493        assert_eq!(events.len(), 1);
1494        assert_eq!(projection.genes.len(), 1);
1495        assert_eq!(projection.genes[0].id, scanned_gene.id);
1496    }
1497
1498    #[test]
1499    fn store_backed_selector_uses_scan_projection_contract() {
1500        struct InconsistentSnapshotStore {
1501            scanned_events: Vec<StoredEvolutionEvent>,
1502            rebuilt_projection: EvolutionProjection,
1503        }
1504
1505        impl EvolutionStore for InconsistentSnapshotStore {
1506            fn append_event(&self, _event: EvolutionEvent) -> Result<u64, EvolutionError> {
1507                Err(EvolutionError::Io("unused in test".into()))
1508            }
1509
1510            fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
1511                Ok(self
1512                    .scanned_events
1513                    .iter()
1514                    .filter(|stored| stored.seq >= from_seq)
1515                    .cloned()
1516                    .collect())
1517            }
1518
1519            fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
1520                Ok(self.rebuilt_projection.clone())
1521            }
1522        }
1523
1524        let scanned_gene = Gene {
1525            id: "gene-scanned".into(),
1526            signals: vec!["signal".into()],
1527            strategy: vec!["a".into()],
1528            validation: vec!["oris-default".into()],
1529            state: AssetState::Promoted,
1530            task_class_id: None,
1531        };
1532        let scanned_capsule = Capsule {
1533            id: "capsule-scanned".into(),
1534            gene_id: scanned_gene.id.clone(),
1535            mutation_id: "mutation-scanned".into(),
1536            run_id: "run-scanned".into(),
1537            diff_hash: "hash".into(),
1538            confidence: 0.8,
1539            env: EnvFingerprint {
1540                rustc_version: "rustc 1.80".into(),
1541                cargo_lock_hash: "lock".into(),
1542                target_triple: "x86_64-unknown-linux-gnu".into(),
1543                os: "linux".into(),
1544            },
1545            outcome: Outcome {
1546                success: true,
1547                validation_profile: "oris-default".into(),
1548                validation_duration_ms: 100,
1549                changed_files: vec!["file.rs".into()],
1550                validator_hash: "validator".into(),
1551                lines_changed: 1,
1552                replay_verified: false,
1553            },
1554            state: AssetState::Promoted,
1555        };
1556        let fresh_ts = Utc::now().to_rfc3339();
1557        let store = std::sync::Arc::new(InconsistentSnapshotStore {
1558            scanned_events: vec![
1559                StoredEvolutionEvent {
1560                    seq: 1,
1561                    timestamp: fresh_ts.clone(),
1562                    prev_hash: String::new(),
1563                    record_hash: "hash-1".into(),
1564                    event: EvolutionEvent::GeneProjected {
1565                        gene: scanned_gene.clone(),
1566                    },
1567                },
1568                StoredEvolutionEvent {
1569                    seq: 2,
1570                    timestamp: fresh_ts,
1571                    prev_hash: "hash-1".into(),
1572                    record_hash: "hash-2".into(),
1573                    event: EvolutionEvent::CapsuleCommitted {
1574                        capsule: scanned_capsule.clone(),
1575                    },
1576                },
1577            ],
1578            rebuilt_projection: EvolutionProjection {
1579                genes: vec![Gene {
1580                    id: "gene-rebuilt".into(),
1581                    signals: vec!["other".into()],
1582                    strategy: vec!["b".into()],
1583                    validation: vec!["oris-default".into()],
1584                    state: AssetState::Promoted,
1585                    task_class_id: None,
1586                }],
1587                ..Default::default()
1588            },
1589        });
1590        let selector = StoreBackedSelector::new(store);
1591        let input = SelectorInput {
1592            signals: vec!["signal".into()],
1593            env: scanned_capsule.env.clone(),
1594            spec_id: None,
1595            limit: 1,
1596        };
1597
1598        let candidates = selector.select(&input);
1599
1600        assert_eq!(candidates.len(), 1);
1601        assert_eq!(candidates[0].gene.id, scanned_gene.id);
1602        assert_eq!(candidates[0].capsules[0].id, scanned_capsule.id);
1603    }
1604
1605    #[test]
1606    fn selector_orders_results_stably() {
1607        let projection = EvolutionProjection {
1608            genes: vec![
1609                Gene {
1610                    id: "gene-a".into(),
1611                    signals: vec!["signal".into()],
1612                    strategy: vec!["a".into()],
1613                    validation: vec!["oris-default".into()],
1614                    state: AssetState::Promoted,
1615                    task_class_id: None,
1616                },
1617                Gene {
1618                    id: "gene-b".into(),
1619                    signals: vec!["signal".into()],
1620                    strategy: vec!["b".into()],
1621                    validation: vec!["oris-default".into()],
1622                    state: AssetState::Promoted,
1623                    task_class_id: None,
1624                },
1625            ],
1626            capsules: vec![
1627                Capsule {
1628                    id: "capsule-a".into(),
1629                    gene_id: "gene-a".into(),
1630                    mutation_id: "m1".into(),
1631                    run_id: "r1".into(),
1632                    diff_hash: "1".into(),
1633                    confidence: 0.7,
1634                    env: EnvFingerprint {
1635                        rustc_version: "rustc".into(),
1636                        cargo_lock_hash: "lock".into(),
1637                        target_triple: "x86_64-unknown-linux-gnu".into(),
1638                        os: "linux".into(),
1639                    },
1640                    outcome: Outcome {
1641                        success: true,
1642                        validation_profile: "oris-default".into(),
1643                        validation_duration_ms: 1,
1644                        changed_files: vec!["crates/oris-kernel".into()],
1645                        validator_hash: "v".into(),
1646                        lines_changed: 1,
1647                        replay_verified: false,
1648                    },
1649                    state: AssetState::Promoted,
1650                },
1651                Capsule {
1652                    id: "capsule-b".into(),
1653                    gene_id: "gene-b".into(),
1654                    mutation_id: "m2".into(),
1655                    run_id: "r2".into(),
1656                    diff_hash: "2".into(),
1657                    confidence: 0.7,
1658                    env: EnvFingerprint {
1659                        rustc_version: "rustc".into(),
1660                        cargo_lock_hash: "lock".into(),
1661                        target_triple: "x86_64-unknown-linux-gnu".into(),
1662                        os: "linux".into(),
1663                    },
1664                    outcome: Outcome {
1665                        success: true,
1666                        validation_profile: "oris-default".into(),
1667                        validation_duration_ms: 1,
1668                        changed_files: vec!["crates/oris-kernel".into()],
1669                        validator_hash: "v".into(),
1670                        lines_changed: 1,
1671                        replay_verified: false,
1672                    },
1673                    state: AssetState::Promoted,
1674                },
1675            ],
1676            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1677            attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1678            last_updated_at: BTreeMap::from([
1679                ("gene-a".into(), Utc::now().to_rfc3339()),
1680                ("gene-b".into(), Utc::now().to_rfc3339()),
1681            ]),
1682            spec_ids_by_gene: BTreeMap::new(),
1683        };
1684        let selector = ProjectionSelector::new(projection);
1685        let input = SelectorInput {
1686            signals: vec!["signal".into()],
1687            env: EnvFingerprint {
1688                rustc_version: "rustc".into(),
1689                cargo_lock_hash: "lock".into(),
1690                target_triple: "x86_64-unknown-linux-gnu".into(),
1691                os: "linux".into(),
1692            },
1693            spec_id: None,
1694            limit: 2,
1695        };
1696        let first = selector.select(&input);
1697        let second = selector.select(&input);
1698        assert_eq!(first.len(), 2);
1699        assert_eq!(
1700            first
1701                .iter()
1702                .map(|candidate| candidate.gene.id.clone())
1703                .collect::<Vec<_>>(),
1704            second
1705                .iter()
1706                .map(|candidate| candidate.gene.id.clone())
1707                .collect::<Vec<_>>()
1708        );
1709    }
1710
1711    #[test]
1712    fn selector_can_narrow_by_spec_id() {
1713        let projection = EvolutionProjection {
1714            genes: vec![
1715                Gene {
1716                    id: "gene-a".into(),
1717                    signals: vec!["signal".into()],
1718                    strategy: vec!["a".into()],
1719                    validation: vec!["oris-default".into()],
1720                    state: AssetState::Promoted,
1721                    task_class_id: None,
1722                },
1723                Gene {
1724                    id: "gene-b".into(),
1725                    signals: vec!["signal".into()],
1726                    strategy: vec!["b".into()],
1727                    validation: vec!["oris-default".into()],
1728                    state: AssetState::Promoted,
1729                    task_class_id: None,
1730                },
1731            ],
1732            capsules: vec![
1733                Capsule {
1734                    id: "capsule-a".into(),
1735                    gene_id: "gene-a".into(),
1736                    mutation_id: "m1".into(),
1737                    run_id: "r1".into(),
1738                    diff_hash: "1".into(),
1739                    confidence: 0.7,
1740                    env: EnvFingerprint {
1741                        rustc_version: "rustc".into(),
1742                        cargo_lock_hash: "lock".into(),
1743                        target_triple: "x86_64-unknown-linux-gnu".into(),
1744                        os: "linux".into(),
1745                    },
1746                    outcome: Outcome {
1747                        success: true,
1748                        validation_profile: "oris-default".into(),
1749                        validation_duration_ms: 1,
1750                        changed_files: vec!["crates/oris-kernel".into()],
1751                        validator_hash: "v".into(),
1752                        lines_changed: 1,
1753                        replay_verified: false,
1754                    },
1755                    state: AssetState::Promoted,
1756                },
1757                Capsule {
1758                    id: "capsule-b".into(),
1759                    gene_id: "gene-b".into(),
1760                    mutation_id: "m2".into(),
1761                    run_id: "r2".into(),
1762                    diff_hash: "2".into(),
1763                    confidence: 0.7,
1764                    env: EnvFingerprint {
1765                        rustc_version: "rustc".into(),
1766                        cargo_lock_hash: "lock".into(),
1767                        target_triple: "x86_64-unknown-linux-gnu".into(),
1768                        os: "linux".into(),
1769                    },
1770                    outcome: Outcome {
1771                        success: true,
1772                        validation_profile: "oris-default".into(),
1773                        validation_duration_ms: 1,
1774                        changed_files: vec!["crates/oris-kernel".into()],
1775                        validator_hash: "v".into(),
1776                        lines_changed: 1,
1777                        replay_verified: false,
1778                    },
1779                    state: AssetState::Promoted,
1780                },
1781            ],
1782            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1783            attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1784            last_updated_at: BTreeMap::from([
1785                ("gene-a".into(), Utc::now().to_rfc3339()),
1786                ("gene-b".into(), Utc::now().to_rfc3339()),
1787            ]),
1788            spec_ids_by_gene: BTreeMap::from([
1789                ("gene-a".into(), BTreeSet::from(["spec-a".to_string()])),
1790                ("gene-b".into(), BTreeSet::from(["spec-b".to_string()])),
1791            ]),
1792        };
1793        let selector = ProjectionSelector::new(projection);
1794        let input = SelectorInput {
1795            signals: vec!["signal".into()],
1796            env: EnvFingerprint {
1797                rustc_version: "rustc".into(),
1798                cargo_lock_hash: "lock".into(),
1799                target_triple: "x86_64-unknown-linux-gnu".into(),
1800                os: "linux".into(),
1801            },
1802            spec_id: Some("spec-b".into()),
1803            limit: 2,
1804        };
1805        let selected = selector.select(&input);
1806        assert_eq!(selected.len(), 1);
1807        assert_eq!(selected[0].gene.id, "gene-b");
1808    }
1809
1810    #[test]
1811    fn selector_prefers_closest_environment_match() {
1812        let projection = EvolutionProjection {
1813            genes: vec![
1814                Gene {
1815                    id: "gene-a".into(),
1816                    signals: vec!["signal".into()],
1817                    strategy: vec!["a".into()],
1818                    validation: vec!["oris-default".into()],
1819                    state: AssetState::Promoted,
1820                    task_class_id: None,
1821                },
1822                Gene {
1823                    id: "gene-b".into(),
1824                    signals: vec!["signal".into()],
1825                    strategy: vec!["b".into()],
1826                    validation: vec!["oris-default".into()],
1827                    state: AssetState::Promoted,
1828                    task_class_id: None,
1829                },
1830            ],
1831            capsules: vec![
1832                Capsule {
1833                    id: "capsule-a-stale".into(),
1834                    gene_id: "gene-a".into(),
1835                    mutation_id: "m1".into(),
1836                    run_id: "r1".into(),
1837                    diff_hash: "1".into(),
1838                    confidence: 0.2,
1839                    env: EnvFingerprint {
1840                        rustc_version: "old-rustc".into(),
1841                        cargo_lock_hash: "other-lock".into(),
1842                        target_triple: "aarch64-apple-darwin".into(),
1843                        os: "macos".into(),
1844                    },
1845                    outcome: Outcome {
1846                        success: true,
1847                        validation_profile: "oris-default".into(),
1848                        validation_duration_ms: 1,
1849                        changed_files: vec!["crates/oris-kernel".into()],
1850                        validator_hash: "v".into(),
1851                        lines_changed: 1,
1852                        replay_verified: false,
1853                    },
1854                    state: AssetState::Promoted,
1855                },
1856                Capsule {
1857                    id: "capsule-a-best".into(),
1858                    gene_id: "gene-a".into(),
1859                    mutation_id: "m2".into(),
1860                    run_id: "r2".into(),
1861                    diff_hash: "2".into(),
1862                    confidence: 0.9,
1863                    env: EnvFingerprint {
1864                        rustc_version: "rustc".into(),
1865                        cargo_lock_hash: "lock".into(),
1866                        target_triple: "x86_64-unknown-linux-gnu".into(),
1867                        os: "linux".into(),
1868                    },
1869                    outcome: Outcome {
1870                        success: true,
1871                        validation_profile: "oris-default".into(),
1872                        validation_duration_ms: 1,
1873                        changed_files: vec!["crates/oris-kernel".into()],
1874                        validator_hash: "v".into(),
1875                        lines_changed: 1,
1876                        replay_verified: false,
1877                    },
1878                    state: AssetState::Promoted,
1879                },
1880                Capsule {
1881                    id: "capsule-b".into(),
1882                    gene_id: "gene-b".into(),
1883                    mutation_id: "m3".into(),
1884                    run_id: "r3".into(),
1885                    diff_hash: "3".into(),
1886                    confidence: 0.7,
1887                    env: EnvFingerprint {
1888                        rustc_version: "rustc".into(),
1889                        cargo_lock_hash: "different-lock".into(),
1890                        target_triple: "x86_64-unknown-linux-gnu".into(),
1891                        os: "linux".into(),
1892                    },
1893                    outcome: Outcome {
1894                        success: true,
1895                        validation_profile: "oris-default".into(),
1896                        validation_duration_ms: 1,
1897                        changed_files: vec!["crates/oris-kernel".into()],
1898                        validator_hash: "v".into(),
1899                        lines_changed: 1,
1900                        replay_verified: false,
1901                    },
1902                    state: AssetState::Promoted,
1903                },
1904            ],
1905            reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1906            attempt_counts: BTreeMap::from([("gene-a".into(), 2), ("gene-b".into(), 1)]),
1907            last_updated_at: BTreeMap::from([
1908                ("gene-a".into(), Utc::now().to_rfc3339()),
1909                ("gene-b".into(), Utc::now().to_rfc3339()),
1910            ]),
1911            spec_ids_by_gene: BTreeMap::new(),
1912        };
1913        let selector = ProjectionSelector::new(projection);
1914        let input = SelectorInput {
1915            signals: vec!["signal".into()],
1916            env: EnvFingerprint {
1917                rustc_version: "rustc".into(),
1918                cargo_lock_hash: "lock".into(),
1919                target_triple: "x86_64-unknown-linux-gnu".into(),
1920                os: "linux".into(),
1921            },
1922            spec_id: None,
1923            limit: 2,
1924        };
1925
1926        let selected = selector.select(&input);
1927
1928        assert_eq!(selected.len(), 2);
1929        assert_eq!(selected[0].gene.id, "gene-a");
1930        assert_eq!(selected[0].capsules[0].id, "capsule-a-best");
1931        assert!(selected[0].score > selected[1].score);
1932    }
1933
1934    #[test]
1935    fn selector_preserves_fresh_candidate_scores_while_ranking_by_confidence() {
1936        let now = Utc::now();
1937        let projection = EvolutionProjection {
1938            genes: vec![Gene {
1939                id: "gene-fresh".into(),
1940                signals: vec!["missing".into()],
1941                strategy: vec!["a".into()],
1942                validation: vec!["oris-default".into()],
1943                state: AssetState::Promoted,
1944                task_class_id: None,
1945            }],
1946            capsules: vec![Capsule {
1947                id: "capsule-fresh".into(),
1948                gene_id: "gene-fresh".into(),
1949                mutation_id: "m1".into(),
1950                run_id: "r1".into(),
1951                diff_hash: "1".into(),
1952                confidence: 0.7,
1953                env: EnvFingerprint {
1954                    rustc_version: "rustc".into(),
1955                    cargo_lock_hash: "lock".into(),
1956                    target_triple: "x86_64-unknown-linux-gnu".into(),
1957                    os: "linux".into(),
1958                },
1959                outcome: Outcome {
1960                    success: true,
1961                    validation_profile: "oris-default".into(),
1962                    validation_duration_ms: 1,
1963                    changed_files: vec!["README.md".into()],
1964                    validator_hash: "v".into(),
1965                    lines_changed: 1,
1966                    replay_verified: false,
1967                },
1968                state: AssetState::Promoted,
1969            }],
1970            reuse_counts: BTreeMap::from([("gene-fresh".into(), 1)]),
1971            attempt_counts: BTreeMap::from([("gene-fresh".into(), 1)]),
1972            last_updated_at: BTreeMap::from([("gene-fresh".into(), now.to_rfc3339())]),
1973            spec_ids_by_gene: BTreeMap::new(),
1974        };
1975        let selector = ProjectionSelector::with_now(projection, now);
1976        let input = SelectorInput {
1977            signals: vec![
1978                "missing".into(),
1979                "token-a".into(),
1980                "token-b".into(),
1981                "token-c".into(),
1982            ],
1983            env: EnvFingerprint {
1984                rustc_version: "rustc".into(),
1985                cargo_lock_hash: "lock".into(),
1986                target_triple: "x86_64-unknown-linux-gnu".into(),
1987                os: "linux".into(),
1988            },
1989            spec_id: None,
1990            limit: 1,
1991        };
1992
1993        let selected = selector.select(&input);
1994
1995        assert_eq!(selected.len(), 1);
1996        assert_eq!(selected[0].gene.id, "gene-fresh");
1997        assert!(selected[0].score > 0.35);
1998    }
1999
2000    #[test]
2001    fn selector_skips_stale_candidates_after_confidence_decay() {
2002        let now = Utc::now();
2003        let projection = EvolutionProjection {
2004            genes: vec![Gene {
2005                id: "gene-stale".into(),
2006                signals: vec!["missing readme".into()],
2007                strategy: vec!["a".into()],
2008                validation: vec!["oris-default".into()],
2009                state: AssetState::Promoted,
2010                task_class_id: None,
2011            }],
2012            capsules: vec![Capsule {
2013                id: "capsule-stale".into(),
2014                gene_id: "gene-stale".into(),
2015                mutation_id: "m1".into(),
2016                run_id: "r1".into(),
2017                diff_hash: "1".into(),
2018                confidence: 0.8,
2019                env: EnvFingerprint {
2020                    rustc_version: "rustc".into(),
2021                    cargo_lock_hash: "lock".into(),
2022                    target_triple: "x86_64-unknown-linux-gnu".into(),
2023                    os: "linux".into(),
2024                },
2025                outcome: Outcome {
2026                    success: true,
2027                    validation_profile: "oris-default".into(),
2028                    validation_duration_ms: 1,
2029                    changed_files: vec!["README.md".into()],
2030                    validator_hash: "v".into(),
2031                    lines_changed: 1,
2032                    replay_verified: false,
2033                },
2034                state: AssetState::Promoted,
2035            }],
2036            reuse_counts: BTreeMap::from([("gene-stale".into(), 2)]),
2037            attempt_counts: BTreeMap::from([("gene-stale".into(), 1)]),
2038            last_updated_at: BTreeMap::from([(
2039                "gene-stale".into(),
2040                (now - chrono::Duration::hours(48)).to_rfc3339(),
2041            )]),
2042            spec_ids_by_gene: BTreeMap::new(),
2043        };
2044        let selector = ProjectionSelector::with_now(projection, now);
2045        let input = SelectorInput {
2046            signals: vec!["missing readme".into()],
2047            env: EnvFingerprint {
2048                rustc_version: "rustc".into(),
2049                cargo_lock_hash: "lock".into(),
2050                target_triple: "x86_64-unknown-linux-gnu".into(),
2051                os: "linux".into(),
2052            },
2053            spec_id: None,
2054            limit: 1,
2055        };
2056
2057        let selected = selector.select(&input);
2058
2059        assert!(selected.is_empty());
2060        assert!(decayed_replay_confidence(0.8, Some(48 * 60 * 60)) < MIN_REPLAY_CONFIDENCE);
2061    }
2062
2063    #[test]
2064    fn legacy_capsule_reused_events_deserialize_without_replay_run_id() {
2065        let serialized = r#"{
2066  "seq": 1,
2067  "timestamp": "2026-03-04T00:00:00Z",
2068  "prev_hash": "",
2069  "record_hash": "hash",
2070  "event": {
2071    "kind": "capsule_reused",
2072    "capsule_id": "capsule-1",
2073    "gene_id": "gene-1",
2074    "run_id": "run-1"
2075  }
2076}"#;
2077
2078        let stored = serde_json::from_str::<StoredEvolutionEvent>(serialized).unwrap();
2079
2080        match stored.event {
2081            EvolutionEvent::CapsuleReused {
2082                capsule_id,
2083                gene_id,
2084                run_id,
2085                replay_run_id,
2086            } => {
2087                assert_eq!(capsule_id, "capsule-1");
2088                assert_eq!(gene_id, "gene-1");
2089                assert_eq!(run_id, "run-1");
2090                assert_eq!(replay_run_id, None);
2091            }
2092            other => panic!("unexpected event: {other:?}"),
2093        }
2094    }
2095
2096    #[test]
2097    fn legacy_remote_asset_imported_events_deserialize_without_sender_id() {
2098        let serialized = r#"{
2099  "seq": 1,
2100  "timestamp": "2026-03-04T00:00:00Z",
2101  "prev_hash": "",
2102  "record_hash": "hash",
2103  "event": {
2104    "kind": "remote_asset_imported",
2105    "source": "Remote",
2106    "asset_ids": ["gene-1"]
2107  }
2108}"#;
2109
2110        let stored = serde_json::from_str::<StoredEvolutionEvent>(serialized).unwrap();
2111
2112        match stored.event {
2113            EvolutionEvent::RemoteAssetImported {
2114                source,
2115                asset_ids,
2116                sender_id,
2117            } => {
2118                assert_eq!(source, CandidateSource::Remote);
2119                assert_eq!(asset_ids, vec!["gene-1"]);
2120                assert_eq!(sender_id, None);
2121            }
2122            other => panic!("unexpected event: {other:?}"),
2123        }
2124    }
2125
2126    #[test]
2127    fn normalized_signal_overlap_accepts_semantic_multisignal_variants() {
2128        let overlap = normalized_signal_overlap(
2129            &["missing readme".into(), "route beijing shanghai".into()],
2130            &[
2131                "README file absent".into(),
2132                "travel route beijing shanghai".into(),
2133            ],
2134        );
2135
2136        assert!(overlap >= 0.99, "expected strong overlap, got {overlap}");
2137    }
2138
2139    #[test]
2140    fn normalized_signal_overlap_rejects_single_shared_token_false_positives() {
2141        let overlap =
2142            normalized_signal_overlap(&["missing readme".into()], &["missing cargo".into()]);
2143
2144        assert_eq!(overlap, 0.0);
2145    }
2146}