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