lsp_max_protocol/repair.rs
1//! Read-only `max/repairPlanSynth` and `max/whatIf` protocol surface.
2//!
3//! A diagnostic tells an agent *that* a law axis is unsatisfied. This module
4//! projects each known diagnostic family onto a structured, bounded **repair
5//! plan**: an ordered list of [`RepairStep`]s an agent or CI runner can act on.
6//! It also exposes a [`simulate_admission`] *what-if* gate that answers, without
7//! touching any file, what admission verdict a hypothetical world-state would
8//! produce.
9//!
10//! This surface is **read-only**. It emits plans and intents; it never mutates
11//! files. A [`RepairPlanResult`] is guidance, not an applied change — any actual
12//! mutation must route through the `CodeAction -> clap-noun-verb -> Receipt`
13//! chain, never through this observation surface. A `verb` named on a step is a
14//! *suggested* `clap-noun-verb` actuation, not an executed one.
15//!
16//! All status fields carry **bounded statuses only** (`ADMITTED`, `PARTIAL`,
17//! `UNKNOWN`, `REFUSED`, `BLOCKED`). The three-state law holds throughout:
18//! `UNKNOWN` is never coerced into `ADMITTED` or `REFUSED`. An unrecognized
19//! diagnostic id yields an `UNKNOWN` plan with **no** fabricated steps, and a
20//! what-if with any unknown axis yields `UNKNOWN` regardless of how many axes
21//! are admitted.
22
23use serde::{Deserialize, Serialize};
24
25// ---------------------------------------------------------------------------
26// Method name constants (read-only surface — the LSP never mutates files)
27// ---------------------------------------------------------------------------
28
29/// max/repairPlanSynth — Synthesize a bounded, ordered repair plan for a diagnostic
30/// id. Read-only: returns a [`RepairPlanResult`]; never applies the repair.
31pub const METHOD_REPAIR_PLAN: &str = "max/repairPlanSynth";
32
33/// max/whatIf — Simulate the admission verdict for a hypothetical world-state.
34/// Read-only: returns a [`WhatIfResult`]; observes nothing on disk and changes
35/// no state.
36pub const METHOD_WHAT_IF: &str = "max/whatIf";
37
38// ---------------------------------------------------------------------------
39// Bounded status constants
40// ---------------------------------------------------------------------------
41
42/// Bounded status: the plan/axis cleared the admission bar.
43pub const STATUS_ADMITTED: &str = "ADMITTED";
44/// Bounded status: a partial path exists but does not clear the bar on its own.
45pub const STATUS_PARTIAL: &str = "PARTIAL";
46/// Bounded status: admissibility cannot be determined from what is known.
47pub const STATUS_UNKNOWN: &str = "UNKNOWN";
48/// Bounded status: an explicit refusal by law.
49pub const STATUS_REFUSED: &str = "REFUSED";
50/// Bounded status: an active ANDON signal blocks progress until it clears.
51pub const STATUS_BLOCKED: &str = "BLOCKED";
52
53// ---------------------------------------------------------------------------
54// max/repairPlanSynth params / result
55// ---------------------------------------------------------------------------
56
57/// One ordered step in a repair plan. A step describes an intent, never an
58/// applied mutation.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct RepairStep {
61 /// 1-based position of this step within the plan.
62 pub order: usize,
63 /// The action an agent or CI runner should take.
64 pub action: String,
65 /// Why this step is part of the bounded path toward admission.
66 pub rationale: String,
67 /// Suggested `clap-noun-verb` actuation, when one applies. This is a
68 /// suggestion only; the LSP surface never invokes it.
69 pub verb: Option<String>,
70}
71
72/// Parameters for `max/repairPlanSynth`.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct RepairPlanParams {
75 /// The diagnostic id (or law id) to synthesize a plan for.
76 pub diagnostic_id: String,
77}
78
79/// Result of `max/repairPlanSynth`.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct RepairPlanResult {
82 /// Echo of the requested diagnostic id.
83 pub diagnostic_id: String,
84 /// Bounded status string (`ADMITTED`, `PARTIAL`, `UNKNOWN`, `REFUSED`,
85 /// `BLOCKED`).
86 pub status: String,
87 /// Ordered, bounded repair steps. Empty when the id is unrecognized; the
88 /// surface never fabricates steps for an `UNKNOWN` plan.
89 pub steps: Vec<RepairStep>,
90 /// Repairability of the diagnostic, mirroring
91 /// [`crate::diagnostics::Repairability`] as a bounded string.
92 pub repairability: String,
93 /// Human-readable summary of the plan's intent.
94 pub summary: String,
95}
96
97impl RepairPlanResult {
98 /// The `UNKNOWN` plan for an unrecognized diagnostic id: empty steps, no
99 /// fabrication, and a repairability of `Unknown`. This is the negative
100 /// control — an absent catalog entry must never present as `ADMITTED` or
101 /// `REFUSED`.
102 fn unknown(diagnostic_id: &str) -> Self {
103 Self {
104 diagnostic_id: diagnostic_id.to_string(),
105 status: STATUS_UNKNOWN.to_string(),
106 steps: Vec::new(),
107 repairability: "Unknown".to_string(),
108 summary: format!(
109 "no repair catalog entry for `{diagnostic_id}`; status UNKNOWN — \
110 admissibility cannot be determined and is not coerced to REFUSED or ADMITTED"
111 ),
112 }
113 }
114}
115
116/// Build an ordered list of [`RepairStep`]s from `(action, rationale, verb)`
117/// triples, assigning 1-based `order` positions.
118fn steps_from(raw: &[(&str, &str, Option<&str>)]) -> Vec<RepairStep> {
119 raw.iter()
120 .enumerate()
121 .map(|(i, (action, rationale, verb))| RepairStep {
122 order: i + 1,
123 action: (*action).to_string(),
124 rationale: (*rationale).to_string(),
125 verb: verb.map(str::to_string),
126 })
127 .collect()
128}
129
130/// Synthesize a bounded repair plan for a diagnostic id.
131///
132/// Read-only: this consults a small static catalog and reports a plan; it does
133/// not read or change any file.
134///
135/// Status handling, three-state law preserved:
136/// - An ANDON-gated `WASM4PM-*` code -> `BLOCKED`, with steps that route the
137/// agent to resolve the gate (`gate check`). The ANDON must clear before work
138/// proceeds; the plan reflects that floor rather than claiming admission.
139/// - `TPOT2-NONCONVERGENCE` -> `PARTIAL`: a result exists but is below the bar;
140/// the steps widen the search (more generations / larger population).
141/// - `TPOT2-EMPTY-POOL` -> `REFUSED`: with no breeds there is nothing to admit;
142/// the steps direct the agent to repopulate / check the catalog.
143/// - Any unrecognized id -> `UNKNOWN` with an empty plan (negative control).
144pub fn repair_plan_for(diagnostic_id: &str) -> RepairPlanResult {
145 match diagnostic_id {
146 // ANDON-gated process-mining family. The gate is a floor: while it is
147 // set, no shell-side action proceeds, so the bounded status is BLOCKED.
148 "WASM4PM-ANDON" | "WASM4PM-GATE" | "WASM4PM-GATE-BLOCKED" => RepairPlanResult {
149 diagnostic_id: diagnostic_id.to_string(),
150 status: STATUS_BLOCKED.to_string(),
151 steps: steps_from(&[
152 (
153 "Run the gate check to read the current ANDON state.",
154 "The ANDON signal is a one-byte gate; the check reports whether it is set \
155 before any further shell-side action is attempted.",
156 Some("gate check"),
157 ),
158 (
159 "Resolve every active WASM4PM-* and GGEN-* Error-severity diagnostic.",
160 "Lambda_CD blocks while any governed Error is present in the diagnostic \
161 context; the gate clears only when that set drains.",
162 None,
163 ),
164 (
165 "Re-run the gate check and confirm it reports clear before proceeding.",
166 "Re-reading the gate confirms the BLOCKED floor has lifted; status remains \
167 BLOCKED until the gate is observed clear.",
168 Some("gate check"),
169 ),
170 ]),
171 repairability: "Repairable".to_string(),
172 summary: "ANDON gate is active; status BLOCKED — resolve governed diagnostics and \
173 confirm the gate clears before any build, test, or release action"
174 .to_string(),
175 },
176
177 // Search produced a below-threshold result: a partial path exists.
178 "TPOT2-NONCONVERGENCE" => RepairPlanResult {
179 diagnostic_id: diagnostic_id.to_string(),
180 status: STATUS_PARTIAL.to_string(),
181 steps: steps_from(&[
182 (
183 "Increase the number of generations the breed search evolves.",
184 "More generations give the optimizer additional rounds to climb toward the \
185 admission threshold the previous run fell short of.",
186 None,
187 ),
188 (
189 "Increase the population size per generation.",
190 "A larger population widens the explored breed space, reducing the chance \
191 the search stalls in a below-threshold local optimum.",
192 None,
193 ),
194 (
195 "Re-run the search and compare best observed fitness against the threshold.",
196 "The status stays PARTIAL until a re-run clears the bar; the comparison is \
197 what distinguishes PARTIAL from ADMITTED.",
198 None,
199 ),
200 ]),
201 repairability: "Repairable".to_string(),
202 summary: "breed search ended below the admission threshold; status PARTIAL — widen \
203 the search (more generations / larger population) and re-run"
204 .to_string(),
205 },
206
207 // Empty breed pool: an explicit refusal — nothing exists to admit.
208 "TPOT2-EMPTY-POOL" => RepairPlanResult {
209 diagnostic_id: diagnostic_id.to_string(),
210 status: STATUS_REFUSED.to_string(),
211 steps: steps_from(&[
212 (
213 "Inspect the breed catalog to confirm whether any breeds are registered.",
214 "An empty pool is a REFUSED outcome; the first step is to verify the catalog \
215 is the cause rather than a filter excluding every entry.",
216 None,
217 ),
218 (
219 "Repopulate the breed pool with at least one candidate before searching.",
220 "With no breeds there is nothing to evaluate; admission is impossible until \
221 the pool is non-empty.",
222 None,
223 ),
224 ]),
225 repairability: "Repairable".to_string(),
226 summary: "breed pool is empty; status REFUSED — check the catalog and repopulate the \
227 pool before a search can yield any admissible breed"
228 .to_string(),
229 },
230
231 // Negative control: anything outside the catalog stays UNKNOWN.
232 _ => RepairPlanResult::unknown(diagnostic_id),
233 }
234}
235
236// ---------------------------------------------------------------------------
237// max/whatIf params / result
238// ---------------------------------------------------------------------------
239
240/// Parameters for `max/whatIf`: a hypothetical world-state to evaluate.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct WhatIfParams {
243 /// Whether an ANDON signal is active in the hypothetical state.
244 pub andon_active: bool,
245 /// Count of law axes whose admissibility is unknown.
246 pub unknown_axes: usize,
247 /// Count of law axes explicitly refused.
248 pub refused_axes: usize,
249}
250
251/// Result of `max/whatIf`.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct WhatIfResult {
254 /// Bounded admission verdict (`BLOCKED`, `REFUSED`, `UNKNOWN`, `ADMITTED`).
255 pub admission: String,
256 /// Why the verdict was reached, naming the governing precedence.
257 pub rationale: String,
258}
259
260/// Simulate the admission verdict for a hypothetical world-state.
261///
262/// Read-only: this is a pure function over the supplied counts; it observes no
263/// file and changes no state.
264///
265/// Strict precedence, three-state law preserved (`UNKNOWN` never collapses):
266/// 1. `andon_active` -> `BLOCKED`. An active ANDON is a hard floor.
267/// 2. `refused_axes > 0` -> `REFUSED`. An explicit refusal dominates remaining
268/// polarities.
269/// 3. `unknown_axes > 0` -> `UNKNOWN`. An undetermined axis is reported as
270/// `UNKNOWN`; it is never coerced to `ADMITTED` (or to `REFUSED`) merely
271/// because other axes are admitted.
272/// 4. Otherwise -> `ADMITTED`.
273pub fn simulate_admission(p: &WhatIfParams) -> WhatIfResult {
274 if p.andon_active {
275 return WhatIfResult {
276 admission: STATUS_BLOCKED.to_string(),
277 rationale: "an ANDON signal is active; status BLOCKED — it is the highest-precedence \
278 floor and no admission verdict is reached while it is set"
279 .to_string(),
280 };
281 }
282 if p.refused_axes > 0 {
283 return WhatIfResult {
284 admission: STATUS_REFUSED.to_string(),
285 rationale: format!(
286 "{} law axis/axes are explicitly refused; status REFUSED — refusal dominates \
287 any unknown or admitted axes",
288 p.refused_axes
289 ),
290 };
291 }
292 if p.unknown_axes > 0 {
293 return WhatIfResult {
294 admission: STATUS_UNKNOWN.to_string(),
295 rationale: format!(
296 "{} law axis/axes are undetermined; status UNKNOWN — this is never coerced to \
297 ADMITTED or REFUSED, even when every other axis is admitted",
298 p.unknown_axes
299 ),
300 };
301 }
302 WhatIfResult {
303 admission: STATUS_ADMITTED.to_string(),
304 rationale: "no ANDON signal, no refused axes, and no unknown axes; status ADMITTED"
305 .to_string(),
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 /// A known code yields an ordered, non-empty plan with a bounded status.
314 #[test]
315 fn known_code_yields_ordered_bounded_plan() {
316 let plan = repair_plan_for("TPOT2-NONCONVERGENCE");
317 assert!(!plan.steps.is_empty(), "a known code must produce steps");
318 assert_eq!(plan.status, STATUS_PARTIAL);
319 // Steps carry 1-based, contiguous, ascending order indices.
320 for (i, step) in plan.steps.iter().enumerate() {
321 assert_eq!(step.order, i + 1, "step order must be 1-based contiguous");
322 }
323 // Status is drawn from the bounded set, never an ad-hoc string.
324 assert!([
325 STATUS_ADMITTED,
326 STATUS_PARTIAL,
327 STATUS_UNKNOWN,
328 STATUS_REFUSED,
329 STATUS_BLOCKED,
330 ]
331 .contains(&plan.status.as_str()));
332 }
333
334 /// The ANDON-gated family is BLOCKED and suggests the gate-check verb.
335 #[test]
336 fn andon_code_is_blocked_and_routes_to_gate() {
337 let plan = repair_plan_for("WASM4PM-ANDON");
338 assert_eq!(plan.status, STATUS_BLOCKED);
339 assert!(!plan.steps.is_empty());
340 assert!(
341 plan.steps
342 .iter()
343 .any(|s| s.verb.as_deref() == Some("gate check")),
344 "an ANDON plan must route the agent to `gate check`"
345 );
346 }
347
348 /// The empty-pool family is an explicit REFUSED with a non-empty plan.
349 #[test]
350 fn empty_pool_code_is_refused() {
351 let plan = repair_plan_for("TPOT2-EMPTY-POOL");
352 assert_eq!(plan.status, STATUS_REFUSED);
353 assert!(!plan.steps.is_empty());
354 }
355
356 /// NEGATIVE CONTROL: an unrecognized code yields UNKNOWN with no fabricated
357 /// steps, and never collapses to REFUSED or ADMITTED.
358 #[test]
359 fn unknown_code_yields_unknown_and_empty_steps() {
360 let plan = repair_plan_for("NOT-A-REAL-DIAGNOSTIC-CODE");
361 assert_eq!(plan.status, STATUS_UNKNOWN);
362 assert!(
363 plan.steps.is_empty(),
364 "an unrecognized code must not fabricate any repair step"
365 );
366 assert_ne!(plan.status, STATUS_REFUSED);
367 assert_ne!(plan.status, STATUS_ADMITTED);
368 assert_eq!(plan.repairability, "Unknown");
369 }
370
371 /// What-if with an unknown axis stays UNKNOWN — not ADMITTED, not REFUSED —
372 /// even when no axis is refused.
373 #[test]
374 fn what_if_unknown_axis_stays_unknown() {
375 let r = simulate_admission(&WhatIfParams {
376 andon_active: false,
377 unknown_axes: 1,
378 refused_axes: 0,
379 });
380 assert_eq!(r.admission, STATUS_UNKNOWN);
381 assert_ne!(r.admission, STATUS_ADMITTED);
382 assert_ne!(r.admission, STATUS_REFUSED);
383 }
384
385 /// What-if precedence: BLOCKED > REFUSED > UNKNOWN > ADMITTED.
386 #[test]
387 fn what_if_precedence_holds() {
388 // ANDON dominates everything else, including refused and unknown axes.
389 let blocked = simulate_admission(&WhatIfParams {
390 andon_active: true,
391 unknown_axes: 3,
392 refused_axes: 3,
393 });
394 assert_eq!(blocked.admission, STATUS_BLOCKED);
395
396 // With no ANDON, a refusal dominates unknown axes.
397 let refused = simulate_admission(&WhatIfParams {
398 andon_active: false,
399 unknown_axes: 3,
400 refused_axes: 1,
401 });
402 assert_eq!(refused.admission, STATUS_REFUSED);
403
404 // With no ANDON and no refusal, an unknown axis dominates admission.
405 let unknown = simulate_admission(&WhatIfParams {
406 andon_active: false,
407 unknown_axes: 1,
408 refused_axes: 0,
409 });
410 assert_eq!(unknown.admission, STATUS_UNKNOWN);
411
412 // Only a fully clean state admits.
413 let admitted = simulate_admission(&WhatIfParams {
414 andon_active: false,
415 unknown_axes: 0,
416 refused_axes: 0,
417 });
418 assert_eq!(admitted.admission, STATUS_ADMITTED);
419 }
420}