use crate::diagnostics::MaxDiagnostic;
use lsp_types_max::{Diagnostic, DiagnosticSeverity, NumberOrString};
use serde::{Deserialize, Serialize};
pub const METHOD_PHASE_SHIFT: &str = "max/phaseShift";
pub const STEAM_EXPANSION_FACTOR: u32 = 1700;
pub const STATUS_ADMITTED: &str = "ADMITTED";
pub const STATUS_PARTIAL: &str = "PARTIAL";
pub const STATUS_UNKNOWN: &str = "UNKNOWN";
pub const STATUS_REFUSED: &str = "REFUSED";
pub const STATUS_BLOCKED: &str = "BLOCKED";
pub const PHASE_FROZEN: &str = "PHASE-FROZEN";
pub const PHASE_DECOMPOSED: &str = "PHASE-DECOMPOSED";
pub const PHASE_UNSETTLED: &str = "PHASE-UNSETTLED";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseShiftParams {
pub andon_active: bool,
pub refused: bool,
pub unknown: bool,
pub conformance: f64,
pub boiling_point: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseShiftResultMsg {
pub status: String,
pub phase_label: String,
pub expansion_factor: u32,
pub conformance: f64,
pub boiling_point: f64,
pub crossed_boiling_point: bool,
pub summary: String,
}
pub fn phase_shift(p: &PhaseShiftParams) -> PhaseShiftResultMsg {
let (status, label) = if p.andon_active {
(STATUS_BLOCKED, "Frozen")
} else if p.refused {
(STATUS_REFUSED, "Decomposed")
} else if p.unknown {
(STATUS_UNKNOWN, "Unsettled")
} else if p.conformance >= p.boiling_point {
(STATUS_ADMITTED, "Vapor")
} else {
(STATUS_PARTIAL, "Liquid")
};
let expansion_factor = match status {
STATUS_ADMITTED => STEAM_EXPANSION_FACTOR,
STATUS_PARTIAL => 1,
_ => 0,
};
let summary = match status {
STATUS_BLOCKED => {
"ANDON active; phase Frozen — status BLOCKED, no observation propagates".to_string()
}
STATUS_REFUSED => {
"explicit refusal; phase Decomposed — status REFUSED, no observation propagates"
.to_string()
}
STATUS_UNKNOWN => "measurement undetermined; phase Unsettled — status UNKNOWN, never \
coerced to PARTIAL or ADMITTED"
.to_string(),
STATUS_ADMITTED => format!(
"conformance {:.3} at or above boiling point {:.3}; phase Vapor — status ADMITTED, \
mesh expansion {}x",
p.conformance, p.boiling_point, STEAM_EXPANSION_FACTOR
),
_ => format!(
"conformance {:.3} below boiling point {:.3}; phase Liquid — status PARTIAL",
p.conformance, p.boiling_point
),
};
PhaseShiftResultMsg {
status: status.to_string(),
phase_label: label.to_string(),
expansion_factor,
conformance: p.conformance,
boiling_point: p.boiling_point,
crossed_boiling_point: status == STATUS_ADMITTED,
summary,
}
}
fn phase_diagnostic(code: &str, severity: DiagnosticSeverity, message: String) -> MaxDiagnostic {
let lsp = Diagnostic {
severity: Some(severity),
code: Some(NumberOrString::String(code.to_string())),
source: Some("lsp-max:phase".to_string()),
message,
..Default::default()
};
MaxDiagnostic {
lsp,
diagnostic_id: code.to_string(),
law_id: code.to_string(),
law_axis: crate::conformance::LawAxis::Domain,
violated_axes: vec![crate::conformance::LawAxis::Domain.to_string()],
..Default::default()
}
}
pub fn diagnostics_for_phase(status: &str) -> Vec<MaxDiagnostic> {
match status {
STATUS_BLOCKED => vec![phase_diagnostic(
PHASE_FROZEN,
DiagnosticSeverity::ERROR,
"ANDON active; phase Frozen — status BLOCKED, resolve the gate before any flow"
.to_string(),
)],
STATUS_REFUSED => vec![phase_diagnostic(
PHASE_DECOMPOSED,
DiagnosticSeverity::ERROR,
"explicit refusal; phase Decomposed — status REFUSED".to_string(),
)],
STATUS_UNKNOWN => vec![phase_diagnostic(
PHASE_UNSETTLED,
DiagnosticSeverity::INFORMATION,
"measurement undetermined; phase Unsettled — status UNKNOWN, not coerced to PARTIAL \
or ADMITTED"
.to_string(),
)],
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn params(andon: bool, refused: bool, unknown: bool, c: f64, bp: f64) -> PhaseShiftParams {
PhaseShiftParams {
andon_active: andon,
refused,
unknown,
conformance: c,
boiling_point: bp,
}
}
fn codes(diags: &[MaxDiagnostic]) -> Vec<&str> {
diags
.iter()
.filter_map(|d| match &d.lsp.code {
Some(NumberOrString::String(s)) => Some(s.as_str()),
_ => None,
})
.collect()
}
#[test]
fn precedence_blocked_over_refused_over_unknown_over_boiling() {
assert_eq!(
phase_shift(¶ms(true, true, true, 0.9, 0.5)).status,
STATUS_BLOCKED
);
assert_eq!(
phase_shift(¶ms(false, true, true, 0.9, 0.5)).status,
STATUS_REFUSED
);
assert_eq!(
phase_shift(¶ms(false, false, true, 0.9, 0.5)).status,
STATUS_UNKNOWN
);
assert_eq!(
phase_shift(¶ms(false, false, false, 0.9, 0.5)).status,
STATUS_ADMITTED
);
assert_eq!(
phase_shift(¶ms(false, false, false, 0.4, 0.5)).status,
STATUS_PARTIAL
);
}
#[test]
fn vapor_carries_the_steam_expansion_factor() {
let admitted = phase_shift(¶ms(false, false, false, 0.8, 0.7));
assert_eq!(admitted.expansion_factor, STEAM_EXPANSION_FACTOR);
assert!(admitted.crossed_boiling_point);
assert_eq!(admitted.phase_label, "Vapor");
let liquid = phase_shift(¶ms(false, false, false, 0.5, 0.7));
assert_eq!(liquid.expansion_factor, 1);
assert!(!liquid.crossed_boiling_point);
}
#[test]
fn unknown_stays_unknown_and_is_not_coerced() {
let r = phase_shift(¶ms(false, false, true, 1.0, 0.0));
assert_eq!(r.status, STATUS_UNKNOWN);
assert_eq!(r.expansion_factor, 0);
let diags = diagnostics_for_phase(&r.status);
assert_eq!(codes(&diags), vec![PHASE_UNSETTLED]);
assert!(diags
.iter()
.all(|d| d.lsp.severity != Some(DiagnosticSeverity::ERROR)));
}
#[test]
fn frozen_and_decomposed_are_error_polarity() {
assert_eq!(
diagnostics_for_phase(STATUS_BLOCKED)[0].lsp.severity,
Some(DiagnosticSeverity::ERROR)
);
assert_eq!(
diagnostics_for_phase(STATUS_REFUSED)[0].lsp.severity,
Some(DiagnosticSeverity::ERROR)
);
}
#[test]
fn admitted_and_partial_emit_no_diagnostics() {
assert!(diagnostics_for_phase(STATUS_ADMITTED).is_empty());
assert!(diagnostics_for_phase(STATUS_PARTIAL).is_empty());
}
}