Skip to main content

kranz_engine/
plan_fit.rs

1//! Plan-time feature context-fit check (plan-feature-context-fit-check
2//! ticket): warn at approval when a feature looks bigger than one worker
3//! session's context budget. kranz already enforces fresh-context-per-feature
4//! at run time — this moves the sizing signal to plan time, where splitting
5//! is cheap. Advisory only: a decision event and a plan.md note, never a
6//! gate (v1 is a heuristic and must not hard-block plans).
7
8use crate::paths::MissionPaths;
9use crate::types::Plan;
10use serde::Serialize;
11
12/// Default anchor when a repo has no plan history yet: the p90 fallbacks.
13/// Documented as defaults, not fitted numbers — a repo's own corpus replaces
14/// them as soon as one historical plan exists.
15pub const DEFAULT_SPEC_CHARS_P90: usize = 1_200;
16pub const DEFAULT_FILE_MENTIONS_P90: usize = 5;
17
18/// The corpus anchor: historical p90s of per-feature spec length and
19/// mentioned-file counts, computed from the repo's own plan.json files.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct FitAnchor {
22    pub spec_chars_p90: usize,
23    pub file_mentions_p90: usize,
24    pub plans_used: usize,
25}
26
27/// One feature that looks bigger than one worker session.
28#[derive(Debug, Clone, PartialEq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct FeatureFitWarning {
31    pub feature_id: String,
32    pub title: String,
33    pub spec_chars: usize,
34    pub file_mentions: usize,
35    pub reasons: Vec<String>,
36}
37
38/// Count file mentions in a spec: whitespace-separated tokens that look like
39/// a repo path (contain `/` or a glob `*` or end in a common source
40/// extension). Naive by design — it counts "how many files does this spec
41/// point at", not prose length.
42fn count_file_mentions(spec: &str) -> usize {
43    const EXTENSIONS: &[&str] = &[
44        ".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".md", ".json", ".toml", ".yml",
45        ".yaml", ".sh", ".mjs",
46    ];
47    spec.split_whitespace()
48        .filter(|token| {
49            let token = token.trim_matches(|c: char| {
50                matches!(
51                    c,
52                    '`' | '"' | '\'' | '(' | ')' | '[' | ']' | ',' | ';' | ':'
53                )
54            });
55            !token.is_empty()
56                && (token.contains('/')
57                    || token.contains('*')
58                    || EXTENSIONS.iter().any(|ext| token.ends_with(ext)))
59        })
60        .count()
61}
62
63/// p90 of a small usize series (nearest-rank, matching cost.rs's percentile).
64fn p90(values: &mut [usize]) -> usize {
65    if values.is_empty() {
66        return 0;
67    }
68    values.sort_unstable();
69    let idx = ((values.len() as f64) * 0.9).ceil() as usize;
70    values[idx.saturating_sub(1).min(values.len() - 1)]
71}
72
73/// Compute the anchor from the repo's historical plan.json files. Falls back
74/// to the documented defaults when no plans exist (a fresh repo gets the
75/// same check, just unfitted).
76pub fn corpus_fit_anchor(repo_root: &std::path::Path) -> FitAnchor {
77    let mut spec_chars = Vec::new();
78    let mut file_mentions = Vec::new();
79    let mut plans_used = 0usize;
80    for mission_id in MissionPaths::list_missions(repo_root) {
81        let plan_path = MissionPaths::new(repo_root, &mission_id).plan_file();
82        let Ok(text) = std::fs::read_to_string(&plan_path) else {
83            continue;
84        };
85        let Ok(plan) = serde_json::from_str::<Plan>(&text) else {
86            continue;
87        };
88        plans_used += 1;
89        for milestone in &plan.milestones {
90            for feature in &milestone.features {
91                spec_chars.push(feature.spec.chars().count());
92                file_mentions.push(count_file_mentions(&feature.spec));
93            }
94        }
95    }
96    if plans_used == 0 {
97        return FitAnchor {
98            spec_chars_p90: DEFAULT_SPEC_CHARS_P90,
99            file_mentions_p90: DEFAULT_FILE_MENTIONS_P90,
100            plans_used: 0,
101        };
102    }
103    FitAnchor {
104        spec_chars_p90: p90(&mut spec_chars).max(200),
105        file_mentions_p90: p90(&mut file_mentions).max(2),
106        plans_used,
107    }
108}
109
110/// Features whose footprint exceeds the anchor, with the reasons attached.
111/// Pure and deterministic — the caller decides how to surface them.
112pub fn feature_fit_warnings(plan: &Plan, anchor: &FitAnchor) -> Vec<FeatureFitWarning> {
113    let mut warnings = Vec::new();
114    for (mi, milestone) in plan.milestones.iter().enumerate() {
115        for (fi, feature) in milestone.features.iter().enumerate() {
116            let chars = feature.spec.chars().count();
117            let mentions = count_file_mentions(&feature.spec);
118            let mut reasons = Vec::new();
119            if chars > anchor.spec_chars_p90 {
120                reasons.push(format!(
121                    "spec is {chars} chars (corpus p90: {})",
122                    anchor.spec_chars_p90
123                ));
124            }
125            if mentions > anchor.file_mentions_p90 {
126                reasons.push(format!(
127                    "spec points at {mentions} files (corpus p90: {})",
128                    anchor.file_mentions_p90
129                ));
130            }
131            if !reasons.is_empty() {
132                warnings.push(FeatureFitWarning {
133                    feature_id: format!("f-{}-{}", mi + 1, fi + 1),
134                    title: feature.title.clone(),
135                    spec_chars: chars,
136                    file_mentions: mentions,
137                    reasons,
138                });
139            }
140        }
141    }
142    warnings
143}
144
145/// One-line-per-feature advisory text for the plan.md note and the decision
146/// detail.
147pub fn render_fit_note(warnings: &[FeatureFitWarning], anchor: &FitAnchor) -> String {
148    let provenance = if anchor.plans_used == 0 {
149        format!(
150            "defaults (no plan history; p90 ≈ {} chars / {} files)",
151            anchor.spec_chars_p90, anchor.file_mentions_p90
152        )
153    } else {
154        format!(
155            "corpus p90 over {} plan(s): {} chars / {} files",
156            anchor.plans_used, anchor.spec_chars_p90, anchor.file_mentions_p90
157        )
158    };
159    let mut out = format!("Context-fit check ({provenance}):");
160    for warning in warnings {
161        out.push_str(&format!(
162            "\n- **{}** ({}) — {}",
163            warning.feature_id,
164            warning.title,
165            warning.reasons.join("; ")
166        ));
167    }
168    out
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::types::{PlanFeature, PlanMilestone};
175
176    fn plan_with(features: Vec<PlanFeature>) -> Plan {
177        Plan {
178            goal: "g".into(),
179            validation_contract: vec![],
180            milestones: vec![PlanMilestone {
181                title: "m".into(),
182                features,
183            }],
184            considered_alternatives: None,
185            command_grants: vec![],
186            touch_set: vec![],
187            standards_manifest: None,
188            reviewer_independence: None,
189        }
190    }
191
192    fn feature(title: &str, spec: &str) -> PlanFeature {
193        PlanFeature {
194            title: title.into(),
195            spec: spec.into(),
196            validation_criteria: vec![],
197        }
198    }
199
200    fn anchor() -> FitAnchor {
201        FitAnchor {
202            spec_chars_p90: 100,
203            file_mentions_p90: 3,
204            plans_used: 4,
205        }
206    }
207
208    #[test]
209    fn right_sized_features_stay_quiet() {
210        let plan = plan_with(vec![feature("small", "add a flag to config.rs")]);
211        assert!(feature_fit_warnings(&plan, &anchor()).is_empty());
212    }
213
214    #[test]
215    fn oversized_features_warn_with_reasons() {
216        let big_spec = format!(
217            "{} touch crates/engine/src/orchestrator.rs and crates/cli/src/commands.rs and docs/a.md docs/b.md",
218            "word ".repeat(40)
219        );
220        let plan = plan_with(vec![
221            feature("small", "add a flag"),
222            feature("huge", &big_spec),
223        ]);
224        let warnings = feature_fit_warnings(&plan, &anchor());
225        assert_eq!(warnings.len(), 1);
226        assert_eq!(warnings[0].feature_id, "f-1-2");
227        assert_eq!(warnings[0].title, "huge");
228        assert!(warnings[0]
229            .reasons
230            .iter()
231            .any(|r| r.contains("chars (corpus p90: 100)")));
232        assert!(warnings[0]
233            .reasons
234            .iter()
235            .any(|r| r.contains("files (corpus p90: 3)")));
236    }
237
238    #[test]
239    fn file_mentions_counts_paths_globs_and_source_extensions() {
240        assert_eq!(count_file_mentions("edit `src/a.rs` and docs/b.md"), 2);
241        assert_eq!(count_file_mentions("crates/engine/**/*.rs"), 1);
242        assert_eq!(count_file_mentions("no files here at all"), 0);
243        assert_eq!(count_file_mentions("src/lib.rs:42 has the fn"), 1);
244    }
245
246    #[test]
247    fn render_fit_note_lists_features_and_provenance() {
248        let warning = FeatureFitWarning {
249            feature_id: "f-1-1".into(),
250            title: "big".into(),
251            spec_chars: 500,
252            file_mentions: 8,
253            reasons: vec!["spec is 500 chars (corpus p90: 100)".into()],
254        };
255        let note = render_fit_note(&[warning], &anchor());
256        assert!(note.contains("corpus p90 over 4 plan(s)"));
257        assert!(note.contains("f-1-1"));
258        assert!(note.contains("big"));
259    }
260}