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