Skip to main content

kranz_cli/
amm.rs

1//! AMM-compatible readiness projection over kranz-native signals.
2//!
3//! The factory-autonomy-maturity-model whitepaper's L1–L5, **mapped — never
4//! adopted**: kranz-native signals (ready.rs dimensions + contract health)
5//! are the source of truth, and the AMM level is a derived VIEW for people
6//! who think in the paper's vocabulary. The paper is silent on the
7//! contract/consent axis that is kranz's differentiator, so L4+ require
8//! mission history the paper cannot measure.
9
10use crate::ready::{ReadyDimension, ReadyStatus};
11use kranz_engine::contract_health::ContractHealth;
12use serde::Serialize;
13
14/// Bump on ANY change to the ladder below; `--json` consumers pin on it.
15pub const MAPPING_VERSION: u32 = 1;
16
17/// AMM maturity level. Serializes as "L1".."L5".
18#[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    /// Signals missing for the NEXT level up (empty at L5).
32    pub missing_signals: Vec<String>,
33    pub mapping_version: u32,
34}
35
36/// THE ladder — the ONLY place AMM levels meet kranz-native dimensions.
37///
38/// Levels are ORDINAL LABELS, not sacred integers: Factory's
39/// 5/19/36/60/100 point thresholds are marketing numbers that will drift
40/// whenever the paper revises. What defines a level here is which native
41/// signals it presupposes: (ready.rs dimension name, minimum status).
42/// Levels are cumulative — L3 presupposes everything L2 does.
43const LADDER: &[(AmmLevel, &str, ReadyStatus)] = &[
44    // L2 — assisted: an agent can find instructions and a test command.
45    (AmmLevel::L2, "planner instructions", ReadyStatus::Pass),
46    (AmmLevel::L2, "test runner", ReadyStatus::Warn),
47    (AmmLevel::L2, "repo docs", ReadyStatus::Warn),
48    // L3 — guarded autonomy: contracts run against a gated, clean tree.
49    (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    // L4 — orchestrated: multiple backend lanes and a calibration corpus,
55    // plus mission history (the contract-health axis exists to be read).
56    (AmmLevel::L4, "agent backend lanes", ReadyStatus::Warn),
57    (AmmLevel::L4, "calibration corpus", ReadyStatus::Warn),
58];
59
60/// Contract-health thresholds for L5 (kranz-native numbers — ours to tune;
61/// NOT Factory's). A flywheel repo's contract machinery is quiet: lint
62/// catches author bugs before missions do, waivers are rare, and nothing
63/// blocks on a contract bug.
64const 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
112/// Project the AMM level from native dimensions + optional contract health.
113/// The level is the highest whose requirements are ALL met; missing_signals
114/// are what blocks the next level.
115pub 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    /// A report shaped to satisfy exactly the levels below `top_unmet`'s.
162    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        // Every dimension green but no event logs: stops at L3, and the
228        // missing signal names the contract-health axis rather than a pillar.
229        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}