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