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 },
211 SpecLinked {
212 mutation_id: MutationId,
213 spec_id: String,
214 },
215}
216
217#[derive(Clone, Debug, Serialize, Deserialize)]
218pub struct StoredEvolutionEvent {
219 pub seq: u64,
220 pub timestamp: String,
221 pub prev_hash: String,
222 pub record_hash: String,
223 pub event: EvolutionEvent,
224}
225
226#[derive(Clone, Debug, Default, Serialize, Deserialize)]
227pub struct EvolutionProjection {
228 pub genes: Vec<Gene>,
229 pub capsules: Vec<Capsule>,
230 pub reuse_counts: BTreeMap<GeneId, u64>,
231 pub attempt_counts: BTreeMap<GeneId, u64>,
232 pub last_updated_at: BTreeMap<GeneId, String>,
233 pub spec_ids_by_gene: BTreeMap<GeneId, BTreeSet<String>>,
234}
235
236#[derive(Clone, Debug)]
237pub struct SelectorInput {
238 pub signals: Vec<String>,
239 pub env: EnvFingerprint,
240 pub spec_id: Option<String>,
241 pub limit: usize,
242}
243
244#[derive(Clone, Debug)]
245pub struct GeneCandidate {
246 pub gene: Gene,
247 pub score: f32,
248 pub capsules: Vec<Capsule>,
249}
250
251pub trait Selector: Send + Sync {
252 fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate>;
253}
254
255pub trait EvolutionStore: Send + Sync {
256 fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError>;
257 fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError>;
258 fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError>;
259}
260
261#[derive(Debug, Error)]
262pub enum EvolutionError {
263 #[error("I/O error: {0}")]
264 Io(String),
265 #[error("Serialization error: {0}")]
266 Serde(String),
267 #[error("Hash chain validation failed: {0}")]
268 HashChain(String),
269}
270
271pub struct JsonlEvolutionStore {
272 root_dir: PathBuf,
273 lock: Mutex<()>,
274}
275
276impl JsonlEvolutionStore {
277 pub fn new<P: Into<PathBuf>>(root_dir: P) -> Self {
278 Self {
279 root_dir: root_dir.into(),
280 lock: Mutex::new(()),
281 }
282 }
283
284 pub fn root_dir(&self) -> &Path {
285 &self.root_dir
286 }
287
288 fn ensure_layout(&self) -> Result<(), EvolutionError> {
289 fs::create_dir_all(&self.root_dir).map_err(io_err)?;
290 let lock_path = self.root_dir.join("LOCK");
291 if !lock_path.exists() {
292 File::create(lock_path).map_err(io_err)?;
293 }
294 let events_path = self.events_path();
295 if !events_path.exists() {
296 File::create(events_path).map_err(io_err)?;
297 }
298 Ok(())
299 }
300
301 fn events_path(&self) -> PathBuf {
302 self.root_dir.join("events.jsonl")
303 }
304
305 fn genes_path(&self) -> PathBuf {
306 self.root_dir.join("genes.json")
307 }
308
309 fn capsules_path(&self) -> PathBuf {
310 self.root_dir.join("capsules.json")
311 }
312
313 fn read_all_events(&self) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
314 self.ensure_layout()?;
315 let file = File::open(self.events_path()).map_err(io_err)?;
316 let reader = BufReader::new(file);
317 let mut events = Vec::new();
318 for line in reader.lines() {
319 let line = line.map_err(io_err)?;
320 if line.trim().is_empty() {
321 continue;
322 }
323 let event = serde_json::from_str::<StoredEvolutionEvent>(&line)
324 .map_err(|err| EvolutionError::Serde(err.to_string()))?;
325 events.push(event);
326 }
327 verify_hash_chain(&events)?;
328 Ok(events)
329 }
330
331 fn write_projection_files(
332 &self,
333 projection: &EvolutionProjection,
334 ) -> Result<(), EvolutionError> {
335 write_json_atomic(&self.genes_path(), &projection.genes)?;
336 write_json_atomic(&self.capsules_path(), &projection.capsules)?;
337 Ok(())
338 }
339
340 fn append_event_locked(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
341 let existing = self.read_all_events()?;
342 let next_seq = existing.last().map(|entry| entry.seq + 1).unwrap_or(1);
343 let prev_hash = existing
344 .last()
345 .map(|entry| entry.record_hash.clone())
346 .unwrap_or_default();
347 let timestamp = Utc::now().to_rfc3339();
348 let record_hash = hash_record(next_seq, ×tamp, &prev_hash, &event)?;
349 let stored = StoredEvolutionEvent {
350 seq: next_seq,
351 timestamp,
352 prev_hash,
353 record_hash,
354 event,
355 };
356 let mut file = OpenOptions::new()
357 .create(true)
358 .append(true)
359 .open(self.events_path())
360 .map_err(io_err)?;
361 let line =
362 serde_json::to_string(&stored).map_err(|err| EvolutionError::Serde(err.to_string()))?;
363 file.write_all(line.as_bytes()).map_err(io_err)?;
364 file.write_all(b"\n").map_err(io_err)?;
365 file.sync_data().map_err(io_err)?;
366
367 let events = self.read_all_events()?;
368 let projection = rebuild_projection_from_events(&events);
369 self.write_projection_files(&projection)?;
370 Ok(next_seq)
371 }
372}
373
374impl EvolutionStore for JsonlEvolutionStore {
375 fn append_event(&self, event: EvolutionEvent) -> Result<u64, EvolutionError> {
376 let _guard = self
377 .lock
378 .lock()
379 .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
380 self.append_event_locked(event)
381 }
382
383 fn scan(&self, from_seq: u64) -> Result<Vec<StoredEvolutionEvent>, EvolutionError> {
384 let _guard = self
385 .lock
386 .lock()
387 .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
388 Ok(self
389 .read_all_events()?
390 .into_iter()
391 .filter(|entry| entry.seq >= from_seq)
392 .collect())
393 }
394
395 fn rebuild_projection(&self) -> Result<EvolutionProjection, EvolutionError> {
396 let _guard = self
397 .lock
398 .lock()
399 .map_err(|_| EvolutionError::Io("evolution store lock poisoned".into()))?;
400 let projection = rebuild_projection_from_events(&self.read_all_events()?);
401 self.write_projection_files(&projection)?;
402 Ok(projection)
403 }
404}
405
406pub struct ProjectionSelector {
407 projection: EvolutionProjection,
408 now: DateTime<Utc>,
409}
410
411impl ProjectionSelector {
412 pub fn new(projection: EvolutionProjection) -> Self {
413 Self {
414 projection,
415 now: Utc::now(),
416 }
417 }
418
419 pub fn with_now(projection: EvolutionProjection, now: DateTime<Utc>) -> Self {
420 Self { projection, now }
421 }
422}
423
424impl Selector for ProjectionSelector {
425 fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
426 let requested_spec_id = input
427 .spec_id
428 .as_deref()
429 .map(str::trim)
430 .filter(|value| !value.is_empty());
431 let mut out = Vec::new();
432 for gene in &self.projection.genes {
433 if gene.state != AssetState::Promoted {
434 continue;
435 }
436 if let Some(spec_id) = requested_spec_id {
437 let matches_spec = self
438 .projection
439 .spec_ids_by_gene
440 .get(&gene.id)
441 .map(|values| {
442 values
443 .iter()
444 .any(|value| value.eq_ignore_ascii_case(spec_id))
445 })
446 .unwrap_or(false);
447 if !matches_spec {
448 continue;
449 }
450 }
451 let capsules = self
452 .projection
453 .capsules
454 .iter()
455 .filter(|capsule| {
456 capsule.gene_id == gene.id && capsule.state == AssetState::Promoted
457 })
458 .cloned()
459 .collect::<Vec<_>>();
460 if capsules.is_empty() {
461 continue;
462 }
463 let mut capsules = capsules;
464 capsules.sort_by(|left, right| {
465 environment_match_factor(&input.env, &right.env)
466 .partial_cmp(&environment_match_factor(&input.env, &left.env))
467 .unwrap_or(std::cmp::Ordering::Equal)
468 .then_with(|| {
469 right
470 .confidence
471 .partial_cmp(&left.confidence)
472 .unwrap_or(std::cmp::Ordering::Equal)
473 })
474 .then_with(|| left.id.cmp(&right.id))
475 });
476 let env_match_factor = capsules
477 .first()
478 .map(|capsule| environment_match_factor(&input.env, &capsule.env))
479 .unwrap_or(0.0);
480
481 let successful_capsules = capsules.len() as f64;
482 let attempts = self
483 .projection
484 .attempt_counts
485 .get(&gene.id)
486 .copied()
487 .unwrap_or(capsules.len() as u64) as f64;
488 let success_rate = if attempts == 0.0 {
489 0.0
490 } else {
491 successful_capsules / attempts
492 };
493 let successful_reuses = self
494 .projection
495 .reuse_counts
496 .get(&gene.id)
497 .copied()
498 .unwrap_or(0) as f64;
499 let reuse_count_factor = 1.0 + (1.0 + successful_reuses).ln();
500 let signal_overlap = normalized_signal_overlap(&gene.signals, &input.signals);
501 let recency_decay = self
502 .projection
503 .last_updated_at
504 .get(&gene.id)
505 .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
506 .map(|dt| {
507 let age_days = (self.now - dt.with_timezone(&Utc)).num_days().max(0) as f64;
508 E.powf(-age_days / 30.0)
509 })
510 .unwrap_or(0.0);
511 let score = (success_rate
512 * reuse_count_factor
513 * env_match_factor
514 * recency_decay
515 * signal_overlap) as f32;
516 if score < 0.35 {
517 continue;
518 }
519 out.push(GeneCandidate {
520 gene: gene.clone(),
521 score,
522 capsules,
523 });
524 }
525
526 out.sort_by(|left, right| {
527 right
528 .score
529 .partial_cmp(&left.score)
530 .unwrap_or(std::cmp::Ordering::Equal)
531 .then_with(|| left.gene.id.cmp(&right.gene.id))
532 });
533 out.truncate(input.limit.max(1));
534 out
535 }
536}
537
538pub struct StoreBackedSelector {
539 store: std::sync::Arc<dyn EvolutionStore>,
540}
541
542impl StoreBackedSelector {
543 pub fn new(store: std::sync::Arc<dyn EvolutionStore>) -> Self {
544 Self { store }
545 }
546}
547
548impl Selector for StoreBackedSelector {
549 fn select(&self, input: &SelectorInput) -> Vec<GeneCandidate> {
550 match self.store.rebuild_projection() {
551 Ok(projection) => ProjectionSelector::new(projection).select(input),
552 Err(_) => Vec::new(),
553 }
554 }
555}
556
557pub fn rebuild_projection_from_events(events: &[StoredEvolutionEvent]) -> EvolutionProjection {
558 let mut genes = BTreeMap::<GeneId, Gene>::new();
559 let mut capsules = BTreeMap::<CapsuleId, Capsule>::new();
560 let mut reuse_counts = BTreeMap::<GeneId, u64>::new();
561 let mut attempt_counts = BTreeMap::<GeneId, u64>::new();
562 let mut last_updated_at = BTreeMap::<GeneId, String>::new();
563 let mut spec_ids_by_gene = BTreeMap::<GeneId, BTreeSet<String>>::new();
564 let mut mutation_to_gene = HashMap::<MutationId, GeneId>::new();
565 let mut mutation_spec_ids = HashMap::<MutationId, String>::new();
566
567 for stored in events {
568 match &stored.event {
569 EvolutionEvent::MutationDeclared { mutation } => {
570 if let Some(spec_id) = mutation
571 .intent
572 .spec_id
573 .as_ref()
574 .map(|value| value.trim())
575 .filter(|value| !value.is_empty())
576 {
577 mutation_spec_ids.insert(mutation.intent.id.clone(), spec_id.to_string());
578 }
579 }
580 EvolutionEvent::GeneProjected { gene } => {
581 genes.insert(gene.id.clone(), gene.clone());
582 last_updated_at.insert(gene.id.clone(), stored.timestamp.clone());
583 }
584 EvolutionEvent::GenePromoted { gene_id } => {
585 if let Some(gene) = genes.get_mut(gene_id) {
586 gene.state = AssetState::Promoted;
587 }
588 last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
589 }
590 EvolutionEvent::GeneRevoked { gene_id, .. } => {
591 if let Some(gene) = genes.get_mut(gene_id) {
592 gene.state = AssetState::Revoked;
593 }
594 last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
595 }
596 EvolutionEvent::GeneArchived { gene_id } => {
597 if let Some(gene) = genes.get_mut(gene_id) {
598 gene.state = AssetState::Archived;
599 }
600 last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
601 }
602 EvolutionEvent::PromotionEvaluated { gene_id, state, .. } => {
603 if let Some(gene) = genes.get_mut(gene_id) {
604 gene.state = state.clone();
605 }
606 last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
607 }
608 EvolutionEvent::CapsuleCommitted { capsule } => {
609 mutation_to_gene.insert(capsule.mutation_id.clone(), capsule.gene_id.clone());
610 capsules.insert(capsule.id.clone(), capsule.clone());
611 *attempt_counts.entry(capsule.gene_id.clone()).or_insert(0) += 1;
612 if let Some(spec_id) = mutation_spec_ids.get(&capsule.mutation_id) {
613 spec_ids_by_gene
614 .entry(capsule.gene_id.clone())
615 .or_default()
616 .insert(spec_id.clone());
617 }
618 last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
619 }
620 EvolutionEvent::CapsuleQuarantined { capsule_id } => {
621 if let Some(capsule) = capsules.get_mut(capsule_id) {
622 capsule.state = AssetState::Quarantined;
623 last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
624 }
625 }
626 EvolutionEvent::CapsuleReleased { capsule_id, state } => {
627 if let Some(capsule) = capsules.get_mut(capsule_id) {
628 capsule.state = state.clone();
629 last_updated_at.insert(capsule.gene_id.clone(), stored.timestamp.clone());
630 }
631 }
632 EvolutionEvent::CapsuleReused { gene_id, .. } => {
633 *reuse_counts.entry(gene_id.clone()).or_insert(0) += 1;
634 last_updated_at.insert(gene_id.clone(), stored.timestamp.clone());
635 }
636 EvolutionEvent::ValidationFailed {
637 mutation_id,
638 gene_id,
639 ..
640 } => {
641 let id = gene_id
642 .clone()
643 .or_else(|| mutation_to_gene.get(mutation_id).cloned());
644 if let Some(gene_id) = id {
645 *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
646 last_updated_at.insert(gene_id, stored.timestamp.clone());
647 }
648 }
649 EvolutionEvent::ValidationPassed {
650 mutation_id,
651 gene_id,
652 ..
653 } => {
654 let id = gene_id
655 .clone()
656 .or_else(|| mutation_to_gene.get(mutation_id).cloned());
657 if let Some(gene_id) = id {
658 *attempt_counts.entry(gene_id.clone()).or_insert(0) += 1;
659 last_updated_at.insert(gene_id, stored.timestamp.clone());
660 }
661 }
662 _ => {}
663 }
664 }
665
666 EvolutionProjection {
667 genes: genes.into_values().collect(),
668 capsules: capsules.into_values().collect(),
669 reuse_counts,
670 attempt_counts,
671 last_updated_at,
672 spec_ids_by_gene,
673 }
674}
675
676pub fn default_store_root() -> PathBuf {
677 PathBuf::from(".oris").join("evolution")
678}
679
680pub fn hash_string(input: &str) -> String {
681 let mut hasher = Sha256::new();
682 hasher.update(input.as_bytes());
683 hex::encode(hasher.finalize())
684}
685
686pub fn stable_hash_json<T: Serialize>(value: &T) -> Result<String, EvolutionError> {
687 let bytes = serde_json::to_vec(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
688 let mut hasher = Sha256::new();
689 hasher.update(bytes);
690 Ok(hex::encode(hasher.finalize()))
691}
692
693pub fn compute_artifact_hash(payload: &str) -> String {
694 hash_string(payload)
695}
696
697pub fn next_id(prefix: &str) -> String {
698 let nanos = SystemTime::now()
699 .duration_since(UNIX_EPOCH)
700 .unwrap_or_default()
701 .as_nanos();
702 format!("{prefix}-{nanos:x}")
703}
704
705fn normalized_signal_overlap(gene_signals: &[String], input_signals: &[String]) -> f64 {
706 if input_signals.is_empty() {
707 return 0.0;
708 }
709 let gene = gene_signals
710 .iter()
711 .map(|signal| signal.to_ascii_lowercase())
712 .collect::<BTreeSet<_>>();
713 let input = input_signals
714 .iter()
715 .map(|signal| signal.to_ascii_lowercase())
716 .collect::<BTreeSet<_>>();
717 let matched = input.iter().filter(|signal| gene.contains(*signal)).count() as f64;
718 matched / input.len() as f64
719}
720
721fn environment_match_factor(input: &EnvFingerprint, candidate: &EnvFingerprint) -> f64 {
722 let fields = [
723 input
724 .rustc_version
725 .eq_ignore_ascii_case(&candidate.rustc_version),
726 input
727 .cargo_lock_hash
728 .eq_ignore_ascii_case(&candidate.cargo_lock_hash),
729 input
730 .target_triple
731 .eq_ignore_ascii_case(&candidate.target_triple),
732 input.os.eq_ignore_ascii_case(&candidate.os),
733 ];
734 let matched_fields = fields.into_iter().filter(|matched| *matched).count() as f64;
735 0.5 + ((matched_fields / 4.0) * 0.5)
736}
737
738fn hash_record(
739 seq: u64,
740 timestamp: &str,
741 prev_hash: &str,
742 event: &EvolutionEvent,
743) -> Result<String, EvolutionError> {
744 stable_hash_json(&(seq, timestamp, prev_hash, event))
745}
746
747fn verify_hash_chain(events: &[StoredEvolutionEvent]) -> Result<(), EvolutionError> {
748 let mut previous_hash = String::new();
749 let mut expected_seq = 1u64;
750 for event in events {
751 if event.seq != expected_seq {
752 return Err(EvolutionError::HashChain(format!(
753 "expected seq {}, found {}",
754 expected_seq, event.seq
755 )));
756 }
757 if event.prev_hash != previous_hash {
758 return Err(EvolutionError::HashChain(format!(
759 "event {} prev_hash mismatch",
760 event.seq
761 )));
762 }
763 let actual_hash = hash_record(event.seq, &event.timestamp, &event.prev_hash, &event.event)?;
764 if actual_hash != event.record_hash {
765 return Err(EvolutionError::HashChain(format!(
766 "event {} record_hash mismatch",
767 event.seq
768 )));
769 }
770 previous_hash = event.record_hash.clone();
771 expected_seq += 1;
772 }
773 Ok(())
774}
775
776fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), EvolutionError> {
777 let tmp_path = path.with_extension("tmp");
778 let bytes =
779 serde_json::to_vec_pretty(value).map_err(|err| EvolutionError::Serde(err.to_string()))?;
780 fs::write(&tmp_path, bytes).map_err(io_err)?;
781 fs::rename(&tmp_path, path).map_err(io_err)?;
782 Ok(())
783}
784
785fn io_err(err: std::io::Error) -> EvolutionError {
786 EvolutionError::Io(err.to_string())
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 fn temp_root(name: &str) -> PathBuf {
794 std::env::temp_dir().join(format!("oris-evolution-{name}-{}", next_id("t")))
795 }
796
797 fn sample_mutation() -> PreparedMutation {
798 PreparedMutation {
799 intent: MutationIntent {
800 id: "mutation-1".into(),
801 intent: "tighten borrow scope".into(),
802 target: MutationTarget::Paths {
803 allow: vec!["crates/oris-kernel".into()],
804 },
805 expected_effect: "cargo check passes".into(),
806 risk: RiskLevel::Low,
807 signals: vec!["rust borrow error".into()],
808 spec_id: None,
809 },
810 artifact: MutationArtifact {
811 encoding: ArtifactEncoding::UnifiedDiff,
812 payload: "diff --git a/foo b/foo".into(),
813 base_revision: Some("HEAD".into()),
814 content_hash: compute_artifact_hash("diff --git a/foo b/foo"),
815 },
816 }
817 }
818
819 #[test]
820 fn append_event_assigns_monotonic_seq() {
821 let root = temp_root("seq");
822 let store = JsonlEvolutionStore::new(root);
823 let first = store
824 .append_event(EvolutionEvent::MutationDeclared {
825 mutation: sample_mutation(),
826 })
827 .unwrap();
828 let second = store
829 .append_event(EvolutionEvent::MutationRejected {
830 mutation_id: "mutation-1".into(),
831 reason: "no-op".into(),
832 })
833 .unwrap();
834 assert_eq!(first, 1);
835 assert_eq!(second, 2);
836 }
837
838 #[test]
839 fn tampered_hash_chain_is_rejected() {
840 let root = temp_root("tamper");
841 let store = JsonlEvolutionStore::new(&root);
842 store
843 .append_event(EvolutionEvent::MutationDeclared {
844 mutation: sample_mutation(),
845 })
846 .unwrap();
847 let path = root.join("events.jsonl");
848 let contents = fs::read_to_string(&path).unwrap();
849 let mutated = contents.replace("tighten borrow scope", "tampered");
850 fs::write(&path, mutated).unwrap();
851 let result = store.scan(1);
852 assert!(matches!(result, Err(EvolutionError::HashChain(_))));
853 }
854
855 #[test]
856 fn rebuild_projection_after_cache_deletion() {
857 let root = temp_root("projection");
858 let store = JsonlEvolutionStore::new(&root);
859 let gene = Gene {
860 id: "gene-1".into(),
861 signals: vec!["rust borrow error".into()],
862 strategy: vec!["crates".into()],
863 validation: vec!["oris-default".into()],
864 state: AssetState::Promoted,
865 };
866 let capsule = Capsule {
867 id: "capsule-1".into(),
868 gene_id: gene.id.clone(),
869 mutation_id: "mutation-1".into(),
870 run_id: "run-1".into(),
871 diff_hash: "abc".into(),
872 confidence: 0.7,
873 env: EnvFingerprint {
874 rustc_version: "rustc 1.80".into(),
875 cargo_lock_hash: "lock".into(),
876 target_triple: "x86_64-unknown-linux-gnu".into(),
877 os: "linux".into(),
878 },
879 outcome: Outcome {
880 success: true,
881 validation_profile: "oris-default".into(),
882 validation_duration_ms: 100,
883 changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
884 validator_hash: "vh".into(),
885 lines_changed: 1,
886 replay_verified: false,
887 },
888 state: AssetState::Promoted,
889 };
890 store
891 .append_event(EvolutionEvent::GeneProjected { gene })
892 .unwrap();
893 store
894 .append_event(EvolutionEvent::CapsuleCommitted { capsule })
895 .unwrap();
896 fs::remove_file(root.join("genes.json")).unwrap();
897 fs::remove_file(root.join("capsules.json")).unwrap();
898 let projection = store.rebuild_projection().unwrap();
899 assert_eq!(projection.genes.len(), 1);
900 assert_eq!(projection.capsules.len(), 1);
901 }
902
903 #[test]
904 fn rebuild_projection_tracks_spec_ids_for_genes() {
905 let root = temp_root("projection-spec");
906 let store = JsonlEvolutionStore::new(&root);
907 let mut mutation = sample_mutation();
908 mutation.intent.id = "mutation-spec".into();
909 mutation.intent.spec_id = Some("spec-repair-1".into());
910 let gene = Gene {
911 id: "gene-spec".into(),
912 signals: vec!["rust borrow error".into()],
913 strategy: vec!["crates".into()],
914 validation: vec!["oris-default".into()],
915 state: AssetState::Promoted,
916 };
917 let capsule = Capsule {
918 id: "capsule-spec".into(),
919 gene_id: gene.id.clone(),
920 mutation_id: mutation.intent.id.clone(),
921 run_id: "run-spec".into(),
922 diff_hash: "abc".into(),
923 confidence: 0.7,
924 env: EnvFingerprint {
925 rustc_version: "rustc 1.80".into(),
926 cargo_lock_hash: "lock".into(),
927 target_triple: "x86_64-unknown-linux-gnu".into(),
928 os: "linux".into(),
929 },
930 outcome: Outcome {
931 success: true,
932 validation_profile: "oris-default".into(),
933 validation_duration_ms: 100,
934 changed_files: vec!["crates/oris-kernel/src/lib.rs".into()],
935 validator_hash: "vh".into(),
936 lines_changed: 1,
937 replay_verified: false,
938 },
939 state: AssetState::Promoted,
940 };
941 store
942 .append_event(EvolutionEvent::MutationDeclared { mutation })
943 .unwrap();
944 store
945 .append_event(EvolutionEvent::GeneProjected { gene })
946 .unwrap();
947 store
948 .append_event(EvolutionEvent::CapsuleCommitted { capsule })
949 .unwrap();
950
951 let projection = store.rebuild_projection().unwrap();
952 let spec_ids = projection.spec_ids_by_gene.get("gene-spec").unwrap();
953 assert!(spec_ids.contains("spec-repair-1"));
954 }
955
956 #[test]
957 fn selector_orders_results_stably() {
958 let projection = EvolutionProjection {
959 genes: vec![
960 Gene {
961 id: "gene-a".into(),
962 signals: vec!["signal".into()],
963 strategy: vec!["a".into()],
964 validation: vec!["oris-default".into()],
965 state: AssetState::Promoted,
966 },
967 Gene {
968 id: "gene-b".into(),
969 signals: vec!["signal".into()],
970 strategy: vec!["b".into()],
971 validation: vec!["oris-default".into()],
972 state: AssetState::Promoted,
973 },
974 ],
975 capsules: vec![
976 Capsule {
977 id: "capsule-a".into(),
978 gene_id: "gene-a".into(),
979 mutation_id: "m1".into(),
980 run_id: "r1".into(),
981 diff_hash: "1".into(),
982 confidence: 0.7,
983 env: EnvFingerprint {
984 rustc_version: "rustc".into(),
985 cargo_lock_hash: "lock".into(),
986 target_triple: "x86_64-unknown-linux-gnu".into(),
987 os: "linux".into(),
988 },
989 outcome: Outcome {
990 success: true,
991 validation_profile: "oris-default".into(),
992 validation_duration_ms: 1,
993 changed_files: vec!["crates/oris-kernel".into()],
994 validator_hash: "v".into(),
995 lines_changed: 1,
996 replay_verified: false,
997 },
998 state: AssetState::Promoted,
999 },
1000 Capsule {
1001 id: "capsule-b".into(),
1002 gene_id: "gene-b".into(),
1003 mutation_id: "m2".into(),
1004 run_id: "r2".into(),
1005 diff_hash: "2".into(),
1006 confidence: 0.7,
1007 env: EnvFingerprint {
1008 rustc_version: "rustc".into(),
1009 cargo_lock_hash: "lock".into(),
1010 target_triple: "x86_64-unknown-linux-gnu".into(),
1011 os: "linux".into(),
1012 },
1013 outcome: Outcome {
1014 success: true,
1015 validation_profile: "oris-default".into(),
1016 validation_duration_ms: 1,
1017 changed_files: vec!["crates/oris-kernel".into()],
1018 validator_hash: "v".into(),
1019 lines_changed: 1,
1020 replay_verified: false,
1021 },
1022 state: AssetState::Promoted,
1023 },
1024 ],
1025 reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1026 attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1027 last_updated_at: BTreeMap::from([
1028 ("gene-a".into(), Utc::now().to_rfc3339()),
1029 ("gene-b".into(), Utc::now().to_rfc3339()),
1030 ]),
1031 spec_ids_by_gene: BTreeMap::new(),
1032 };
1033 let selector = ProjectionSelector::new(projection);
1034 let input = SelectorInput {
1035 signals: vec!["signal".into()],
1036 env: EnvFingerprint {
1037 rustc_version: "rustc".into(),
1038 cargo_lock_hash: "lock".into(),
1039 target_triple: "x86_64-unknown-linux-gnu".into(),
1040 os: "linux".into(),
1041 },
1042 spec_id: None,
1043 limit: 2,
1044 };
1045 let first = selector.select(&input);
1046 let second = selector.select(&input);
1047 assert_eq!(first.len(), 2);
1048 assert_eq!(
1049 first
1050 .iter()
1051 .map(|candidate| candidate.gene.id.clone())
1052 .collect::<Vec<_>>(),
1053 second
1054 .iter()
1055 .map(|candidate| candidate.gene.id.clone())
1056 .collect::<Vec<_>>()
1057 );
1058 }
1059
1060 #[test]
1061 fn selector_can_narrow_by_spec_id() {
1062 let projection = EvolutionProjection {
1063 genes: vec![
1064 Gene {
1065 id: "gene-a".into(),
1066 signals: vec!["signal".into()],
1067 strategy: vec!["a".into()],
1068 validation: vec!["oris-default".into()],
1069 state: AssetState::Promoted,
1070 },
1071 Gene {
1072 id: "gene-b".into(),
1073 signals: vec!["signal".into()],
1074 strategy: vec!["b".into()],
1075 validation: vec!["oris-default".into()],
1076 state: AssetState::Promoted,
1077 },
1078 ],
1079 capsules: vec![
1080 Capsule {
1081 id: "capsule-a".into(),
1082 gene_id: "gene-a".into(),
1083 mutation_id: "m1".into(),
1084 run_id: "r1".into(),
1085 diff_hash: "1".into(),
1086 confidence: 0.7,
1087 env: EnvFingerprint {
1088 rustc_version: "rustc".into(),
1089 cargo_lock_hash: "lock".into(),
1090 target_triple: "x86_64-unknown-linux-gnu".into(),
1091 os: "linux".into(),
1092 },
1093 outcome: Outcome {
1094 success: true,
1095 validation_profile: "oris-default".into(),
1096 validation_duration_ms: 1,
1097 changed_files: vec!["crates/oris-kernel".into()],
1098 validator_hash: "v".into(),
1099 lines_changed: 1,
1100 replay_verified: false,
1101 },
1102 state: AssetState::Promoted,
1103 },
1104 Capsule {
1105 id: "capsule-b".into(),
1106 gene_id: "gene-b".into(),
1107 mutation_id: "m2".into(),
1108 run_id: "r2".into(),
1109 diff_hash: "2".into(),
1110 confidence: 0.7,
1111 env: EnvFingerprint {
1112 rustc_version: "rustc".into(),
1113 cargo_lock_hash: "lock".into(),
1114 target_triple: "x86_64-unknown-linux-gnu".into(),
1115 os: "linux".into(),
1116 },
1117 outcome: Outcome {
1118 success: true,
1119 validation_profile: "oris-default".into(),
1120 validation_duration_ms: 1,
1121 changed_files: vec!["crates/oris-kernel".into()],
1122 validator_hash: "v".into(),
1123 lines_changed: 1,
1124 replay_verified: false,
1125 },
1126 state: AssetState::Promoted,
1127 },
1128 ],
1129 reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1130 attempt_counts: BTreeMap::from([("gene-a".into(), 1), ("gene-b".into(), 1)]),
1131 last_updated_at: BTreeMap::from([
1132 ("gene-a".into(), Utc::now().to_rfc3339()),
1133 ("gene-b".into(), Utc::now().to_rfc3339()),
1134 ]),
1135 spec_ids_by_gene: BTreeMap::from([
1136 ("gene-a".into(), BTreeSet::from(["spec-a".to_string()])),
1137 ("gene-b".into(), BTreeSet::from(["spec-b".to_string()])),
1138 ]),
1139 };
1140 let selector = ProjectionSelector::new(projection);
1141 let input = SelectorInput {
1142 signals: vec!["signal".into()],
1143 env: EnvFingerprint {
1144 rustc_version: "rustc".into(),
1145 cargo_lock_hash: "lock".into(),
1146 target_triple: "x86_64-unknown-linux-gnu".into(),
1147 os: "linux".into(),
1148 },
1149 spec_id: Some("spec-b".into()),
1150 limit: 2,
1151 };
1152 let selected = selector.select(&input);
1153 assert_eq!(selected.len(), 1);
1154 assert_eq!(selected[0].gene.id, "gene-b");
1155 }
1156
1157 #[test]
1158 fn selector_prefers_closest_environment_match() {
1159 let projection = EvolutionProjection {
1160 genes: vec![
1161 Gene {
1162 id: "gene-a".into(),
1163 signals: vec!["signal".into()],
1164 strategy: vec!["a".into()],
1165 validation: vec!["oris-default".into()],
1166 state: AssetState::Promoted,
1167 },
1168 Gene {
1169 id: "gene-b".into(),
1170 signals: vec!["signal".into()],
1171 strategy: vec!["b".into()],
1172 validation: vec!["oris-default".into()],
1173 state: AssetState::Promoted,
1174 },
1175 ],
1176 capsules: vec![
1177 Capsule {
1178 id: "capsule-a-stale".into(),
1179 gene_id: "gene-a".into(),
1180 mutation_id: "m1".into(),
1181 run_id: "r1".into(),
1182 diff_hash: "1".into(),
1183 confidence: 0.2,
1184 env: EnvFingerprint {
1185 rustc_version: "old-rustc".into(),
1186 cargo_lock_hash: "other-lock".into(),
1187 target_triple: "aarch64-apple-darwin".into(),
1188 os: "macos".into(),
1189 },
1190 outcome: Outcome {
1191 success: true,
1192 validation_profile: "oris-default".into(),
1193 validation_duration_ms: 1,
1194 changed_files: vec!["crates/oris-kernel".into()],
1195 validator_hash: "v".into(),
1196 lines_changed: 1,
1197 replay_verified: false,
1198 },
1199 state: AssetState::Promoted,
1200 },
1201 Capsule {
1202 id: "capsule-a-best".into(),
1203 gene_id: "gene-a".into(),
1204 mutation_id: "m2".into(),
1205 run_id: "r2".into(),
1206 diff_hash: "2".into(),
1207 confidence: 0.9,
1208 env: EnvFingerprint {
1209 rustc_version: "rustc".into(),
1210 cargo_lock_hash: "lock".into(),
1211 target_triple: "x86_64-unknown-linux-gnu".into(),
1212 os: "linux".into(),
1213 },
1214 outcome: Outcome {
1215 success: true,
1216 validation_profile: "oris-default".into(),
1217 validation_duration_ms: 1,
1218 changed_files: vec!["crates/oris-kernel".into()],
1219 validator_hash: "v".into(),
1220 lines_changed: 1,
1221 replay_verified: false,
1222 },
1223 state: AssetState::Promoted,
1224 },
1225 Capsule {
1226 id: "capsule-b".into(),
1227 gene_id: "gene-b".into(),
1228 mutation_id: "m3".into(),
1229 run_id: "r3".into(),
1230 diff_hash: "3".into(),
1231 confidence: 0.7,
1232 env: EnvFingerprint {
1233 rustc_version: "rustc".into(),
1234 cargo_lock_hash: "different-lock".into(),
1235 target_triple: "x86_64-unknown-linux-gnu".into(),
1236 os: "linux".into(),
1237 },
1238 outcome: Outcome {
1239 success: true,
1240 validation_profile: "oris-default".into(),
1241 validation_duration_ms: 1,
1242 changed_files: vec!["crates/oris-kernel".into()],
1243 validator_hash: "v".into(),
1244 lines_changed: 1,
1245 replay_verified: false,
1246 },
1247 state: AssetState::Promoted,
1248 },
1249 ],
1250 reuse_counts: BTreeMap::from([("gene-a".into(), 3), ("gene-b".into(), 3)]),
1251 attempt_counts: BTreeMap::from([("gene-a".into(), 2), ("gene-b".into(), 1)]),
1252 last_updated_at: BTreeMap::from([
1253 ("gene-a".into(), Utc::now().to_rfc3339()),
1254 ("gene-b".into(), Utc::now().to_rfc3339()),
1255 ]),
1256 spec_ids_by_gene: BTreeMap::new(),
1257 };
1258 let selector = ProjectionSelector::new(projection);
1259 let input = SelectorInput {
1260 signals: vec!["signal".into()],
1261 env: EnvFingerprint {
1262 rustc_version: "rustc".into(),
1263 cargo_lock_hash: "lock".into(),
1264 target_triple: "x86_64-unknown-linux-gnu".into(),
1265 os: "linux".into(),
1266 },
1267 spec_id: None,
1268 limit: 2,
1269 };
1270
1271 let selected = selector.select(&input);
1272
1273 assert_eq!(selected.len(), 2);
1274 assert_eq!(selected[0].gene.id, "gene-a");
1275 assert_eq!(selected[0].capsules[0].id, "capsule-a-best");
1276 assert!(selected[0].score > selected[1].score);
1277 }
1278
1279 #[test]
1280 fn legacy_capsule_reused_events_deserialize_without_replay_run_id() {
1281 let serialized = r#"{
1282 "seq": 1,
1283 "timestamp": "2026-03-04T00:00:00Z",
1284 "prev_hash": "",
1285 "record_hash": "hash",
1286 "event": {
1287 "kind": "capsule_reused",
1288 "capsule_id": "capsule-1",
1289 "gene_id": "gene-1",
1290 "run_id": "run-1"
1291 }
1292}"#;
1293
1294 let stored = serde_json::from_str::<StoredEvolutionEvent>(serialized).unwrap();
1295
1296 match stored.event {
1297 EvolutionEvent::CapsuleReused {
1298 capsule_id,
1299 gene_id,
1300 run_id,
1301 replay_run_id,
1302 } => {
1303 assert_eq!(capsule_id, "capsule-1");
1304 assert_eq!(gene_id, "gene-1");
1305 assert_eq!(run_id, "run-1");
1306 assert_eq!(replay_run_id, None);
1307 }
1308 other => panic!("unexpected event: {other:?}"),
1309 }
1310 }
1311}