Skip to main content

lean_ctx_workgraph/outcome/
contracts.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Versioned acceptance contracts for coding task classes.
4
5use serde::{Deserialize, Serialize};
6
7use super::signals::SignalType;
8
9/// Current local outcome-contract version.
10pub const OUTCOME_CONTRACT_VERSION: &str = "1.0.0";
11
12/// Coding task class used to select an acceptance policy.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TaskClass {
16    BugFix,
17    Refactor,
18    TestAddition,
19    Documentation,
20    Investigation,
21}
22
23impl TaskClass {
24    /// Stable snake-case identifier used in contract references.
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::BugFix => "bug_fix",
28            Self::Refactor => "refactor",
29            Self::TestAddition => "test_addition",
30            Self::Documentation => "documentation",
31            Self::Investigation => "investigation",
32        }
33    }
34}
35
36impl std::fmt::Display for TaskClass {
37    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        formatter.write_str(self.as_str())
39    }
40}
41
42/// One signal and its contribution to the contract's acceptance score.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct SignalRequirement {
45    pub signal_type: SignalType,
46    pub required: bool,
47    /// Contribution in milli-units; canonical constructors keep this in 0..=1000.
48    pub weight_milli: u32,
49}
50
51impl SignalRequirement {
52    /// Build a required signal requirement.
53    pub const fn required(signal_type: SignalType, weight_milli: u32) -> Self {
54        Self {
55            signal_type,
56            required: true,
57            weight_milli,
58        }
59    }
60
61    /// Build an optional signal requirement.
62    pub const fn optional(signal_type: SignalType, weight_milli: u32) -> Self {
63        Self {
64            signal_type,
65            required: false,
66            weight_milli,
67        }
68    }
69
70    /// Build a requirement with explicit requiredness.
71    pub const fn new(signal_type: SignalType, required: bool, weight_milli: u32) -> Self {
72        Self {
73            signal_type,
74            required,
75            weight_milli,
76        }
77    }
78}
79
80/// Acceptance policy for one coding-task class.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct OutcomeContractV1 {
83    pub task_class: TaskClass,
84    pub contract_version: String,
85    pub required_signals: Vec<SignalRequirement>,
86    pub optional_signals: Vec<SignalRequirement>,
87    pub expiry_window_hours: u32,
88    pub supersession_allowed: bool,
89}
90
91impl OutcomeContractV1 {
92    /// Return the canonical contract for a task class.
93    pub fn for_task_class(task_class: TaskClass) -> Self {
94        match task_class {
95            TaskClass::BugFix => bug_fix_contract(),
96            TaskClass::Refactor => refactor_contract(),
97            TaskClass::TestAddition => test_addition_contract(),
98            TaskClass::Documentation => documentation_contract(),
99            TaskClass::Investigation => investigation_contract(),
100        }
101    }
102
103    /// Stable reference used by accepted outcomes.
104    pub fn reference(&self) -> String {
105        format!(
106            "outcome-contract/{}/v{}",
107            self.task_class.as_str(),
108            self.contract_version
109        )
110    }
111
112    /// Validate the local contract invariants.
113    pub fn validate(&self) -> Result<(), String> {
114        if self.contract_version.trim().is_empty() {
115            return Err("contract_version must not be empty".to_owned());
116        }
117
118        if self
119            .required_signals
120            .iter()
121            .chain(self.optional_signals.iter())
122            .any(|requirement| requirement.weight_milli > 1000)
123        {
124            return Err("signal weight_milli must be between 0 and 1000".to_owned());
125        }
126
127        if self
128            .optional_signals
129            .iter()
130            .any(|requirement| requirement.required)
131        {
132            return Err("optional signal requirements must set required=false".to_owned());
133        }
134
135        Ok(())
136    }
137}
138
139/// Canonical bug-fix contract: build and tests must pass; PR merge is optional.
140pub fn bug_fix_contract() -> OutcomeContractV1 {
141    OutcomeContractV1 {
142        task_class: TaskClass::BugFix,
143        contract_version: OUTCOME_CONTRACT_VERSION.to_owned(),
144        required_signals: vec![
145            SignalRequirement::required(SignalType::BuildSuccess, 500),
146            SignalRequirement::required(SignalType::TestsPassing, 500),
147        ],
148        optional_signals: vec![SignalRequirement::optional(SignalType::PrMerge, 100)],
149        expiry_window_hours: 168,
150        supersession_allowed: true,
151    }
152}
153
154/// Short alias for [`bug_fix_contract`].
155pub fn bug_fix() -> OutcomeContractV1 {
156    bug_fix_contract()
157}
158
159/// Canonical refactor contract: build, tests, and lint must pass.
160pub fn refactor_contract() -> OutcomeContractV1 {
161    OutcomeContractV1 {
162        task_class: TaskClass::Refactor,
163        contract_version: OUTCOME_CONTRACT_VERSION.to_owned(),
164        required_signals: vec![
165            SignalRequirement::required(SignalType::BuildSuccess, 334),
166            SignalRequirement::required(SignalType::TestsPassing, 333),
167            SignalRequirement::required(SignalType::LintClean, 333),
168        ],
169        optional_signals: vec![SignalRequirement::optional(
170            SignalType::TypecheckPassing,
171            100,
172        )],
173        expiry_window_hours: 168,
174        supersession_allowed: true,
175    }
176}
177
178/// Short alias for [`refactor_contract`].
179pub fn refactor() -> OutcomeContractV1 {
180    refactor_contract()
181}
182
183/// Canonical test-addition contract.
184///
185/// The two `TestsPassing` requirements intentionally distinguish a boolean
186/// passing result from a positive count delta. The evaluator uses the first
187/// occurrence for the suite result and the second for the count adapter.
188pub fn test_addition_contract() -> OutcomeContractV1 {
189    OutcomeContractV1 {
190        task_class: TaskClass::TestAddition,
191        contract_version: OUTCOME_CONTRACT_VERSION.to_owned(),
192        required_signals: vec![
193            SignalRequirement::required(SignalType::TestsPassing, 600),
194            SignalRequirement::required(SignalType::TestsPassing, 400),
195        ],
196        optional_signals: vec![SignalRequirement::optional(SignalType::CiPassing, 100)],
197        expiry_window_hours: 336,
198        supersession_allowed: true,
199    }
200}
201
202/// Short alias for [`test_addition_contract`].
203pub fn test_addition() -> OutcomeContractV1 {
204    test_addition_contract()
205}
206
207/// Canonical documentation contract: the documentation build must pass.
208pub fn documentation_contract() -> OutcomeContractV1 {
209    OutcomeContractV1 {
210        task_class: TaskClass::Documentation,
211        contract_version: OUTCOME_CONTRACT_VERSION.to_owned(),
212        required_signals: vec![SignalRequirement::required(SignalType::BuildSuccess, 1000)],
213        optional_signals: Vec::new(),
214        expiry_window_hours: 720,
215        supersession_allowed: true,
216    }
217}
218
219/// Short alias for [`documentation_contract`].
220pub fn documentation() -> OutcomeContractV1 {
221    documentation_contract()
222}
223
224/// Canonical investigation contract.
225///
226/// The required human-acceptance requirement is evaluated as an explicit
227/// alternative to `AgentCompletion`; the evaluator documents that alternative
228/// in the contribution explanation.
229pub fn investigation_contract() -> OutcomeContractV1 {
230    OutcomeContractV1 {
231        task_class: TaskClass::Investigation,
232        contract_version: OUTCOME_CONTRACT_VERSION.to_owned(),
233        required_signals: vec![SignalRequirement::required(
234            SignalType::HumanAcceptance,
235            1000,
236        )],
237        optional_signals: vec![SignalRequirement::optional(
238            SignalType::AgentCompletion,
239            1000,
240        )],
241        expiry_window_hours: 168,
242        supersession_allowed: true,
243    }
244}
245
246/// Short alias for [`investigation_contract`].
247pub fn investigation() -> OutcomeContractV1 {
248    investigation_contract()
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn canonical_contracts_have_distinct_task_classes() {
257        let contracts = [
258            bug_fix_contract(),
259            refactor_contract(),
260            test_addition_contract(),
261            documentation_contract(),
262            investigation_contract(),
263        ];
264
265        assert_eq!(
266            contracts
267                .iter()
268                .map(|contract| contract.task_class)
269                .collect::<std::collections::HashSet<_>>()
270                .len(),
271            5
272        );
273        assert!(contracts.iter().all(|contract| contract.validate().is_ok()));
274    }
275}