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