1use crate::ready::{ReadyDimension, ReadyStatus};
11use kranz_engine::contract_health::ContractHealth;
12use serde::Serialize;
13
14pub const MAPPING_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
19pub enum AmmLevel {
20 L1,
21 L2,
22 L3,
23 L4,
24 L5,
25}
26
27#[derive(Debug, Clone, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct AmmProjection {
30 pub level: AmmLevel,
31 pub missing_signals: Vec<String>,
33 pub mapping_version: u32,
34}
35
36const LADDER: &[(AmmLevel, &str, ReadyStatus)] = &[
44 (AmmLevel::L2, "planner instructions", ReadyStatus::Pass),
46 (AmmLevel::L2, "test runner", ReadyStatus::Warn),
47 (AmmLevel::L2, "repo docs", ReadyStatus::Warn),
48 (AmmLevel::L3, "CI config", ReadyStatus::Warn),
50 (AmmLevel::L3, "merge gates", ReadyStatus::Pass),
51 (AmmLevel::L3, "contract prerequisites", ReadyStatus::Pass),
52 (AmmLevel::L3, "clean git state", ReadyStatus::Warn),
53 (AmmLevel::L3, "kranz runtime gitignore", ReadyStatus::Warn),
54 (AmmLevel::L4, "agent backend lanes", ReadyStatus::Warn),
57 (AmmLevel::L4, "calibration corpus", ReadyStatus::Warn),
58];
59
60const L5_MIN_LINT_PASS_RATE: f64 = 0.8;
65const L5_MAX_WAIVERS_PER_MISSION: f64 = 0.5;
66
67fn unmet_for(
68 target: AmmLevel,
69 dimensions: &[ReadyDimension],
70 health: Option<&ContractHealth>,
71) -> Vec<String> {
72 let mut missing = Vec::new();
73 for (level, name, min) in LADDER {
74 if *level != target {
75 continue;
76 }
77 let met = dimensions
78 .iter()
79 .find(|d| d.name == *name)
80 .map(|d| d.status <= *min)
81 .unwrap_or(false);
82 if !met {
83 missing.push((*name).to_string());
84 }
85 }
86 match target {
87 AmmLevel::L4 => {
88 if health.is_none() {
89 missing.push("mission history (contract-health axis)".to_string());
90 }
91 }
92 AmmLevel::L5 => {
93 if let Some(h) = health {
94 if h.lint_pass_rate.unwrap_or(0.0) < L5_MIN_LINT_PASS_RATE {
95 missing.push(format!("contract-lint pass rate ≥ {L5_MIN_LINT_PASS_RATE}"));
96 }
97 if h.waivers_per_mission.unwrap_or(f64::MAX) > L5_MAX_WAIVERS_PER_MISSION {
98 missing.push(format!(
99 "finding waivers per mission ≤ {L5_MAX_WAIVERS_PER_MISSION}"
100 ));
101 }
102 if h.blocked.contract_bug > 0 {
103 missing.push("zero contract-bug blocked events".to_string());
104 }
105 }
106 }
107 _ => {}
108 }
109 missing
110}
111
112pub fn project(dimensions: &[ReadyDimension], health: Option<&ContractHealth>) -> AmmProjection {
116 let mut level = AmmLevel::L1;
117 let mut missing = unmet_for(AmmLevel::L2, dimensions, health);
118 for target in [AmmLevel::L2, AmmLevel::L3, AmmLevel::L4, AmmLevel::L5] {
119 let unmet = unmet_for(target, dimensions, health);
120 if unmet.is_empty() {
121 level = target;
122 missing = Vec::new();
123 } else {
124 missing = unmet;
125 break;
126 }
127 }
128 AmmProjection {
129 level,
130 missing_signals: missing,
131 mapping_version: MAPPING_VERSION,
132 }
133}
134
135pub fn level_label(level: AmmLevel) -> &'static str {
136 match level {
137 AmmLevel::L1 => "L1",
138 AmmLevel::L2 => "L2",
139 AmmLevel::L3 => "L3",
140 AmmLevel::L4 => "L4",
141 AmmLevel::L5 => "L5",
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::ready::ReadyStatus::*;
149
150 fn dim(name: &'static str, status: ReadyStatus) -> ReadyDimension {
151 ReadyDimension {
152 name,
153 score: 0,
154 weight: 0,
155 status,
156 evidence: String::new(),
157 remedy: String::new(),
158 }
159 }
160
161 fn dims(failing: &[&'static str]) -> Vec<ReadyDimension> {
163 [
164 "planner instructions",
165 "test runner",
166 "repo docs",
167 "CI config",
168 "merge gates",
169 "contract prerequisites",
170 "clean git state",
171 "kranz runtime gitignore",
172 "agent backend lanes",
173 "calibration corpus",
174 ]
175 .into_iter()
176 .map(|name| {
177 let status = if failing.contains(&name) { Fail } else { Pass };
178 dim(name, status)
179 })
180 .collect()
181 }
182
183 fn strong_health() -> ContractHealth {
184 ContractHealth {
185 missions: 10,
186 lint_linted: 10,
187 lint_clean: 9,
188 lint_pass_rate: Some(0.9),
189 finding_waivers: 2,
190 missions_with_waivers: 2,
191 waivers_per_mission: Some(0.2),
192 blocked: Default::default(),
193 }
194 }
195
196 #[test]
197 fn full_repo_with_strong_health_is_l5() {
198 let p = project(&dims(&[]), Some(&strong_health()));
199 assert_eq!(p.level, AmmLevel::L5);
200 assert!(p.missing_signals.is_empty());
201 assert_eq!(p.mapping_version, MAPPING_VERSION);
202 }
203
204 #[test]
205 fn l3_boundary_stops_at_missing_l4_dimensions() {
206 let p = project(&dims(&["agent backend lanes", "calibration corpus"]), None);
207 assert_eq!(p.level, AmmLevel::L3);
208 assert!(p
209 .missing_signals
210 .contains(&"agent backend lanes".to_string()));
211 assert!(p
212 .missing_signals
213 .contains(&"calibration corpus".to_string()));
214 }
215
216 #[test]
217 fn bare_repo_is_l1_with_l2_gaps_named() {
218 let p = project(&[], None);
219 assert_eq!(p.level, AmmLevel::L1);
220 assert!(p
221 .missing_signals
222 .contains(&"planner instructions".to_string()));
223 }
224
225 #[test]
226 fn l4_requires_mission_history() {
227 let p = project(&dims(&[]), None);
230 assert_eq!(p.level, AmmLevel::L3);
231 assert!(p
232 .missing_signals
233 .contains(&"mission history (contract-health axis)".to_string()));
234 }
235
236 #[test]
237 fn weak_contract_health_caps_at_l4() {
238 let mut health = strong_health();
239 health.lint_pass_rate = Some(0.5);
240 health.blocked.contract_bug = 1;
241 let p = project(&dims(&[]), Some(&health));
242 assert_eq!(p.level, AmmLevel::L4);
243 assert!(p
244 .missing_signals
245 .iter()
246 .any(|m| m.contains("contract-lint pass rate")));
247 assert!(p
248 .missing_signals
249 .contains(&"zero contract-bug blocked events".to_string()));
250 }
251}