Skip to main content

lsp_max_protocol/
phase.rs

1//! Read-only `max/phaseShift` protocol surface.
2//!
3//! Water expands roughly 1,700x when it crosses the boiling point into steam.
4//! This surface projects that picture onto the law-state runtime: the **boiling
5//! point is the admission threshold** and the **1,700x expansion is the
6//! autonomic-mesh fan-out** an admitted observation receives. `max/phaseShift`
7//! resolves a hypothetical world-state into a bounded conformance *phase* and the
8//! mesh amplification that phase carries.
9//!
10//! This surface is **read-only**. It reports a phase and an expansion factor; it
11//! mutates nothing and propagates nothing on its own. The expansion factor is the
12//! *intended* mesh fan-out for an admitted observation, not an executed action —
13//! any actual propagation routes through the mesh's own hook/action chain, never
14//! through this observation surface.
15//!
16//! All status fields carry **bounded statuses only** (`ADMITTED`, `PARTIAL`,
17//! `UNKNOWN`, `REFUSED`, `BLOCKED`). The three-state law holds: the `UNKNOWN`
18//! phase (`Unsettled`) is never coerced into `PARTIAL` (`Liquid`) or `ADMITTED`
19//! (`Vapor`). The precedence mirrors [`crate::repair::simulate_admission`]:
20//! BLOCKED > REFUSED > UNKNOWN > the boiling-point comparison.
21
22use crate::diagnostics::MaxDiagnostic;
23use lsp_types_max::{Diagnostic, DiagnosticSeverity, NumberOrString};
24use serde::{Deserialize, Serialize};
25
26// ---------------------------------------------------------------------------
27// Method name constant (read-only surface — the LSP never mutates files)
28// ---------------------------------------------------------------------------
29
30/// max/phaseShift — Resolve a world-state into a bounded conformance phase and
31/// its autonomic-mesh expansion factor. Read-only: returns a
32/// [`PhaseShiftResultMsg`]; it neither writes state nor performs the fan-out.
33pub const METHOD_PHASE_SHIFT: &str = "max/phaseShift";
34
35/// Volumetric expansion of water into steam at standard pressure (~1,700x); the
36/// autonomic-mesh amplification factor for an admitted (`Vapor`) observation.
37pub const STEAM_EXPANSION_FACTOR: u32 = 1700;
38
39// ---------------------------------------------------------------------------
40// Bounded status constants (each maps to one matter-state phase label)
41// ---------------------------------------------------------------------------
42
43/// Bounded status for the `Vapor` phase: crossed the boiling point.
44pub const STATUS_ADMITTED: &str = "ADMITTED";
45/// Bounded status for the `Liquid` phase: flowing below the boiling point.
46pub const STATUS_PARTIAL: &str = "PARTIAL";
47/// Bounded status for the `Unsettled` phase: measurement undetermined.
48pub const STATUS_UNKNOWN: &str = "UNKNOWN";
49/// Bounded status for the `Decomposed` phase: explicit refusal.
50pub const STATUS_REFUSED: &str = "REFUSED";
51/// Bounded status for the `Frozen` phase: an ANDON signal is active.
52pub const STATUS_BLOCKED: &str = "BLOCKED";
53
54// ---------------------------------------------------------------------------
55// PHASE-* diagnostic family
56// ---------------------------------------------------------------------------
57
58/// Emitted for the `Frozen` phase (status `BLOCKED`): an ANDON signal is active
59/// and no observation propagates until it clears.
60pub const PHASE_FROZEN: &str = "PHASE-FROZEN";
61
62/// Emitted for the `Decomposed` phase (status `REFUSED`): an explicit refusal;
63/// nothing propagates.
64pub const PHASE_DECOMPOSED: &str = "PHASE-DECOMPOSED";
65
66/// Emitted for the `Unsettled` phase (status `UNKNOWN`): the measurement is
67/// undetermined. Information severity — `UNKNOWN` is never raised to the
68/// refused/blocked (Error) polarity.
69pub const PHASE_UNSETTLED: &str = "PHASE-UNSETTLED";
70
71// ---------------------------------------------------------------------------
72// Request / result params (serde)
73// ---------------------------------------------------------------------------
74
75/// Parameters for `max/phaseShift`: a hypothetical world-state to resolve.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PhaseShiftParams {
78    /// Whether an ANDON signal is active (the highest-precedence floor).
79    pub andon_active: bool,
80    /// Whether the observation is explicitly refused by law.
81    pub refused: bool,
82    /// Whether the measurement is undetermined (forces `UNKNOWN`).
83    pub unknown: bool,
84    /// Conformance measurement in `[0.0, 1.0]` (the "temperature").
85    pub conformance: f64,
86    /// The admission threshold in `[0.0, 1.0]` (the "boiling point").
87    pub boiling_point: f64,
88}
89
90/// Result of `max/phaseShift`.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct PhaseShiftResultMsg {
93    /// Bounded status string for the resolved phase.
94    pub status: String,
95    /// Matter-state label of the phase: `Frozen`, `Liquid`, `Vapor`,
96    /// `Unsettled`, or `Decomposed`.
97    pub phase_label: String,
98    /// Autonomic-mesh amplification factor for the resolved phase.
99    pub expansion_factor: u32,
100    /// The conformance measurement that drove the resolution.
101    pub conformance: f64,
102    /// The admission threshold (boiling point) compared against.
103    pub boiling_point: f64,
104    /// Whether the measurement crossed the boiling point into `Vapor`.
105    pub crossed_boiling_point: bool,
106    /// Human-readable summary (one line, no victory language).
107    pub summary: String,
108}
109
110/// Resolve a world-state into its bounded phase and mesh expansion factor.
111///
112/// Read-only and pure: a function of the supplied params; it observes no file and
113/// changes no state. Precedence (three-state law preserved):
114/// BLOCKED > REFUSED > UNKNOWN > boiling-point comparison.
115pub fn phase_shift(p: &PhaseShiftParams) -> PhaseShiftResultMsg {
116    let (status, label) = if p.andon_active {
117        (STATUS_BLOCKED, "Frozen")
118    } else if p.refused {
119        (STATUS_REFUSED, "Decomposed")
120    } else if p.unknown {
121        (STATUS_UNKNOWN, "Unsettled")
122    } else if p.conformance >= p.boiling_point {
123        (STATUS_ADMITTED, "Vapor")
124    } else {
125        (STATUS_PARTIAL, "Liquid")
126    };
127
128    let expansion_factor = match status {
129        STATUS_ADMITTED => STEAM_EXPANSION_FACTOR,
130        STATUS_PARTIAL => 1,
131        _ => 0,
132    };
133
134    let summary = match status {
135        STATUS_BLOCKED => {
136            "ANDON active; phase Frozen — status BLOCKED, no observation propagates".to_string()
137        }
138        STATUS_REFUSED => {
139            "explicit refusal; phase Decomposed — status REFUSED, no observation propagates"
140                .to_string()
141        }
142        STATUS_UNKNOWN => "measurement undetermined; phase Unsettled — status UNKNOWN, never \
143                           coerced to PARTIAL or ADMITTED"
144            .to_string(),
145        STATUS_ADMITTED => format!(
146            "conformance {:.3} at or above boiling point {:.3}; phase Vapor — status ADMITTED, \
147             mesh expansion {}x",
148            p.conformance, p.boiling_point, STEAM_EXPANSION_FACTOR
149        ),
150        _ => format!(
151            "conformance {:.3} below boiling point {:.3}; phase Liquid — status PARTIAL",
152            p.conformance, p.boiling_point
153        ),
154    };
155
156    PhaseShiftResultMsg {
157        status: status.to_string(),
158        phase_label: label.to_string(),
159        expansion_factor,
160        conformance: p.conformance,
161        boiling_point: p.boiling_point,
162        crossed_boiling_point: status == STATUS_ADMITTED,
163        summary,
164    }
165}
166
167/// Build a single `PHASE-*` diagnostic carrying its family code on `lsp.code`.
168fn phase_diagnostic(code: &str, severity: DiagnosticSeverity, message: String) -> MaxDiagnostic {
169    let lsp = Diagnostic {
170        severity: Some(severity),
171        code: Some(NumberOrString::String(code.to_string())),
172        source: Some("lsp-max:phase".to_string()),
173        message,
174        ..Default::default()
175    };
176    MaxDiagnostic {
177        lsp,
178        diagnostic_id: code.to_string(),
179        law_id: code.to_string(),
180        law_axis: crate::conformance::LawAxis::Domain,
181        violated_axes: vec![crate::conformance::LawAxis::Domain.to_string()],
182        ..Default::default()
183    }
184}
185
186/// Map a resolved phase status to zero or more `PHASE-*` diagnostics, three-state
187/// law preserved.
188///
189/// - `BLOCKED` (`Frozen`) -> `PHASE-FROZEN` (Error).
190/// - `REFUSED` (`Decomposed`) -> `PHASE-DECOMPOSED` (Error).
191/// - `UNKNOWN` (`Unsettled`) -> `PHASE-UNSETTLED` (Information). Never raised to
192///   Error; `UNKNOWN` stays `UNKNOWN`.
193/// - `PARTIAL` (`Liquid`) and `ADMITTED` (`Vapor`) -> no diagnostics; the status
194///   itself carries the outcome.
195pub fn diagnostics_for_phase(status: &str) -> Vec<MaxDiagnostic> {
196    match status {
197        STATUS_BLOCKED => vec![phase_diagnostic(
198            PHASE_FROZEN,
199            DiagnosticSeverity::ERROR,
200            "ANDON active; phase Frozen — status BLOCKED, resolve the gate before any flow"
201                .to_string(),
202        )],
203        STATUS_REFUSED => vec![phase_diagnostic(
204            PHASE_DECOMPOSED,
205            DiagnosticSeverity::ERROR,
206            "explicit refusal; phase Decomposed — status REFUSED".to_string(),
207        )],
208        STATUS_UNKNOWN => vec![phase_diagnostic(
209            PHASE_UNSETTLED,
210            DiagnosticSeverity::INFORMATION,
211            "measurement undetermined; phase Unsettled — status UNKNOWN, not coerced to PARTIAL \
212             or ADMITTED"
213                .to_string(),
214        )],
215        _ => Vec::new(),
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn params(andon: bool, refused: bool, unknown: bool, c: f64, bp: f64) -> PhaseShiftParams {
224        PhaseShiftParams {
225            andon_active: andon,
226            refused,
227            unknown,
228            conformance: c,
229            boiling_point: bp,
230        }
231    }
232
233    fn codes(diags: &[MaxDiagnostic]) -> Vec<&str> {
234        diags
235            .iter()
236            .filter_map(|d| match &d.lsp.code {
237                Some(NumberOrString::String(s)) => Some(s.as_str()),
238                _ => None,
239            })
240            .collect()
241    }
242
243    #[test]
244    fn precedence_blocked_over_refused_over_unknown_over_boiling() {
245        assert_eq!(
246            phase_shift(&params(true, true, true, 0.9, 0.5)).status,
247            STATUS_BLOCKED
248        );
249        assert_eq!(
250            phase_shift(&params(false, true, true, 0.9, 0.5)).status,
251            STATUS_REFUSED
252        );
253        assert_eq!(
254            phase_shift(&params(false, false, true, 0.9, 0.5)).status,
255            STATUS_UNKNOWN
256        );
257        assert_eq!(
258            phase_shift(&params(false, false, false, 0.9, 0.5)).status,
259            STATUS_ADMITTED
260        );
261        assert_eq!(
262            phase_shift(&params(false, false, false, 0.4, 0.5)).status,
263            STATUS_PARTIAL
264        );
265    }
266
267    #[test]
268    fn vapor_carries_the_steam_expansion_factor() {
269        let admitted = phase_shift(&params(false, false, false, 0.8, 0.7));
270        assert_eq!(admitted.expansion_factor, STEAM_EXPANSION_FACTOR);
271        assert!(admitted.crossed_boiling_point);
272        assert_eq!(admitted.phase_label, "Vapor");
273
274        let liquid = phase_shift(&params(false, false, false, 0.5, 0.7));
275        assert_eq!(liquid.expansion_factor, 1);
276        assert!(!liquid.crossed_boiling_point);
277    }
278
279    #[test]
280    fn unknown_stays_unknown_and_is_not_coerced() {
281        let r = phase_shift(&params(false, false, true, 1.0, 0.0));
282        assert_eq!(r.status, STATUS_UNKNOWN);
283        assert_eq!(r.expansion_factor, 0);
284        let diags = diagnostics_for_phase(&r.status);
285        assert_eq!(codes(&diags), vec![PHASE_UNSETTLED]);
286        // UNKNOWN never surfaces a refused/blocked (Error) polarity diagnostic.
287        assert!(diags
288            .iter()
289            .all(|d| d.lsp.severity != Some(DiagnosticSeverity::ERROR)));
290    }
291
292    #[test]
293    fn frozen_and_decomposed_are_error_polarity() {
294        assert_eq!(
295            diagnostics_for_phase(STATUS_BLOCKED)[0].lsp.severity,
296            Some(DiagnosticSeverity::ERROR)
297        );
298        assert_eq!(
299            diagnostics_for_phase(STATUS_REFUSED)[0].lsp.severity,
300            Some(DiagnosticSeverity::ERROR)
301        );
302    }
303
304    #[test]
305    fn admitted_and_partial_emit_no_diagnostics() {
306        assert!(diagnostics_for_phase(STATUS_ADMITTED).is_empty());
307        assert!(diagnostics_for_phase(STATUS_PARTIAL).is_empty());
308    }
309}