1use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use lean_ctx_protocol::{
9 AcceptanceState, AcceptedOutcomeV1, EvidenceKind, EvidenceRefV1, OutcomeId, OutcomeSignalsV1,
10 SignalState, SignatureStatus, TaskId,
11};
12
13use super::contracts::{OutcomeContractV1, TaskClass};
14use super::signals::{OutcomeSignal, SignalType, SignalValue};
15
16pub const EVALUATOR_VERSION: &str = "1.0.1";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct EvaluationContext {
22 pub task_id: String,
23 pub contract_version: String,
24 pub evaluator_version: String,
25}
26
27impl EvaluationContext {
28 pub fn new(
30 task_id: impl Into<String>,
31 contract_version: impl Into<String>,
32 evaluator_version: impl Into<String>,
33 ) -> Self {
34 Self {
35 task_id: task_id.into(),
36 contract_version: contract_version.into(),
37 evaluator_version: evaluator_version.into(),
38 }
39 }
40
41 pub fn from_signals(signals: &[OutcomeSignal]) -> Self {
43 let task_id = signals
44 .iter()
45 .filter_map(|signal| signal.evidence_ref.as_deref())
46 .find_map(|reference| reference.strip_prefix("task:"))
47 .unwrap_or("task-unknown");
48 Self::new(task_id, "unknown", EVALUATOR_VERSION)
49 }
50}
51
52impl Default for EvaluationContext {
53 fn default() -> Self {
54 Self::new("task-unknown", "unknown", EVALUATOR_VERSION)
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum ContributionStatus {
62 Passed,
63 Failed,
64 Missing,
65 Unknown,
66 NotRequired,
67 AlternativeSatisfied,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SignalContribution {
73 pub signal_type: SignalType,
74 pub required: bool,
75 pub weight_milli: u32,
76 pub value: SignalValue,
77 pub status: ContributionStatus,
78 pub contribution_milli: u32,
79 pub explanation: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct OutcomeEvaluation {
85 pub result: AcceptedOutcomeV1,
87 pub reasoning: Vec<String>,
89 pub signals_used: Vec<SignalType>,
91 pub outcome: AcceptedOutcomeV1,
93 pub contributions: Vec<SignalContribution>,
94 pub evaluator_version: String,
95 pub policy_violations: Vec<PolicyViolation>,
96 pub supersedes: Option<String>,
98}
99
100impl OutcomeEvaluation {
101 pub const fn state(&self) -> AcceptanceState {
103 self.outcome.accepted
104 }
105
106 pub const fn is_accepted(&self) -> bool {
108 matches!(self.outcome.accepted, AcceptanceState::Accepted)
109 }
110
111 pub const fn is_unknown(&self) -> bool {
113 matches!(self.outcome.accepted, AcceptanceState::Unknown)
114 }
115
116 pub fn explanation(&self) -> String {
118 self.reasoning.join("; ")
119 }
120}
121
122pub type EvaluationResult = OutcomeEvaluation;
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct PolicyViolation {
128 pub code: String,
129 pub message: String,
130}
131
132impl PolicyViolation {
133 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
134 Self {
135 code: code.into(),
136 message: message.into(),
137 }
138 }
139}
140
141impl From<String> for PolicyViolation {
142 fn from(message: String) -> Self {
143 Self::new("policy_violation", message)
144 }
145}
146
147impl From<&str> for PolicyViolation {
148 fn from(message: &str) -> Self {
149 Self::new("policy_violation", message)
150 }
151}
152
153pub trait PolicyInput {
155 fn violation_messages(&self) -> Vec<PolicyViolation>;
156}
157
158impl PolicyInput for bool {
159 fn violation_messages(&self) -> Vec<PolicyViolation> {
160 if *self {
161 vec![PolicyViolation::from("policy violation")]
162 } else {
163 Vec::new()
164 }
165 }
166}
167
168impl PolicyInput for Vec<String> {
169 fn violation_messages(&self) -> Vec<PolicyViolation> {
170 self.iter().cloned().map(PolicyViolation::from).collect()
171 }
172}
173
174impl PolicyInput for &[String] {
175 fn violation_messages(&self) -> Vec<PolicyViolation> {
176 self.iter().cloned().map(PolicyViolation::from).collect()
177 }
178}
179
180impl<'a> PolicyInput for &'a [&'a str] {
181 fn violation_messages(&self) -> Vec<PolicyViolation> {
182 self.iter()
183 .map(|message| PolicyViolation::from(*message))
184 .collect()
185 }
186}
187
188impl PolicyInput for PolicyViolation {
189 fn violation_messages(&self) -> Vec<PolicyViolation> {
190 vec![self.clone()]
191 }
192}
193
194impl PolicyInput for Vec<PolicyViolation> {
195 fn violation_messages(&self) -> Vec<PolicyViolation> {
196 self.clone()
197 }
198}
199
200impl PolicyInput for &[PolicyViolation] {
201 fn violation_messages(&self) -> Vec<PolicyViolation> {
202 self.to_vec()
203 }
204}
205
206#[derive(Debug, Clone, Copy, Default)]
208pub struct OutcomeEvaluator;
209
210impl OutcomeEvaluator {
211 pub const fn new() -> Self {
212 Self
213 }
214
215 pub fn evaluate(
217 &self,
218 contract: &OutcomeContractV1,
219 signals: &[OutcomeSignal],
220 ) -> OutcomeEvaluation {
221 self.evaluate_with_context(contract, signals, EvaluationContext::from_signals(signals))
222 }
223
224 #[allow(clippy::needless_pass_by_value)]
226 pub fn evaluate_with_context(
227 &self,
228 contract: &OutcomeContractV1,
229 signals: &[OutcomeSignal],
230 context: EvaluationContext,
231 ) -> OutcomeEvaluation {
232 self.evaluate_internal(contract, signals, context, Vec::new())
233 }
234
235 #[allow(clippy::needless_pass_by_value)]
237 pub fn evaluate_with_policy<P: PolicyInput>(
238 &self,
239 contract: &OutcomeContractV1,
240 signals: &[OutcomeSignal],
241 policy: P,
242 ) -> OutcomeEvaluation {
243 self.evaluate_internal(
244 contract,
245 signals,
246 EvaluationContext::from_signals(signals),
247 policy.violation_messages(),
248 )
249 }
250
251 pub fn evaluate_outcome(
253 &self,
254 contract: &OutcomeContractV1,
255 signals: &[OutcomeSignal],
256 ) -> AcceptedOutcomeV1 {
257 self.evaluate(contract, signals).outcome
258 }
259
260 #[allow(clippy::manual_checked_ops)]
261 fn evaluate_internal(
262 &self,
263 contract: &OutcomeContractV1,
264 signals: &[OutcomeSignal],
265 context: EvaluationContext,
266 policy_violations: Vec<PolicyViolation>,
267 ) -> OutcomeEvaluation {
268 let mut contributions = Vec::new();
269 let mut required_occurrences = [0usize; 11];
270
271 for requirement in &contract.required_signals {
272 let occurrence = occurrence_index(&mut required_occurrences, requirement.signal_type);
273 contributions.push(evaluate_requirement(
274 contract.task_class,
275 requirement.signal_type,
276 requirement.required,
277 requirement.weight_milli,
278 occurrence,
279 signals,
280 ));
281 }
282
283 for requirement in &contract.optional_signals {
284 contributions.push(evaluate_requirement(
285 contract.task_class,
286 requirement.signal_type,
287 false,
288 requirement.weight_milli,
289 0,
290 signals,
291 ));
292 }
293
294 let total_required_weight: u32 = contributions
295 .iter()
296 .filter(|item| item.required)
297 .map(|item| item.weight_milli)
298 .sum();
299 let passed_required_weight: u32 = contributions
300 .iter()
301 .filter(|item| item.required)
302 .filter(|item| {
303 matches!(
304 item.status,
305 ContributionStatus::Passed | ContributionStatus::AlternativeSatisfied
306 )
307 })
308 .map(|item| item.weight_milli)
309 .sum();
310 let required_failed = contributions
311 .iter()
312 .any(|item| item.required && matches!(item.status, ContributionStatus::Failed));
313 let required_unknown = contributions.iter().any(|item| {
314 item.required
315 && matches!(
316 item.status,
317 ContributionStatus::Missing | ContributionStatus::Unknown
318 )
319 });
320
321 let state = if !policy_violations.is_empty() || required_failed {
322 AcceptanceState::Rejected
323 } else if required_unknown {
324 AcceptanceState::Unknown
325 } else {
326 AcceptanceState::Accepted
327 };
328
329 let quality_score_milli = match state {
330 AcceptanceState::Accepted => Some(1000),
331 AcceptanceState::Rejected => Some(0),
332 AcceptanceState::Unknown if signals.is_empty() => None,
334 AcceptanceState::Unknown => Some(if total_required_weight == 0 {
335 0
336 } else {
337 (passed_required_weight.saturating_mul(1000) / total_required_weight).min(1000)
338 as u16
339 }),
340 };
341
342 let mut reasoning = contributions
343 .iter()
344 .map(|item| {
345 format!(
346 "{}={} (required={}, weight={}, contribution={}): {}",
347 item.signal_type,
348 item.status,
349 item.required,
350 item.weight_milli,
351 item.contribution_milli,
352 item.explanation
353 )
354 })
355 .collect::<Vec<_>>();
356
357 if policy_violations.is_empty() {
358 reasoning.push(format!("final state: {}", state.as_str()));
359 } else {
360 reasoning.push(format!(
361 "policy violation overrides signal result: {}",
362 policy_violations
363 .iter()
364 .map(|violation| format!("{}: {}", violation.code, violation.message))
365 .collect::<Vec<_>>()
366 .join(", ")
367 ));
368 reasoning.push(format!("final state: {}", state.as_str()));
369 }
370
371 let outcome_id = context_outcome_id(&context);
372 let evidence_refs = if state == AcceptanceState::Unknown {
373 Vec::new()
374 } else {
375 let digest = Sha256::digest(
376 serde_json::to_vec(&(contract, signals))
377 .expect("outcome contract and signals are serializable"),
378 );
379 let digest = digest
380 .iter()
381 .fold(String::with_capacity(64), |mut output, byte| {
382 use std::fmt::Write;
383 write!(&mut output, "{byte:02x}").expect("writing to a string cannot fail");
384 output
385 });
386 vec![EvidenceRefV1 {
387 schema_version: Some(1),
388 kind: EvidenceKind::QualityMeasurement,
389 uri: format!("urn:lean-ctx:outcome-evaluation:{}", outcome_id.as_str()),
390 digest: format!("sha256:{digest}"),
391 signature_status: SignatureStatus::NotSigned,
392 media_type: Some("application/json".to_owned()),
393 extensions: Default::default(),
394 }]
395 };
396 let outcome = AcceptedOutcomeV1 {
397 schema_version: 1,
398 outcome_id,
399 task_id: make_task_id(&context.task_id),
400 accepted: state,
401 quality_score_milli,
402 signals: project_protocol_signals(signals),
403 contract_ref: Some(contract.reference()),
404 evidence_refs,
405 observed_at: DETERMINISTIC_OBSERVED_AT.to_owned(),
406 plan_id: None,
407 receipt_id: None,
408 decision_refs: Vec::new(),
409 extensions: Default::default(),
410 };
411 let signals_used = signals.iter().map(|signal| signal.signal_type).collect();
412
413 OutcomeEvaluation {
414 result: outcome.clone(),
415 reasoning,
416 signals_used,
417 outcome,
418 contributions,
419 evaluator_version: context.evaluator_version,
420 policy_violations,
421 supersedes: None,
422 }
423 }
424}
425
426pub fn evaluate(contract: &OutcomeContractV1, signals: &[OutcomeSignal]) -> AcceptedOutcomeV1 {
428 OutcomeEvaluator::new().evaluate_outcome(contract, signals)
429}
430
431pub fn evaluate_detailed(
433 contract: &OutcomeContractV1,
434 signals: &[OutcomeSignal],
435) -> OutcomeEvaluation {
436 OutcomeEvaluator::new().evaluate(contract, signals)
437}
438
439pub fn evaluate_detailed_with_context(
441 contract: &OutcomeContractV1,
442 signals: &[OutcomeSignal],
443 context: EvaluationContext,
444) -> OutcomeEvaluation {
445 OutcomeEvaluator::new().evaluate_with_context(contract, signals, context)
446}
447
448pub fn evaluate_detailed_with_policy<P: PolicyInput>(
450 contract: &OutcomeContractV1,
451 signals: &[OutcomeSignal],
452 policy: P,
453) -> OutcomeEvaluation {
454 OutcomeEvaluator::new().evaluate_with_policy(contract, signals, policy)
455}
456
457pub fn evaluate_for_task(
459 contract: &OutcomeContractV1,
460 signals: &[OutcomeSignal],
461 outcome_id: impl Into<String>,
462 task_id: impl Into<String>,
463 observed_at: impl Into<String>,
464) -> AcceptedOutcomeV1 {
465 let wire_outcome_id = outcome_id.into();
466 let wire_task_id = task_id.into();
467 let observed_at = observed_at.into();
468 let mut outcome = OutcomeEvaluator::new()
469 .evaluate_with_context(
470 contract,
471 signals,
472 EvaluationContext::new(&wire_task_id, &contract.contract_version, EVALUATOR_VERSION),
473 )
474 .result;
475 outcome.outcome_id = make_outcome_id(&wire_outcome_id);
476 outcome.task_id = make_task_id(&wire_task_id);
477 outcome.observed_at = observed_at;
478 outcome
479}
480
481pub fn evaluate_with_policy<P: PolicyInput>(
483 contract: &OutcomeContractV1,
484 signals: &[OutcomeSignal],
485 policy: P,
486) -> AcceptedOutcomeV1 {
487 OutcomeEvaluator::new()
488 .evaluate_with_policy(contract, signals, policy)
489 .outcome
490}
491
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct OutcomeRecord {
496 pub outcome: AcceptedOutcomeV1,
497 pub supersedes: Option<String>,
498}
499
500#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
502pub struct OutcomeHistory {
503 records: Vec<OutcomeRecord>,
504}
505
506impl OutcomeHistory {
507 pub fn new() -> Self {
508 Self::default()
509 }
510
511 pub fn append(&mut self, mut outcome: AcceptedOutcomeV1) -> OutcomeRecord {
514 self.append_internal(&mut outcome, true)
515 }
516
517 pub fn append_for_contract(
519 &mut self,
520 mut outcome: AcceptedOutcomeV1,
521 contract: &OutcomeContractV1,
522 ) -> OutcomeRecord {
523 self.append_internal(&mut outcome, contract.supersession_allowed)
524 }
525
526 pub fn append_evaluation(&mut self, evaluation: &OutcomeEvaluation) -> OutcomeRecord {
528 self.append(evaluation.outcome.clone())
529 }
530
531 pub fn len(&self) -> usize {
532 self.records.len()
533 }
534
535 pub fn is_empty(&self) -> bool {
536 self.records.is_empty()
537 }
538
539 pub fn records(&self) -> &[OutcomeRecord] {
540 &self.records
541 }
542
543 pub fn latest(&self) -> Option<&OutcomeRecord> {
544 self.records.last()
545 }
546
547 fn append_internal(
548 &mut self,
549 outcome: &mut AcceptedOutcomeV1,
550 supersession_allowed: bool,
551 ) -> OutcomeRecord {
552 let previous = if supersession_allowed {
553 self.records.iter().rev().find(|record| {
554 record.outcome.task_id == outcome.task_id
555 && record.outcome.contract_ref == outcome.contract_ref
556 })
557 } else {
558 None
559 };
560
561 let supersedes = previous.map(|record| record.outcome.outcome_id.as_str().to_owned());
562 if let Some(previous_id) = &supersedes {
563 if outcome.outcome_id.as_str() == previous_id {
564 let replacement = format!("outcome-superseding-{}", self.records.len() + 1);
565 outcome.outcome_id = make_outcome_id(&replacement);
566 }
567 }
568
569 let record = OutcomeRecord {
570 outcome: outcome.clone(),
571 supersedes,
572 };
573 self.records.push(record.clone());
574 record
575 }
576}
577
578pub type OutcomeLedger = OutcomeHistory;
580
581fn occurrence_index(counts: &mut [usize; 11], signal_type: SignalType) -> usize {
582 let index = signal_type_index(signal_type);
583 let occurrence = counts[index];
584 counts[index] += 1;
585 occurrence
586}
587
588fn signal_type_index(signal_type: SignalType) -> usize {
589 match signal_type {
590 SignalType::BuildSuccess => 0,
591 SignalType::TestsPassing => 1,
592 SignalType::LintClean => 2,
593 SignalType::TypecheckPassing => 3,
594 SignalType::AgentCompletion => 4,
595 SignalType::RetryCount => 5,
596 SignalType::HumanAcceptance => 6,
597 SignalType::PrMerge => 7,
598 SignalType::CiPassing => 8,
599 SignalType::Correction => 9,
600 SignalType::Rollback => 10,
601 }
602}
603
604fn evaluate_requirement(
605 task_class: TaskClass,
606 signal_type: SignalType,
607 required: bool,
608 weight_milli: u32,
609 occurrence: usize,
610 signals: &[OutcomeSignal],
611) -> SignalContribution {
612 let observation = find_observation(task_class, signal_type, occurrence, signals);
613 let (value, status, explanation) = match observation {
614 None => (
615 SignalValue::Unknown,
616 if required {
617 ContributionStatus::Missing
618 } else {
619 ContributionStatus::NotRequired
620 },
621 if required {
622 "required signal not observed".to_owned()
623 } else {
624 "optional signal not observed".to_owned()
625 },
626 ),
627 Some(signal) => match signal.value {
628 SignalValue::Unknown => (
629 SignalValue::Unknown,
630 ContributionStatus::Unknown,
631 "signal observed with unknown value".to_owned(),
632 ),
633 value => {
634 let passed = value_passes(task_class, signal_type, occurrence, &value);
635 let status = if passed {
636 if task_class == TaskClass::Investigation
637 && signal_type == SignalType::HumanAcceptance
638 && !has_positive_signal(SignalType::HumanAcceptance, signals)
639 {
640 ContributionStatus::AlternativeSatisfied
641 } else {
642 ContributionStatus::Passed
643 }
644 } else {
645 ContributionStatus::Failed
646 };
647 (
648 value,
649 status,
650 format!(
651 "observed value contributed {}",
652 if passed { "success" } else { "failure" }
653 ),
654 )
655 }
656 },
657 };
658 let contribution_milli = if matches!(
659 status,
660 ContributionStatus::Passed | ContributionStatus::AlternativeSatisfied
661 ) {
662 weight_milli
663 } else {
664 0
665 };
666
667 SignalContribution {
668 signal_type,
669 required,
670 weight_milli,
671 value,
672 status,
673 contribution_milli,
674 explanation,
675 }
676}
677
678fn find_observation(
679 task_class: TaskClass,
680 signal_type: SignalType,
681 occurrence: usize,
682 signals: &[OutcomeSignal],
683) -> Option<OutcomeSignal> {
684 if task_class == TaskClass::Investigation && signal_type == SignalType::HumanAcceptance {
685 if let Some(human) = signals
686 .iter()
687 .rfind(|signal| signal.signal_type == SignalType::HumanAcceptance)
688 {
689 if !matches!(human.value, SignalValue::Unknown) {
690 return Some(human.clone());
691 }
692 }
693
694 return signals
695 .iter()
696 .rfind(|signal| signal.signal_type == SignalType::AgentCompletion)
697 .cloned();
698 }
699
700 if task_class == TaskClass::TestAddition && signal_type == SignalType::TestsPassing {
701 if occurrence == 0 {
702 return signals
703 .iter()
704 .rfind(|signal| {
705 signal.signal_type == SignalType::TestsPassing
706 && matches!(signal.value, SignalValue::Boolean(_))
707 })
708 .cloned();
709 }
710
711 if let Some(count_delta) = signals.iter().rfind(|signal| {
712 signal.signal_type == SignalType::TestsPassing
713 && matches!(signal.value, SignalValue::Count(_))
714 }) {
715 return Some(count_delta.clone());
716 }
717
718 return signals
719 .iter()
720 .rfind(|signal| {
721 signal.signal_type == SignalType::AgentCompletion
722 && matches!(signal.value, SignalValue::Count(_))
723 })
724 .cloned();
725 }
726
727 signals
728 .iter()
729 .rfind(|signal| signal.signal_type == signal_type)
730 .cloned()
731}
732
733fn value_passes(
734 _task_class: TaskClass,
735 signal_type: SignalType,
736 _occurrence: usize,
737 value: &SignalValue,
738) -> bool {
739 match value {
740 SignalValue::Boolean(value) => *value,
741 SignalValue::Count(value) => match signal_type {
742 SignalType::RetryCount => true,
743 _ => *value > 0,
744 },
745 SignalValue::Unknown => false,
746 }
747}
748
749fn has_positive_signal(signal_type: SignalType, signals: &[OutcomeSignal]) -> bool {
750 signals.iter().any(|signal| {
751 signal.signal_type == signal_type
752 && value_passes(TaskClass::BugFix, signal_type, 0, &signal.value)
753 })
754}
755
756const DETERMINISTIC_OBSERVED_AT: &str = "1970-01-01T00:00:00Z";
757
758fn context_outcome_id(context: &EvaluationContext) -> OutcomeId {
759 let seed = format!(
760 "outcome-v1:{}:{}:{}",
761 context.task_id, context.contract_version, context.evaluator_version
762 );
763 let value = format!("outcome:{}", blake3::hash(seed.as_bytes()).to_hex());
764 make_outcome_id(&value)
765}
766
767fn make_outcome_id(value: &str) -> OutcomeId {
768 OutcomeId::new(value.to_owned()).unwrap_or_else(|_| {
769 OutcomeId::new("outcome-unknown".to_owned()).unwrap_or_else(|_| {
770 unreachable!("fixed outcome identifier must satisfy protocol bounds")
771 })
772 })
773}
774
775fn make_task_id(value: &str) -> TaskId {
776 TaskId::new(value.to_owned()).unwrap_or_else(|_| {
777 TaskId::new("task-unknown".to_owned())
778 .unwrap_or_else(|_| unreachable!("fixed task identifier must satisfy protocol bounds"))
779 })
780}
781
782fn project_protocol_signals(signals: &[OutcomeSignal]) -> OutcomeSignalsV1 {
783 OutcomeSignalsV1 {
784 build: latest_state(signals, &[SignalType::BuildSuccess, SignalType::CiPassing]),
785 tests: latest_state(signals, &[SignalType::TestsPassing]),
786 lint: latest_state(signals, &[SignalType::LintClean]),
787 typecheck: latest_state(signals, &[SignalType::TypecheckPassing]),
788 completion: latest_state(
789 signals,
790 &[SignalType::AgentCompletion, SignalType::HumanAcceptance],
791 ),
792 pr: latest_state(signals, &[SignalType::PrMerge]),
793 correction: latest_state(signals, &[SignalType::Correction]),
794 rollback: latest_state(signals, &[SignalType::Rollback]),
795 retry: latest_state(signals, &[SignalType::RetryCount]),
796 }
797}
798
799fn latest_state(signals: &[OutcomeSignal], signal_types: &[SignalType]) -> Option<SignalState> {
800 signals
801 .iter()
802 .rfind(|signal| signal_types.contains(&signal.signal_type))
803 .map(|signal| match signal.value {
804 SignalValue::Boolean(value) => {
805 if value {
806 SignalState::Passed
807 } else {
808 SignalState::Failed
809 }
810 }
811 SignalValue::Count(value) => {
812 if value > 0 {
813 SignalState::Passed
814 } else {
815 SignalState::Failed
816 }
817 }
818 SignalValue::Unknown => SignalState::Unknown,
819 })
820}
821
822trait AcceptanceStateText {
823 fn as_str(&self) -> &'static str;
824}
825
826impl AcceptanceStateText for AcceptanceState {
827 fn as_str(&self) -> &'static str {
828 match self {
829 AcceptanceState::Accepted => "accepted",
830 AcceptanceState::Rejected => "rejected",
831 AcceptanceState::Unknown => "unknown",
832 }
833 }
834}
835
836impl std::fmt::Display for ContributionStatus {
837 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
838 let value = match self {
839 Self::Passed => "passed",
840 Self::Failed => "failed",
841 Self::Missing => "missing",
842 Self::Unknown => "unknown",
843 Self::NotRequired => "not_required",
844 Self::AlternativeSatisfied => "alternative_satisfied",
845 };
846 formatter.write_str(value)
847 }
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853 use crate::outcome::contracts::{
854 bug_fix_contract, investigation_contract, test_addition_contract,
855 };
856 use crate::outcome::signals::LocalSignalAdapters;
857
858 fn bug_fix_signals() -> Vec<OutcomeSignal> {
859 vec![
860 LocalSignalAdapters::build_success(true),
861 LocalSignalAdapters::tests_passing(true),
862 LocalSignalAdapters::pr_merge(true),
863 ]
864 }
865
866 #[test]
867 fn bug_fix_with_all_signals_is_accepted() {
868 let result = evaluate_detailed(&bug_fix_contract(), &bug_fix_signals());
869
870 assert_eq!(result.outcome.accepted, AcceptanceState::Accepted);
871 assert_eq!(result.outcome.quality_score_milli, Some(1000));
872 assert_eq!(result.contributions.len(), 3);
873 assert!(result.explanation().contains("build_success"));
874 assert!(result.explanation().contains("tests_passing"));
875 }
876
877 #[test]
878 fn bug_fix_with_failed_tests_is_rejected() {
879 let signals = vec![
880 LocalSignalAdapters::build_success(true),
881 LocalSignalAdapters::tests_passing(false),
882 ];
883 let result = evaluate_detailed(&bug_fix_contract(), &signals);
884
885 assert_eq!(result.outcome.accepted, AcceptanceState::Rejected);
886 assert_eq!(result.outcome.quality_score_milli, Some(0));
887 }
888
889 #[test]
890 fn empty_signals_never_fabricate_a_quality_score() {
891 for task_class in [
892 TaskClass::BugFix,
893 TaskClass::Refactor,
894 TaskClass::TestAddition,
895 TaskClass::Documentation,
896 TaskClass::Investigation,
897 ] {
898 let result = evaluate_detailed(&OutcomeContractV1::for_task_class(task_class), &[]);
899 assert_eq!(result.outcome.accepted, AcceptanceState::Unknown);
900 assert_eq!(result.outcome.quality_score_milli, None);
901 assert!(
902 result.outcome.evidence_refs.is_empty(),
903 "{:?}",
904 result.outcome.evidence_refs
905 );
906 assert_eq!(result.result, result.outcome);
907 }
908 }
909
910 #[test]
911 fn bug_fix_with_missing_signals_is_unknown() {
912 let result = evaluate_detailed(
913 &bug_fix_contract(),
914 &[LocalSignalAdapters::build_success(true)],
915 );
916
917 assert_eq!(result.outcome.accepted, AcceptanceState::Unknown);
918 assert!(result.outcome.quality_score_milli.is_some());
919 assert!(
920 result
921 .contributions
922 .iter()
923 .any(|item| item.signal_type == SignalType::TestsPassing
924 && item.status == ContributionStatus::Missing)
925 );
926 }
927
928 #[test]
929 fn policy_violation_overrides_acceptance() {
930 let result = evaluate_detailed_with_policy(&bug_fix_contract(), &bug_fix_signals(), true);
931
932 assert_eq!(result.outcome.accepted, AcceptanceState::Rejected);
933 assert!(
934 result
935 .explanation()
936 .contains("policy violation overrides signal result")
937 );
938 }
939
940 #[test]
941 fn evaluator_is_deterministic() {
942 let contract = bug_fix_contract();
943 let signals = bug_fix_signals();
944
945 assert_eq!(
946 evaluate_detailed(&contract, &signals),
947 evaluate_detailed(&contract, &signals)
948 );
949 }
950
951 #[test]
952 fn late_signal_creates_superseding_outcome() {
953 let contract = bug_fix_contract();
954 let first = evaluate_detailed(&contract, &[LocalSignalAdapters::build_success(true)]);
955 let second = evaluate_detailed(&contract, &bug_fix_signals());
956 let mut history = OutcomeHistory::new();
957
958 let first_record = history.append_for_contract(first.outcome, &contract);
959 let second_record = history.append_for_contract(second.outcome, &contract);
960
961 assert_eq!(history.len(), 2);
962 assert_eq!(
963 second_record.supersedes,
964 Some(first_record.outcome.outcome_id.as_str().to_owned())
965 );
966 assert_ne!(
967 first_record.outcome.outcome_id,
968 second_record.outcome.outcome_id
969 );
970 assert_eq!(history.records()[0], first_record);
971 }
972
973 #[test]
974 fn investigation_accepts_completion_as_explicit_alternative() {
975 let result = evaluate_detailed(
976 &investigation_contract(),
977 &[LocalSignalAdapters::agent_completion(true)],
978 );
979
980 assert_eq!(result.outcome.accepted, AcceptanceState::Accepted);
981 assert!(result.explanation().contains("alternative_satisfied"));
982 }
983
984 #[test]
985 fn test_addition_requires_passing_suite_and_count_delta() {
986 let signals = vec![
987 LocalSignalAdapters::tests_passing(true),
988 LocalSignalAdapters::test_count_increased(1),
989 ];
990
991 let result = evaluate_detailed(&test_addition_contract(), &signals);
992 assert_eq!(result.outcome.accepted, AcceptanceState::Accepted);
993 }
994}