1use crate::diagnostics::MaxDiagnostic;
23use lsp_types_max::{Diagnostic, DiagnosticSeverity, NumberOrString};
24use serde::{Deserialize, Serialize};
25
26pub const METHOD_PHASE_SHIFT: &str = "max/phaseShift";
34
35pub const STEAM_EXPANSION_FACTOR: u32 = 1700;
38
39pub const STATUS_ADMITTED: &str = "ADMITTED";
45pub const STATUS_PARTIAL: &str = "PARTIAL";
47pub const STATUS_UNKNOWN: &str = "UNKNOWN";
49pub const STATUS_REFUSED: &str = "REFUSED";
51pub const STATUS_BLOCKED: &str = "BLOCKED";
53
54pub const PHASE_FROZEN: &str = "PHASE-FROZEN";
61
62pub const PHASE_DECOMPOSED: &str = "PHASE-DECOMPOSED";
65
66pub const PHASE_UNSETTLED: &str = "PHASE-UNSETTLED";
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PhaseShiftParams {
78 pub andon_active: bool,
80 pub refused: bool,
82 pub unknown: bool,
84 pub conformance: f64,
86 pub boiling_point: f64,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct PhaseShiftResultMsg {
93 pub status: String,
95 pub phase_label: String,
98 pub expansion_factor: u32,
100 pub conformance: f64,
102 pub boiling_point: f64,
104 pub crossed_boiling_point: bool,
106 pub summary: String,
108}
109
110pub 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
167fn 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
186pub 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(¶ms(true, true, true, 0.9, 0.5)).status,
247 STATUS_BLOCKED
248 );
249 assert_eq!(
250 phase_shift(¶ms(false, true, true, 0.9, 0.5)).status,
251 STATUS_REFUSED
252 );
253 assert_eq!(
254 phase_shift(¶ms(false, false, true, 0.9, 0.5)).status,
255 STATUS_UNKNOWN
256 );
257 assert_eq!(
258 phase_shift(¶ms(false, false, false, 0.9, 0.5)).status,
259 STATUS_ADMITTED
260 );
261 assert_eq!(
262 phase_shift(¶ms(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(¶ms(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(¶ms(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(¶ms(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 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}