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