1use std::collections::{HashMap, HashSet};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest as ShaDigest, Sha256};
11use thiserror::Error;
12
13use crate::context::execution::ContextExecutionInput;
14
15pub const EVOLUTION_SCHEMA: &str = "evolution/v1";
16pub const EVOLUTION_REPORT_SCHEMA: &str = "evolution-report/v1";
17
18pub fn validate_evolution_json(request: &str) -> Result<String, String> {
23 let bundle: EvolutionBundle = serde_json::from_str(request)
24 .map_err(|error| format!("invalid evolution bundle: {error}"))?;
25 serde_json::to_string(&validate_evolution(&bundle))
26 .map_err(|error| format!("could not encode evolution report: {error}"))
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30#[serde(try_from = "String", into = "String")]
31pub struct ContentDigest(String);
32
33impl ContentDigest {
34 pub fn parse(value: impl Into<String>) -> Result<Self, EvolutionError> {
35 let value = value.into();
36 let valid = value.len() == 71
37 && value.starts_with("sha256:")
38 && value[7..].bytes().all(|byte| byte.is_ascii_hexdigit());
39 if valid {
40 Ok(Self(value.to_ascii_lowercase()))
41 } else {
42 Err(EvolutionError::InvalidDigest(value))
43 }
44 }
45
46 pub fn from_bytes(bytes: &[u8]) -> Self {
47 let mut hasher = Sha256::new();
48 hasher.update(bytes);
49 Self(format!("sha256:{:x}", hasher.finalize()))
50 }
51
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57impl TryFrom<String> for ContentDigest {
58 type Error = EvolutionError;
59
60 fn try_from(value: String) -> Result<Self, Self::Error> {
61 Self::parse(value)
62 }
63}
64
65impl From<ContentDigest> for String {
66 fn from(value: ContentDigest) -> Self {
67 value.0
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct ArtifactSetBinding {
79 pub artifact_set_digest: ContentDigest,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub promotion_decision_digest: Option<ContentDigest>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub lineage_root_digest: Option<ContentDigest>,
84}
85
86impl ArtifactSetBinding {
87 pub fn new(artifact_set_digest: ContentDigest) -> Self {
88 Self {
89 artifact_set_digest,
90 promotion_decision_digest: None,
91 lineage_root_digest: None,
92 }
93 }
94}
95
96impl std::fmt::Display for ContentDigest {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str(&self.0)
99 }
100}
101
102#[derive(Debug, Error, Clone, PartialEq, Eq)]
103pub enum EvolutionError {
104 #[error("invalid content digest: {0}")]
105 InvalidDigest(String),
106 #[error("canonical evolution object could not be serialized: {0}")]
107 Serialization(String),
108 #[error("duplicate artifact reference: {0}")]
109 DuplicateArtifact(ContentDigest),
110 #[error("artifact set must contain at least one artifact")]
111 EmptyArtifactSet,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum ArtifactKind {
117 Runtime,
118 Skill,
119 Prompt,
120 Policy,
121 Toolset,
122 Bundle,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
126pub struct ArtifactRef {
127 pub kind: ArtifactKind,
128 pub digest: ContentDigest,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ArtifactManifest {
133 pub kind: ArtifactKind,
134 pub payload_digest: ContentDigest,
135 pub parents: Vec<ContentDigest>,
136 pub toolchain_digest: ContentDigest,
137 pub scope: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct ArtifactVersion {
142 pub digest: ContentDigest,
143 pub manifest: ArtifactManifest,
144}
145
146impl ArtifactVersion {
147 pub fn from_manifest(manifest: ArtifactManifest) -> Result<Self, EvolutionError> {
148 let digest = canonical_digest(&manifest)?;
149 Ok(Self { digest, manifest })
150 }
151
152 pub fn reference(&self) -> ArtifactRef {
153 ArtifactRef {
154 kind: self.manifest.kind,
155 digest: self.digest.clone(),
156 }
157 }
158
159 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
160 let expected = canonical_digest(&self.manifest)?;
161 if expected != self.digest {
162 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
163 }
164 Ok(())
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct ArtifactSet {
170 pub digest: ContentDigest,
171 pub artifacts: Vec<ArtifactRef>,
172}
173
174impl ArtifactSet {
175 pub fn new(mut artifacts: Vec<ArtifactRef>) -> Result<Self, EvolutionError> {
176 if artifacts.is_empty() {
177 return Err(EvolutionError::EmptyArtifactSet);
178 }
179 artifacts.sort_by(|left, right| {
180 left.kind
181 .cmp(&right.kind)
182 .then_with(|| left.digest.cmp(&right.digest))
183 });
184 for pair in artifacts.windows(2) {
185 if pair[0].digest == pair[1].digest {
186 return Err(EvolutionError::DuplicateArtifact(pair[0].digest.clone()));
187 }
188 }
189 let digest = canonical_digest(&artifacts)?;
190 Ok(Self { digest, artifacts })
191 }
192
193 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
194 let expected = canonical_digest(&self.artifacts)?;
195 if expected != self.digest {
196 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
197 }
198 Ok(())
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct EvolutionProposal {
204 pub digest: ContentDigest,
205 pub base_artifact_set: ContentDigest,
206 pub candidate_artifact_set: ContentDigest,
207 pub objective: String,
208 pub change_manifest: ContentDigest,
209 pub proposer: String,
210 pub constraints: Vec<String>,
211}
212
213impl EvolutionProposal {
214 pub fn new(
215 base_artifact_set: ContentDigest,
216 candidate_artifact_set: ContentDigest,
217 objective: impl Into<String>,
218 change_manifest: ContentDigest,
219 proposer: impl Into<String>,
220 constraints: Vec<String>,
221 ) -> Result<Self, EvolutionError> {
222 let unsigned = Self {
223 digest: ContentDigest::from_bytes(b"placeholder"),
224 base_artifact_set,
225 candidate_artifact_set,
226 objective: objective.into(),
227 change_manifest,
228 proposer: proposer.into(),
229 constraints,
230 };
231 let digest = canonical_digest(&ProposalBody::from(&unsigned))?;
232 Ok(Self { digest, ..unsigned })
233 }
234
235 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
236 let expected = canonical_digest(&ProposalBody::from(self))?;
237 if expected != self.digest {
238 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
239 }
240 Ok(())
241 }
242}
243
244#[derive(Debug, Serialize)]
245struct ProposalBody<'a> {
246 base_artifact_set: &'a ContentDigest,
247 candidate_artifact_set: &'a ContentDigest,
248 objective: &'a str,
249 change_manifest: &'a ContentDigest,
250 proposer: &'a str,
251 constraints: &'a [String],
252}
253
254impl<'a> From<&'a EvolutionProposal> for ProposalBody<'a> {
255 fn from(value: &'a EvolutionProposal) -> Self {
256 Self {
257 base_artifact_set: &value.base_artifact_set,
258 candidate_artifact_set: &value.candidate_artifact_set,
259 objective: &value.objective,
260 change_manifest: &value.change_manifest,
261 proposer: &value.proposer,
262 constraints: &value.constraints,
263 }
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct EvaluationContextBinding {
274 pub digest: ContentDigest,
275 pub operation_id: String,
276 pub execution_input: ContentDigest,
277 pub context_state: ContentDigest,
278 pub context_policy: ContentDigest,
279 pub context_plan: ContentDigest,
280 pub rendered_snapshot: ContentDigest,
281 pub prompt_measurement: ContentDigest,
282 pub provider_route: ContentDigest,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub cache_prefix: Option<ContentDigest>,
285}
286
287impl EvaluationContextBinding {
288 pub fn from_execution_input(input: &ContextExecutionInput) -> Self {
290 let unsigned = Self {
291 digest: ContentDigest::from_bytes(b"placeholder"),
292 operation_id: input.operation_id.clone(),
293 execution_input: input.input_digest.clone(),
294 context_state: input.state_digest.clone(),
295 context_policy: input.policy_digest.clone(),
296 context_plan: input.plan_digest.clone(),
297 rendered_snapshot: input.rendered_snapshot.clone(),
298 prompt_measurement: input.prompt_measurement.clone(),
299 provider_route: input.provider_route.clone(),
300 cache_prefix: input
301 .cache_prefix
302 .as_ref()
303 .map(|boundary| boundary.digest.clone()),
304 };
305 let digest = canonical_digest(&EvaluationContextBindingBody::from(&unsigned))
306 .expect("context input projection is canonical");
307 Self { digest, ..unsigned }
308 }
309
310 pub fn new(
311 operation_id: impl Into<String>,
312 execution_input: ContentDigest,
313 context_state: ContentDigest,
314 context_policy: ContentDigest,
315 context_plan: ContentDigest,
316 rendered_snapshot: ContentDigest,
317 prompt_measurement: ContentDigest,
318 provider_route: ContentDigest,
319 cache_prefix: Option<ContentDigest>,
320 ) -> Result<Self, EvolutionError> {
321 let unsigned = Self {
322 digest: ContentDigest::from_bytes(b"placeholder"),
323 operation_id: operation_id.into(),
324 execution_input,
325 context_state,
326 context_policy,
327 context_plan,
328 rendered_snapshot,
329 prompt_measurement,
330 provider_route,
331 cache_prefix,
332 };
333 let digest = canonical_digest(&EvaluationContextBindingBody::from(&unsigned))?;
334 Ok(Self { digest, ..unsigned })
335 }
336
337 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
338 let expected = canonical_digest(&EvaluationContextBindingBody::from(self))?;
339 if expected != self.digest {
340 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
341 }
342 Ok(())
343 }
344}
345
346#[derive(Debug, Serialize)]
347struct EvaluationContextBindingBody<'a> {
348 operation_id: &'a str,
349 execution_input: &'a ContentDigest,
350 context_state: &'a ContentDigest,
351 context_policy: &'a ContentDigest,
352 context_plan: &'a ContentDigest,
353 rendered_snapshot: &'a ContentDigest,
354 prompt_measurement: &'a ContentDigest,
355 provider_route: &'a ContentDigest,
356 cache_prefix: Option<&'a ContentDigest>,
357}
358
359impl<'a> From<&'a EvaluationContextBinding> for EvaluationContextBindingBody<'a> {
360 fn from(value: &'a EvaluationContextBinding) -> Self {
361 Self {
362 operation_id: &value.operation_id,
363 execution_input: &value.execution_input,
364 context_state: &value.context_state,
365 context_policy: &value.context_policy,
366 context_plan: &value.context_plan,
367 rendered_snapshot: &value.rendered_snapshot,
368 prompt_measurement: &value.prompt_measurement,
369 provider_route: &value.provider_route,
370 cache_prefix: value.cache_prefix.as_ref(),
371 }
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct EvaluationRun {
377 pub digest: ContentDigest,
378 pub proposal: ContentDigest,
379 pub baseline_artifact_set: ContentDigest,
380 pub candidate_artifact_set: ContentDigest,
381 pub evaluator: ContentDigest,
382 pub dataset: ContentDigest,
383 pub operation_ids: Vec<String>,
384 pub contexts: Vec<EvaluationContextBinding>,
385 pub evidence_refs: Vec<ContentDigest>,
386}
387
388impl EvaluationRun {
389 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
390 let expected = canonical_digest(&EvaluationRunBody::from(self))?;
391 if expected != self.digest {
392 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
393 }
394 Ok(())
395 }
396}
397
398#[derive(Debug, Serialize)]
399struct EvaluationRunBody<'a> {
400 proposal: &'a ContentDigest,
401 baseline_artifact_set: &'a ContentDigest,
402 candidate_artifact_set: &'a ContentDigest,
403 evaluator: &'a ContentDigest,
404 dataset: &'a ContentDigest,
405 operation_ids: &'a [String],
406 contexts: &'a [EvaluationContextBinding],
407 evidence_refs: &'a [ContentDigest],
408}
409
410impl<'a> From<&'a EvaluationRun> for EvaluationRunBody<'a> {
411 fn from(value: &'a EvaluationRun) -> Self {
412 Self {
413 proposal: &value.proposal,
414 baseline_artifact_set: &value.baseline_artifact_set,
415 candidate_artifact_set: &value.candidate_artifact_set,
416 evaluator: &value.evaluator,
417 dataset: &value.dataset,
418 operation_ids: &value.operation_ids,
419 contexts: &value.contexts,
420 evidence_refs: &value.evidence_refs,
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426pub struct EvaluationMetric {
427 pub name: String,
428 pub baseline: String,
429 pub candidate: String,
430 pub improved: bool,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct EvaluationGate {
435 pub name: String,
436 pub required: bool,
437 pub passed: bool,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct EvaluationFact {
442 pub digest: ContentDigest,
443 pub evaluation: ContentDigest,
444 pub metrics: Vec<EvaluationMetric>,
445 pub gates: Vec<EvaluationGate>,
446 pub replay_passed: bool,
447}
448
449impl EvaluationFact {
450 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
451 let expected = canonical_digest(&EvaluationFactBody::from(self))?;
452 if expected != self.digest {
453 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
454 }
455 Ok(())
456 }
457}
458
459#[derive(Debug, Serialize)]
460struct EvaluationFactBody<'a> {
461 evaluation: &'a ContentDigest,
462 metrics: &'a [EvaluationMetric],
463 gates: &'a [EvaluationGate],
464 replay_passed: bool,
465}
466
467impl<'a> From<&'a EvaluationFact> for EvaluationFactBody<'a> {
468 fn from(value: &'a EvaluationFact) -> Self {
469 Self {
470 evaluation: &value.evaluation,
471 metrics: &value.metrics,
472 gates: &value.gates,
473 replay_passed: value.replay_passed,
474 }
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(rename_all = "snake_case")]
480pub enum PromotionOutcome {
481 Promote,
482 Reject,
483 Hold,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct PromotionDecision {
488 pub digest: ContentDigest,
489 pub proposal: ContentDigest,
490 pub evaluation_facts: Vec<ContentDigest>,
491 pub policy: ContentDigest,
492 pub outcome: PromotionOutcome,
493 pub selected_artifact_set: ContentDigest,
494}
495
496impl PromotionDecision {
497 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
498 let expected = canonical_digest(&PromotionDecisionBody::from(self))?;
499 if expected != self.digest {
500 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
501 }
502 Ok(())
503 }
504}
505
506#[derive(Debug, Serialize)]
507struct PromotionDecisionBody<'a> {
508 proposal: &'a ContentDigest,
509 evaluation_facts: &'a [ContentDigest],
510 policy: &'a ContentDigest,
511 outcome: PromotionOutcome,
512 selected_artifact_set: &'a ContentDigest,
513}
514
515impl<'a> From<&'a PromotionDecision> for PromotionDecisionBody<'a> {
516 fn from(value: &'a PromotionDecision) -> Self {
517 Self {
518 proposal: &value.proposal,
519 evaluation_facts: &value.evaluation_facts,
520 policy: &value.policy,
521 outcome: value.outcome,
522 selected_artifact_set: &value.selected_artifact_set,
523 }
524 }
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528pub struct ActivationBinding {
529 pub digest: ContentDigest,
530 pub operation_id: String,
531 pub artifact_set: ContentDigest,
532 pub promotion_decision: ContentDigest,
533}
534
535impl ActivationBinding {
536 pub fn verify_digest(&self) -> Result<(), EvolutionError> {
537 let expected = canonical_digest(&(
538 &self.operation_id,
539 &self.artifact_set,
540 &self.promotion_decision,
541 ))?;
542 if expected != self.digest {
543 return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
544 }
545 Ok(())
546 }
547}
548
549#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
550pub struct EvolutionBundle {
551 pub artifacts: Vec<ArtifactVersion>,
552 pub artifact_sets: Vec<ArtifactSet>,
553 pub proposals: Vec<EvolutionProposal>,
554 pub evaluations: Vec<EvaluationRun>,
555 pub facts: Vec<EvaluationFact>,
556 pub decisions: Vec<PromotionDecision>,
557 pub activations: Vec<ActivationBinding>,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
561#[serde(rename_all = "snake_case")]
562pub enum EvolutionVerdict {
563 Pass,
564 Fail,
565 Unavailable,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct EvolutionViolation {
570 pub code: String,
571 pub detail: String,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct EvolutionReport {
576 pub schema: String,
577 pub verdict: EvolutionVerdict,
578 pub violations: Vec<EvolutionViolation>,
579}
580
581pub fn validate_evolution(bundle: &EvolutionBundle) -> EvolutionReport {
582 let mut violations = Vec::new();
583 let artifacts: HashMap<_, _> = bundle
584 .artifacts
585 .iter()
586 .map(|artifact| (artifact.digest.clone(), artifact))
587 .collect();
588 let sets: HashMap<_, _> = bundle
589 .artifact_sets
590 .iter()
591 .map(|set| (set.digest.clone(), set))
592 .collect();
593 let proposals: HashMap<_, _> = bundle
594 .proposals
595 .iter()
596 .map(|proposal| (proposal.digest.clone(), proposal))
597 .collect();
598 let evaluations: HashMap<_, _> = bundle
599 .evaluations
600 .iter()
601 .map(|evaluation| (evaluation.digest.clone(), evaluation))
602 .collect();
603 let facts: HashMap<_, _> = bundle
604 .facts
605 .iter()
606 .map(|fact| (fact.digest.clone(), fact))
607 .collect();
608 let decisions: HashMap<_, _> = bundle
609 .decisions
610 .iter()
611 .map(|decision| (decision.digest.clone(), decision))
612 .collect();
613
614 for artifact in &bundle.artifacts {
615 if artifact.verify_digest().is_err() {
616 violation(
617 &mut violations,
618 "E1",
619 format!("artifact {} digest mismatch", artifact.digest),
620 );
621 }
622 for parent in &artifact.manifest.parents {
623 if !artifacts.contains_key(parent) {
624 violation(
625 &mut violations,
626 "E2",
627 format!("artifact {} has missing parent {parent}", artifact.digest),
628 );
629 }
630 }
631 }
632 for artifact in &bundle.artifacts {
633 if has_artifact_cycle(
634 artifact.digest.clone(),
635 &artifacts,
636 &mut HashSet::new(),
637 &mut HashSet::new(),
638 ) {
639 violation(
640 &mut violations,
641 "E2",
642 format!("artifact lineage contains a cycle at {}", artifact.digest),
643 );
644 }
645 }
646 for set in &bundle.artifact_sets {
647 if set.verify_digest().is_err() {
648 violation(
649 &mut violations,
650 "E1",
651 format!("artifact set {} digest mismatch", set.digest),
652 );
653 }
654 for reference in &set.artifacts {
655 if !artifacts.contains_key(&reference.digest) {
656 violation(
657 &mut violations,
658 "E2",
659 format!(
660 "artifact set {} references missing {}",
661 set.digest, reference.digest
662 ),
663 );
664 }
665 }
666 }
667 for proposal in &bundle.proposals {
668 if proposal.verify_digest().is_err() {
669 violation(
670 &mut violations,
671 "E1",
672 format!("proposal {} digest mismatch", proposal.digest),
673 );
674 }
675 if !sets.contains_key(&proposal.base_artifact_set)
676 || !sets.contains_key(&proposal.candidate_artifact_set)
677 {
678 violation(
679 &mut violations,
680 "E3",
681 format!(
682 "proposal {} references an unknown artifact set",
683 proposal.digest
684 ),
685 );
686 }
687 }
688 for evaluation in &bundle.evaluations {
689 if evaluation.verify_digest().is_err() {
690 violation(
691 &mut violations,
692 "E1",
693 format!("evaluation {} digest mismatch", evaluation.digest),
694 );
695 }
696 match proposals.get(&evaluation.proposal) {
697 Some(proposal)
698 if proposal.base_artifact_set == evaluation.baseline_artifact_set
699 && proposal.candidate_artifact_set == evaluation.candidate_artifact_set => {}
700 Some(_) => violation(
701 &mut violations,
702 "E4",
703 format!(
704 "evaluation {} does not match its proposal",
705 evaluation.digest
706 ),
707 ),
708 None => violation(
709 &mut violations,
710 "E4",
711 format!(
712 "evaluation {} references an unknown proposal",
713 evaluation.digest
714 ),
715 ),
716 }
717 if evaluation.operation_ids.is_empty()
718 || evaluation.contexts.is_empty()
719 || evaluation.evidence_refs.is_empty()
720 {
721 violation(
722 &mut violations,
723 "E5",
724 format!("evaluation {} has incomplete evidence", evaluation.digest),
725 );
726 }
727 let operation_ids: HashSet<_> = evaluation.operation_ids.iter().collect();
728 let context_operation_ids: HashSet<_> = evaluation
729 .contexts
730 .iter()
731 .map(|context| &context.operation_id)
732 .collect();
733 let mut context_inputs = HashSet::new();
734 for context in &evaluation.contexts {
735 if !context_inputs.insert((&context.operation_id, &context.execution_input)) {
736 violation(
737 &mut violations,
738 "E5",
739 format!(
740 "evaluation {} repeats context input {} for operation {}",
741 evaluation.digest, context.execution_input, context.operation_id,
742 ),
743 );
744 }
745 if context.verify_digest().is_err() {
746 violation(
747 &mut violations,
748 "E1",
749 format!("evaluation context {} digest mismatch", context.digest),
750 );
751 }
752 if !operation_ids.contains(&context.operation_id) {
753 violation(
754 &mut violations,
755 "E5",
756 format!(
757 "evaluation context {} references an unknown operation {}",
758 context.digest, context.operation_id
759 ),
760 );
761 }
762 for reference in [
763 &context.execution_input,
764 &context.context_state,
765 &context.context_policy,
766 &context.context_plan,
767 &context.rendered_snapshot,
768 &context.prompt_measurement,
769 &context.provider_route,
770 ] {
771 if !evaluation.evidence_refs.contains(reference) {
772 violation(
773 &mut violations,
774 "E5",
775 format!(
776 "evaluation context {} is missing evidence reference {}",
777 context.digest, reference
778 ),
779 );
780 }
781 }
782 if let Some(cache_prefix) = &context.cache_prefix {
783 if !evaluation.evidence_refs.contains(cache_prefix) {
784 violation(
785 &mut violations,
786 "E5",
787 format!(
788 "evaluation context {} is missing cache evidence reference {}",
789 context.digest, cache_prefix
790 ),
791 );
792 }
793 }
794 }
795 if operation_ids != context_operation_ids {
796 violation(
797 &mut violations,
798 "E5",
799 format!(
800 "evaluation {} does not bind every operation to a context",
801 evaluation.digest
802 ),
803 );
804 }
805 }
806 for fact in &bundle.facts {
807 if fact.verify_digest().is_err() {
808 violation(
809 &mut violations,
810 "E1",
811 format!("fact {} digest mismatch", fact.digest),
812 );
813 }
814 if !evaluations.contains_key(&fact.evaluation) {
815 violation(
816 &mut violations,
817 "E5",
818 format!("fact {} references an unknown evaluation", fact.digest),
819 );
820 }
821 if fact
822 .metrics
823 .iter()
824 .any(|metric| metric.baseline.is_empty() || metric.candidate.is_empty())
825 {
826 violation(
827 &mut violations,
828 "E6",
829 format!("fact {} contains an incomplete metric", fact.digest),
830 );
831 }
832 }
833 for decision in &bundle.decisions {
834 if decision.verify_digest().is_err() {
835 violation(
836 &mut violations,
837 "E1",
838 format!("decision {} digest mismatch", decision.digest),
839 );
840 }
841 let Some(proposal) = proposals.get(&decision.proposal) else {
842 violation(
843 &mut violations,
844 "E7",
845 format!(
846 "decision {} references an unknown proposal",
847 decision.digest
848 ),
849 );
850 continue;
851 };
852 if decision.evaluation_facts.is_empty() {
853 violation(
854 &mut violations,
855 "E7",
856 format!("decision {} has no evaluation facts", decision.digest),
857 );
858 }
859 let referenced_facts: Vec<_> = decision
860 .evaluation_facts
861 .iter()
862 .filter_map(|digest| facts.get(digest))
863 .collect();
864 if referenced_facts.len() != decision.evaluation_facts.len() {
865 violation(
866 &mut violations,
867 "E7",
868 format!("decision {} references missing facts", decision.digest),
869 );
870 }
871 if decision.outcome == PromotionOutcome::Promote
872 && (referenced_facts.is_empty()
873 || referenced_facts.iter().any(|fact| {
874 !fact.replay_passed
875 || fact.gates.iter().any(|gate| gate.required && !gate.passed)
876 }))
877 {
878 violation(
879 &mut violations,
880 "E7",
881 format!(
882 "decision {} promotes without passing required gates",
883 decision.digest
884 ),
885 );
886 }
887 if decision.selected_artifact_set != proposal.candidate_artifact_set {
888 violation(
889 &mut violations,
890 "E7",
891 format!(
892 "decision {} selects a non-candidate artifact set",
893 decision.digest
894 ),
895 );
896 }
897 }
898 for activation in &bundle.activations {
899 if activation.verify_digest().is_err() {
900 violation(
901 &mut violations,
902 "E1",
903 format!("activation {} digest mismatch", activation.digest),
904 );
905 }
906 match decisions.get(&activation.promotion_decision) {
907 Some(decision)
908 if decision.outcome == PromotionOutcome::Promote
909 && decision.selected_artifact_set == activation.artifact_set => {}
910 Some(_) => violation(
911 &mut violations,
912 "E8",
913 format!(
914 "activation {} is not backed by a promotion decision",
915 activation.digest
916 ),
917 ),
918 None => violation(
919 &mut violations,
920 "E8",
921 format!(
922 "activation {} references an unknown decision",
923 activation.digest
924 ),
925 ),
926 }
927 }
928
929 EvolutionReport {
930 schema: EVOLUTION_REPORT_SCHEMA.to_string(),
931 verdict: if violations.is_empty() {
932 EvolutionVerdict::Pass
933 } else {
934 EvolutionVerdict::Fail
935 },
936 violations,
937 }
938}
939
940fn canonical_digest<T: Serialize>(value: &T) -> Result<ContentDigest, EvolutionError> {
941 let bytes = serde_json::to_vec(value)
942 .map_err(|error| EvolutionError::Serialization(error.to_string()))?;
943 Ok(ContentDigest::from_bytes(&bytes))
944}
945
946fn violation(violations: &mut Vec<EvolutionViolation>, code: &str, detail: String) {
947 violations.push(EvolutionViolation {
948 code: code.to_string(),
949 detail,
950 });
951}
952
953fn has_artifact_cycle(
954 current: ContentDigest,
955 artifacts: &HashMap<ContentDigest, &ArtifactVersion>,
956 visiting: &mut HashSet<ContentDigest>,
957 visited: &mut HashSet<ContentDigest>,
958) -> bool {
959 if visited.contains(¤t) {
960 return false;
961 }
962 if !visiting.insert(current.clone()) {
963 return true;
964 }
965 let cycle = artifacts.get(¤t).is_some_and(|artifact| {
966 artifact
967 .manifest
968 .parents
969 .iter()
970 .any(|parent| has_artifact_cycle(parent.clone(), artifacts, visiting, visited))
971 });
972 visiting.remove(¤t);
973 visited.insert(current);
974 cycle
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 fn digest(label: &str) -> ContentDigest {
982 ContentDigest::from_bytes(label.as_bytes())
983 }
984
985 fn artifact(kind: ArtifactKind, label: &str, parents: Vec<ContentDigest>) -> ArtifactVersion {
986 ArtifactVersion::from_manifest(ArtifactManifest {
987 kind,
988 payload_digest: digest(&format!("payload:{label}")),
989 parents,
990 toolchain_digest: digest("toolchain:v4"),
991 scope: "runtime".to_string(),
992 })
993 .unwrap()
994 }
995
996 #[test]
997 fn artifact_and_set_digests_are_canonical() {
998 let runtime = artifact(ArtifactKind::Runtime, "r1", Vec::new());
999 let skill = artifact(ArtifactKind::Skill, "s1", Vec::new());
1000 let set = ArtifactSet::new(vec![skill.reference(), runtime.reference()]).unwrap();
1001
1002 assert_eq!(set.artifacts[0].kind, ArtifactKind::Runtime);
1003 assert!(runtime.verify_digest().is_ok());
1004 assert!(set.verify_digest().is_ok());
1005 assert_eq!(
1006 ContentDigest::parse(set.digest.to_string()).unwrap(),
1007 set.digest
1008 );
1009 }
1010
1011 #[test]
1012 fn content_digest_json_decode_rejects_non_sha256_values() {
1013 let invalid = serde_json::from_value::<ContentDigest>(serde_json::json!("legacy-id"));
1014 assert!(invalid.is_err());
1015 }
1016
1017 #[test]
1018 fn tampering_is_fail_closed() {
1019 let runtime = artifact(ArtifactKind::Runtime, "r1", Vec::new());
1020 let mut tampered = runtime.clone();
1021 tampered.manifest.scope = "changed".to_string();
1022 let report = validate_evolution(&EvolutionBundle {
1023 artifacts: vec![tampered],
1024 ..Default::default()
1025 });
1026
1027 assert_eq!(report.verdict, EvolutionVerdict::Fail);
1028 assert!(
1029 report
1030 .violations
1031 .iter()
1032 .any(|violation| violation.code == "E1")
1033 );
1034 }
1035
1036 #[test]
1037 fn proposal_evaluation_promotion_and_activation_form_one_valid_graph() {
1038 let base = artifact(ArtifactKind::Runtime, "base", Vec::new());
1039 let candidate = artifact(
1040 ArtifactKind::Runtime,
1041 "candidate",
1042 vec![base.digest.clone()],
1043 );
1044 let base_set = ArtifactSet::new(vec![base.reference()]).unwrap();
1045 let candidate_set = ArtifactSet::new(vec![candidate.reference()]).unwrap();
1046 let proposal = EvolutionProposal::new(
1047 base_set.digest.clone(),
1048 candidate_set.digest.clone(),
1049 "improve recovery",
1050 digest("change-manifest"),
1051 "agent:root",
1052 vec!["replay must pass".to_string()],
1053 )
1054 .unwrap();
1055 let evaluator = digest("evaluator");
1056 let dataset = digest("dataset");
1057 let evidence = digest("evidence");
1058 let operation_ids = vec!["eval-op".to_string()];
1059 let execution_input = digest("execution-input");
1060 let context_state = digest("context-state");
1061 let context_policy = digest("context-policy");
1062 let context_plan = digest("context-plan");
1063 let rendered_snapshot = digest("rendered-snapshot");
1064 let prompt_measurement = digest("prompt-measurement");
1065 let provider_route = digest("provider-route");
1066 let context = EvaluationContextBinding::new(
1067 "eval-op",
1068 execution_input,
1069 context_state,
1070 context_policy,
1071 context_plan,
1072 rendered_snapshot,
1073 prompt_measurement,
1074 provider_route,
1075 None,
1076 )
1077 .unwrap();
1078 let contexts = vec![context];
1079 let evidence_refs = vec![
1080 evidence.clone(),
1081 contexts[0].execution_input.clone(),
1082 contexts[0].context_state.clone(),
1083 contexts[0].context_policy.clone(),
1084 contexts[0].context_plan.clone(),
1085 contexts[0].rendered_snapshot.clone(),
1086 contexts[0].prompt_measurement.clone(),
1087 contexts[0].provider_route.clone(),
1088 ];
1089 let run_body = EvaluationRunBody {
1090 proposal: &proposal.digest,
1091 baseline_artifact_set: &base_set.digest,
1092 candidate_artifact_set: &candidate_set.digest,
1093 evaluator: &evaluator,
1094 dataset: &dataset,
1095 operation_ids: &operation_ids,
1096 contexts: &contexts,
1097 evidence_refs: &evidence_refs,
1098 };
1099 let evaluation = EvaluationRun {
1100 digest: canonical_digest(&run_body).unwrap(),
1101 proposal: proposal.digest.clone(),
1102 baseline_artifact_set: base_set.digest.clone(),
1103 candidate_artifact_set: candidate_set.digest.clone(),
1104 evaluator,
1105 dataset,
1106 operation_ids,
1107 contexts,
1108 evidence_refs,
1109 };
1110 let metrics = vec![EvaluationMetric {
1111 name: "quality".to_string(),
1112 baseline: "0.8".to_string(),
1113 candidate: "0.9".to_string(),
1114 improved: true,
1115 }];
1116 let gates = vec![EvaluationGate {
1117 name: "replay".to_string(),
1118 required: true,
1119 passed: true,
1120 }];
1121 let fact_body = EvaluationFactBody {
1122 evaluation: &evaluation.digest,
1123 metrics: &metrics,
1124 gates: &gates,
1125 replay_passed: true,
1126 };
1127 let fact = EvaluationFact {
1128 digest: canonical_digest(&fact_body).unwrap(),
1129 evaluation: evaluation.digest.clone(),
1130 metrics,
1131 gates,
1132 replay_passed: true,
1133 };
1134 let policy = digest("policy");
1135 let evaluation_facts = vec![fact.digest.clone()];
1136 let decision_body = PromotionDecisionBody {
1137 proposal: &proposal.digest,
1138 evaluation_facts: &evaluation_facts,
1139 policy: &policy,
1140 outcome: PromotionOutcome::Promote,
1141 selected_artifact_set: &candidate_set.digest,
1142 };
1143 let decision = PromotionDecision {
1144 digest: canonical_digest(&decision_body).unwrap(),
1145 proposal: proposal.digest.clone(),
1146 evaluation_facts,
1147 policy,
1148 outcome: PromotionOutcome::Promote,
1149 selected_artifact_set: candidate_set.digest.clone(),
1150 };
1151 let activation_body = (
1152 &"next-op".to_string(),
1153 &candidate_set.digest,
1154 &decision.digest,
1155 );
1156 let activation = ActivationBinding {
1157 digest: canonical_digest(&activation_body).unwrap(),
1158 operation_id: "next-op".to_string(),
1159 artifact_set: candidate_set.digest.clone(),
1160 promotion_decision: decision.digest.clone(),
1161 };
1162 let mut bundle = EvolutionBundle {
1163 artifacts: vec![base, candidate],
1164 artifact_sets: vec![base_set, candidate_set],
1165 proposals: vec![proposal],
1166 evaluations: vec![evaluation],
1167 facts: vec![fact],
1168 decisions: vec![decision],
1169 activations: vec![activation],
1170 };
1171 let report = validate_evolution(&bundle);
1172
1173 assert_eq!(report.verdict, EvolutionVerdict::Pass);
1174 assert!(report.violations.is_empty());
1175
1176 bundle.facts.clear();
1179 bundle.decisions.clear();
1180 bundle.activations.clear();
1181 let evaluation = &mut bundle.evaluations[0];
1182 let mut second = evaluation.contexts[0].clone();
1183 second.execution_input = digest("second-attempt");
1184 second.digest = canonical_digest(&EvaluationContextBindingBody::from(&second)).unwrap();
1185 evaluation
1186 .evidence_refs
1187 .push(second.execution_input.clone());
1188 evaluation.contexts.push(second);
1189 evaluation.digest = canonical_digest(&EvaluationRunBody::from(&*evaluation)).unwrap();
1190 assert_eq!(validate_evolution(&bundle).verdict, EvolutionVerdict::Pass);
1191 let evaluation = &mut bundle.evaluations[0];
1192 evaluation.contexts.push(evaluation.contexts[0].clone());
1193 evaluation.digest = canonical_digest(&EvaluationRunBody::from(&*evaluation)).unwrap();
1194 assert!(
1195 validate_evolution(&bundle)
1196 .violations
1197 .iter()
1198 .any(|violation| violation.code == "E5"
1199 && violation.detail.contains("repeats context input"))
1200 );
1201 }
1202
1203 #[test]
1204 fn evaluation_rejects_tampered_context_or_missing_measurement_evidence() {
1205 let context_policy = digest("context-policy");
1206 let execution_input = digest("execution-input");
1207 let context_state = digest("context-state");
1208 let context_plan = digest("context-plan");
1209 let rendered_snapshot = digest("rendered-snapshot");
1210 let prompt_measurement = digest("prompt-measurement");
1211 let provider_route = digest("provider-route");
1212 let mut context = EvaluationContextBinding::new(
1213 "eval-op",
1214 execution_input,
1215 context_state,
1216 context_policy,
1217 context_plan,
1218 rendered_snapshot,
1219 prompt_measurement,
1220 provider_route,
1221 None,
1222 )
1223 .unwrap();
1224 context.rendered_snapshot = digest("tampered-render");
1225 let evaluation = EvaluationRun {
1226 digest: digest("evaluation"),
1227 proposal: digest("proposal"),
1228 baseline_artifact_set: digest("base-set"),
1229 candidate_artifact_set: digest("candidate-set"),
1230 evaluator: digest("evaluator"),
1231 dataset: digest("dataset"),
1232 operation_ids: vec!["eval-op".to_string()],
1233 contexts: vec![context],
1234 evidence_refs: vec![digest("context-policy"), digest("context-state")],
1235 };
1236 let report = validate_evolution(&EvolutionBundle {
1237 evaluations: vec![evaluation],
1238 ..Default::default()
1239 });
1240
1241 assert_eq!(report.verdict, EvolutionVerdict::Fail);
1242 assert!(
1243 report
1244 .violations
1245 .iter()
1246 .any(|violation| violation.code == "E1")
1247 );
1248 assert!(
1249 report
1250 .violations
1251 .iter()
1252 .any(|violation| violation.code == "E5")
1253 );
1254 }
1255
1256 #[test]
1257 fn promotion_rejects_missing_required_gate() {
1258 let fact = EvaluationFact {
1259 digest: digest("fact"),
1260 evaluation: digest("evaluation"),
1261 metrics: Vec::new(),
1262 gates: vec![EvaluationGate {
1263 name: "safety".to_string(),
1264 required: true,
1265 passed: false,
1266 }],
1267 replay_passed: true,
1268 };
1269 let decision = PromotionDecision {
1270 digest: digest("decision"),
1271 proposal: digest("proposal"),
1272 evaluation_facts: vec![fact.digest.clone()],
1273 policy: digest("policy"),
1274 outcome: PromotionOutcome::Promote,
1275 selected_artifact_set: digest("candidate"),
1276 };
1277 let report = validate_evolution(&EvolutionBundle {
1278 facts: vec![fact],
1279 decisions: vec![decision],
1280 ..Default::default()
1281 });
1282
1283 assert_eq!(report.verdict, EvolutionVerdict::Fail);
1284 assert!(
1285 report
1286 .violations
1287 .iter()
1288 .any(|violation| violation.code == "E7")
1289 );
1290 }
1291
1292 #[test]
1293 fn json_bridge_returns_the_canonical_report() {
1294 let report = validate_evolution_json(
1295 r#"{"artifacts":[],"artifact_sets":[],"proposals":[],"evaluations":[],"facts":[],"decisions":[],"activations":[]}"#,
1296 )
1297 .expect("valid evolution bundle");
1298 let value: serde_json::Value = serde_json::from_str(&report).expect("report json");
1299 assert_eq!(value["schema"], EVOLUTION_REPORT_SCHEMA);
1300 assert_eq!(value["verdict"], "pass");
1301
1302 let error = validate_evolution_json("[]").expect_err("non-object request must fail");
1303 assert!(error.contains("invalid evolution bundle"));
1304 }
1305}