Skip to main content

lean_ctx_protocol/
triage.rs

1//! Task triage and profile contracts.
2
3use crate::{
4    RiskClass, TaskComplexity,
5    common::{
6        ValidationError, deserialize_milliunit, deserialize_schema_version, validate_milliunit,
7        validate_schema_version,
8    },
9};
10use serde::{Deserialize, Serialize};
11
12/// Scope at which a task's work is expected to occur.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TaskScope {
16    #[default]
17    SingleFile,
18    MultiFile,
19    CrossModule,
20    CrossProject,
21}
22
23/// Backend used to produce a triage result.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum TriageBackend {
27    #[default]
28    Rules,
29    Semantic,
30    Hybrid,
31}
32
33/// Classified task profile used by routing and context planning.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct TaskProfileV1 {
37    pub primary_intent: String,
38    pub task_class: String,
39    pub complexity: TaskComplexity,
40    pub scope: TaskScope,
41    #[serde(deserialize_with = "deserialize_milliunit")]
42    pub context_need_milli: u16,
43    #[serde(deserialize_with = "deserialize_milliunit")]
44    pub reasoning_need_milli: u16,
45    pub risk_signal: RiskClass,
46    #[serde(deserialize_with = "deserialize_milliunit")]
47    pub confidence_milli: u16,
48    /// Capability that produced this profile, when the producer is known.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub capability_id: Option<String>,
51    /// Version of the capability that produced this profile.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub capability_version: Option<String>,
54    #[serde(default)]
55    pub keywords: Vec<String>,
56    #[serde(default)]
57    pub language_hints: Vec<String>,
58}
59
60impl Default for TaskProfileV1 {
61    fn default() -> Self {
62        Self {
63            primary_intent: String::new(),
64            task_class: String::new(),
65            complexity: TaskComplexity::Low,
66            scope: TaskScope::SingleFile,
67            context_need_milli: 0,
68            reasoning_need_milli: 0,
69            risk_signal: RiskClass::Low,
70            confidence_milli: 0,
71            capability_id: None,
72            capability_version: None,
73            keywords: Vec::new(),
74            language_hints: Vec::new(),
75        }
76    }
77}
78
79impl TaskProfileV1 {
80    /// Validate profile fields and milliunit bounds.
81    pub fn validate(&self) -> Result<(), ValidationError> {
82        validate_milliunit(self.context_need_milli, "context_need_milli")?;
83        validate_milliunit(self.reasoning_need_milli, "reasoning_need_milli")?;
84        validate_milliunit(self.confidence_milli, "confidence_milli")
85    }
86}
87
88/// Auditable result produced by task triage.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct TriageResultV1 {
92    #[serde(deserialize_with = "deserialize_schema_version")]
93    pub schema_version: u32,
94    pub triage_result_id: String,
95    pub task_id: String,
96    pub profile: TaskProfileV1,
97    #[serde(deserialize_with = "deserialize_milliunit")]
98    pub confidence_milli: u16,
99    pub backend: TriageBackend,
100    pub analyzer_version: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub model_ref: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub degraded_reason: Option<String>,
105}
106
107impl Default for TriageResultV1 {
108    fn default() -> Self {
109        Self {
110            schema_version: 1,
111            triage_result_id: String::new(),
112            task_id: String::new(),
113            profile: TaskProfileV1::default(),
114            confidence_milli: 0,
115            backend: TriageBackend::Rules,
116            analyzer_version: String::new(),
117            model_ref: None,
118            degraded_reason: None,
119        }
120    }
121}
122
123impl TriageResultV1 {
124    /// Schema version represented by this type.
125    pub const SCHEMA_VERSION: u32 = 1;
126
127    /// Validate result schema, nested profile, and confidence bounds.
128    pub fn validate(&self) -> Result<(), ValidationError> {
129        validate_schema_version(self.schema_version)?;
130        self.profile.validate()?;
131        validate_milliunit(self.confidence_milli, "confidence_milli")
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn valid_profile() -> TaskProfileV1 {
140        TaskProfileV1 {
141            primary_intent: "implement".to_owned(),
142            task_class: "coding".to_owned(),
143            complexity: TaskComplexity::Medium,
144            scope: TaskScope::CrossModule,
145            context_need_milli: 700,
146            reasoning_need_milli: 800,
147            risk_signal: RiskClass::Low,
148            confidence_milli: 900,
149            capability_id: Some("capability://leanctx/triage".to_owned()),
150            capability_version: Some("1.0.0".to_owned()),
151            keywords: vec!["rust".to_owned(), "protocol".to_owned()],
152            language_hints: vec!["rust".to_owned()],
153        }
154    }
155
156    fn valid_result() -> TriageResultV1 {
157        TriageResultV1 {
158            schema_version: 1,
159            triage_result_id: "triage-1".to_owned(),
160            task_id: "task-1".to_owned(),
161            profile: valid_profile(),
162            confidence_milli: 850,
163            backend: TriageBackend::Hybrid,
164            analyzer_version: "rules-1".to_owned(),
165            model_ref: Some("model-1".to_owned()),
166            degraded_reason: None,
167        }
168    }
169
170    #[test]
171    fn serialization_round_trip() {
172        let result = valid_result();
173        let json = serde_json::to_string(&result).expect("triage result should serialize");
174        let decoded: TriageResultV1 =
175            serde_json::from_str(&json).expect("triage result should deserialize");
176        assert_eq!(result, decoded);
177        result.validate().expect("triage result should be valid");
178    }
179
180    #[test]
181    fn profile_capability_metadata_is_optional_and_backward_compatible() {
182        let profile = valid_profile();
183        let json = serde_json::to_value(&profile).expect("profile should serialize");
184        assert_eq!(json["capability_id"], "capability://leanctx/triage");
185        assert_eq!(
186            serde_json::from_value::<TaskProfileV1>(json.clone())
187                .expect("profile with capability metadata should deserialize"),
188            profile
189        );
190
191        let mut legacy = json;
192        let object = legacy
193            .as_object_mut()
194            .expect("serialized profile should be an object");
195        object.remove("capability_id");
196        object.remove("capability_version");
197
198        let decoded: TaskProfileV1 =
199            serde_json::from_value(legacy).expect("legacy profile should deserialize");
200        assert_eq!(decoded.capability_id, None);
201        assert_eq!(decoded.capability_version, None);
202
203        let without_capability = serde_json::to_value(decoded)
204            .expect("profile without capability metadata should serialize");
205        assert!(without_capability.get("capability_id").is_none());
206        assert!(without_capability.get("capability_version").is_none());
207    }
208
209    #[test]
210    fn validation_rejects_invalid_schema_and_milliunits() {
211        let mut result = valid_result();
212        result.schema_version = 2;
213        assert!(result.validate().is_err());
214
215        result.schema_version = 1;
216        result.confidence_milli = 1001;
217        assert!(result.validate().is_err());
218
219        let mut profile = valid_profile();
220        profile.context_need_milli = 1001;
221        assert!(profile.validate().is_err());
222    }
223
224    #[test]
225    fn defaults_are_stable_and_valid() {
226        let profile = TaskProfileV1::default();
227        assert_eq!(profile, TaskProfileV1::default());
228        profile.validate().expect("default profile should be valid");
229
230        let result = TriageResultV1::default();
231        assert_eq!(result, TriageResultV1::default());
232        result.validate().expect("default result should be valid");
233        assert_eq!(TaskScope::default(), TaskScope::SingleFile);
234        assert_eq!(TriageBackend::default(), TriageBackend::Rules);
235    }
236}