use crate::{
RiskClass, TaskComplexity,
common::{
ValidationError, deserialize_milliunit, deserialize_schema_version, validate_milliunit,
validate_schema_version,
},
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskScope {
#[default]
SingleFile,
MultiFile,
CrossModule,
CrossProject,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriageBackend {
#[default]
Rules,
Semantic,
Hybrid,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskProfileV1 {
pub primary_intent: String,
pub task_class: String,
pub complexity: TaskComplexity,
pub scope: TaskScope,
#[serde(deserialize_with = "deserialize_milliunit")]
pub context_need_milli: u16,
#[serde(deserialize_with = "deserialize_milliunit")]
pub reasoning_need_milli: u16,
pub risk_signal: RiskClass,
#[serde(deserialize_with = "deserialize_milliunit")]
pub confidence_milli: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_version: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub language_hints: Vec<String>,
}
impl Default for TaskProfileV1 {
fn default() -> Self {
Self {
primary_intent: String::new(),
task_class: String::new(),
complexity: TaskComplexity::Low,
scope: TaskScope::SingleFile,
context_need_milli: 0,
reasoning_need_milli: 0,
risk_signal: RiskClass::Low,
confidence_milli: 0,
capability_id: None,
capability_version: None,
keywords: Vec::new(),
language_hints: Vec::new(),
}
}
}
impl TaskProfileV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
validate_milliunit(self.context_need_milli, "context_need_milli")?;
validate_milliunit(self.reasoning_need_milli, "reasoning_need_milli")?;
validate_milliunit(self.confidence_milli, "confidence_milli")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TriageResultV1 {
#[serde(deserialize_with = "deserialize_schema_version")]
pub schema_version: u32,
pub triage_result_id: String,
pub task_id: String,
pub profile: TaskProfileV1,
#[serde(deserialize_with = "deserialize_milliunit")]
pub confidence_milli: u16,
pub backend: TriageBackend,
pub analyzer_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub degraded_reason: Option<String>,
}
impl Default for TriageResultV1 {
fn default() -> Self {
Self {
schema_version: 1,
triage_result_id: String::new(),
task_id: String::new(),
profile: TaskProfileV1::default(),
confidence_milli: 0,
backend: TriageBackend::Rules,
analyzer_version: String::new(),
model_ref: None,
degraded_reason: None,
}
}
}
impl TriageResultV1 {
pub const SCHEMA_VERSION: u32 = 1;
pub fn validate(&self) -> Result<(), ValidationError> {
validate_schema_version(self.schema_version)?;
self.profile.validate()?;
validate_milliunit(self.confidence_milli, "confidence_milli")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_profile() -> TaskProfileV1 {
TaskProfileV1 {
primary_intent: "implement".to_owned(),
task_class: "coding".to_owned(),
complexity: TaskComplexity::Medium,
scope: TaskScope::CrossModule,
context_need_milli: 700,
reasoning_need_milli: 800,
risk_signal: RiskClass::Low,
confidence_milli: 900,
capability_id: Some("capability://leanctx/triage".to_owned()),
capability_version: Some("1.0.0".to_owned()),
keywords: vec!["rust".to_owned(), "protocol".to_owned()],
language_hints: vec!["rust".to_owned()],
}
}
fn valid_result() -> TriageResultV1 {
TriageResultV1 {
schema_version: 1,
triage_result_id: "triage-1".to_owned(),
task_id: "task-1".to_owned(),
profile: valid_profile(),
confidence_milli: 850,
backend: TriageBackend::Hybrid,
analyzer_version: "rules-1".to_owned(),
model_ref: Some("model-1".to_owned()),
degraded_reason: None,
}
}
#[test]
fn serialization_round_trip() {
let result = valid_result();
let json = serde_json::to_string(&result).expect("triage result should serialize");
let decoded: TriageResultV1 =
serde_json::from_str(&json).expect("triage result should deserialize");
assert_eq!(result, decoded);
result.validate().expect("triage result should be valid");
}
#[test]
fn profile_capability_metadata_is_optional_and_backward_compatible() {
let profile = valid_profile();
let json = serde_json::to_value(&profile).expect("profile should serialize");
assert_eq!(json["capability_id"], "capability://leanctx/triage");
assert_eq!(
serde_json::from_value::<TaskProfileV1>(json.clone())
.expect("profile with capability metadata should deserialize"),
profile
);
let mut legacy = json;
let object = legacy
.as_object_mut()
.expect("serialized profile should be an object");
object.remove("capability_id");
object.remove("capability_version");
let decoded: TaskProfileV1 =
serde_json::from_value(legacy).expect("legacy profile should deserialize");
assert_eq!(decoded.capability_id, None);
assert_eq!(decoded.capability_version, None);
let without_capability = serde_json::to_value(decoded)
.expect("profile without capability metadata should serialize");
assert!(without_capability.get("capability_id").is_none());
assert!(without_capability.get("capability_version").is_none());
}
#[test]
fn validation_rejects_invalid_schema_and_milliunits() {
let mut result = valid_result();
result.schema_version = 2;
assert!(result.validate().is_err());
result.schema_version = 1;
result.confidence_milli = 1001;
assert!(result.validate().is_err());
let mut profile = valid_profile();
profile.context_need_milli = 1001;
assert!(profile.validate().is_err());
}
#[test]
fn defaults_are_stable_and_valid() {
let profile = TaskProfileV1::default();
assert_eq!(profile, TaskProfileV1::default());
profile.validate().expect("default profile should be valid");
let result = TriageResultV1::default();
assert_eq!(result, TriageResultV1::default());
result.validate().expect("default result should be valid");
assert_eq!(TaskScope::default(), TaskScope::SingleFile);
assert_eq!(TriageBackend::default(), TriageBackend::Rules);
}
}