Skip to main content

lean_ctx_workgraph/outcome/
evaluator.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Deterministic, versioned outcome evaluation.
4
5use 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
16/// Version of the evaluator algorithm and its reasoning format.
17pub const EVALUATOR_VERSION: &str = "1.0.1";
18
19/// Versioned context used to derive a deterministic wire outcome.
20#[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    /// Build evaluation context from stable caller-provided versions.
29    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    /// Build deterministic context when only signals are available.
42    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/// Result of one required or optional signal evaluation.
59#[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/// Explain how one contract requirement contributed to the result.
71#[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/// Detailed evaluator result; `outcome` is the canonical wire observation.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct OutcomeEvaluation {
85    /// Canonical result required by the outcome-evaluation API.
86    pub result: AcceptedOutcomeV1,
87    /// Deterministic per-signal reasoning entries.
88    pub reasoning: Vec<String>,
89    /// Signal kinds considered by the evaluator, in input order.
90    pub signals_used: Vec<SignalType>,
91    /// Compatibility alias for older callers.
92    pub outcome: AcceptedOutcomeV1,
93    pub contributions: Vec<SignalContribution>,
94    pub evaluator_version: String,
95    pub policy_violations: Vec<PolicyViolation>,
96    /// Set by an outcome history when this result supersedes an older one.
97    pub supersedes: Option<String>,
98}
99
100impl OutcomeEvaluation {
101    /// Return the tri-state result.
102    pub const fn state(&self) -> AcceptanceState {
103        self.outcome.accepted
104    }
105
106    /// Return whether the result is accepted.
107    pub const fn is_accepted(&self) -> bool {
108        matches!(self.outcome.accepted, AcceptanceState::Accepted)
109    }
110
111    /// Return whether required evidence is still missing.
112    pub const fn is_unknown(&self) -> bool {
113        matches!(self.outcome.accepted, AcceptanceState::Unknown)
114    }
115
116    /// Return the human-readable deterministic reasoning string.
117    pub fn explanation(&self) -> String {
118        self.reasoning.join("; ")
119    }
120}
121
122/// Compatibility alias for callers that name the detailed result explicitly.
123pub type EvaluationResult = OutcomeEvaluation;
124
125/// A policy violation that forces rejection regardless of signal score.
126#[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
153/// Input accepted by policy-aware evaluator helpers.
154pub 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/// Deterministic evaluator with no clock, randomness, or mutable global state.
207#[derive(Debug, Clone, Copy, Default)]
208pub struct OutcomeEvaluator;
209
210impl OutcomeEvaluator {
211    pub const fn new() -> Self {
212        Self
213    }
214
215    /// Evaluate a contract with context derived only from the signal input.
216    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    /// Evaluate a contract with explicit stable wire identity and timestamp.
225    #[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    /// Evaluate a contract with policy violations that override signal success.
236    #[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    /// Return only the canonical wire outcome.
252    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            // No observation is not a measured zero-quality result.
333            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
426/// Evaluate and return the canonical wire outcome.
427pub fn evaluate(contract: &OutcomeContractV1, signals: &[OutcomeSignal]) -> AcceptedOutcomeV1 {
428    OutcomeEvaluator::new().evaluate_outcome(contract, signals)
429}
430
431/// Evaluate and retain detailed per-signal reasoning.
432pub fn evaluate_detailed(
433    contract: &OutcomeContractV1,
434    signals: &[OutcomeSignal],
435) -> OutcomeEvaluation {
436    OutcomeEvaluator::new().evaluate(contract, signals)
437}
438
439/// Evaluate with explicit outcome/task identity and timestamp.
440pub 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
448/// Evaluate with policy violations that always force rejection.
449pub 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
457/// Evaluate with stable caller-provided identity and timestamp.
458pub 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
481/// Evaluate with policy violations and return only the wire outcome.
482pub 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/// Append-only lineage record. A late signal creates a new record and points
493/// at the prior record; it never overwrites the prior accepted outcome.
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct OutcomeRecord {
496    pub outcome: AcceptedOutcomeV1,
497    pub supersedes: Option<String>,
498}
499
500/// In-memory append-only outcome history for local evaluation and tests.
501#[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    /// Append an outcome, superseding the latest outcome for the same task and
512    /// contract when one exists.
513    pub fn append(&mut self, mut outcome: AcceptedOutcomeV1) -> OutcomeRecord {
514        self.append_internal(&mut outcome, true)
515    }
516
517    /// Append using the contract's supersession policy.
518    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    /// Append a detailed evaluation and return its lineage record.
527    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
578/// Alias emphasizing append-only ledger semantics.
579pub 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}