lsp_max_protocol/pipeline.rs
1//! Read-only `max/*` protocol surface for the TPOT2 breed-pipeline optimizer.
2//!
3//! TPOT2 searches a population of candidate analysis pipelines ("breeds") and
4//! reports the fittest. This module projects that optimizer onto the lsp-max
5//! protocol surface: it declares the method names, the serde request/result
6//! shapes, and the `TPOT2-*` diagnostic family.
7//!
8//! This surface is **read-only**. It emits diagnostics, search results, and
9//! intents; it never mutates files. A search outcome is reported as a
10//! `PipelineSearchResultMsg` and, where a law axis is unsatisfied, as zero or
11//! more [`MaxDiagnostic`] values. Any future mutation would have to route
12//! through the `CodeAction -> clap-noun-verb -> Receipt` chain, not through this
13//! observation surface.
14//!
15//! All status fields carry **bounded statuses only** (`ADMITTED`, `PARTIAL`,
16//! `UNKNOWN`, `REFUSED`, `BLOCKED`, `CANDIDATE`, `OPEN`). The three-state law
17//! holds: `UNKNOWN` is never coerced into `ADMITTED` or `REFUSED`. In
18//! particular, a requested-but-absent OCEL log yields `UNKNOWN`
19//! (`TPOT2-OCEL-MISSING`) and stays `UNKNOWN`.
20
21use crate::diagnostics::MaxDiagnostic;
22use lsp_types_max::{Diagnostic, DiagnosticSeverity, NumberOrString};
23use serde::{Deserialize, Serialize};
24
25// ---------------------------------------------------------------------------
26// Method name constants (read-only surface — the LSP never mutates files)
27// ---------------------------------------------------------------------------
28
29/// max/pipelineSearch — Run a breed-pipeline search and report the fittest
30/// observed breeds. Read-only: returns a [`PipelineSearchResultMsg`]; never
31/// writes the discovered pipeline to disk.
32pub const METHOD_PIPELINE_SEARCH: &str = "max/pipelineSearch";
33
34/// max/pipelineEvaluate — Evaluate a single named breed and report its fitness.
35/// Read-only: returns a [`PipelineEvaluateResultMsg`]; never persists the breed.
36pub const METHOD_PIPELINE_EVALUATE: &str = "max/pipelineEvaluate";
37
38/// max/pipelineBreeds — Enumerate the current breed pool. Read-only: returns a
39/// [`PipelineBreedsResultMsg`]; never alters the pool.
40pub const METHOD_PIPELINE_BREEDS: &str = "max/pipelineBreeds";
41
42// ---------------------------------------------------------------------------
43// Request / result params (serde)
44// ---------------------------------------------------------------------------
45
46/// Parameters for `max/pipelineSearch`.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PipelineSearchParams {
49 /// Number of generations to evolve. `None` lets the optimizer choose.
50 pub generations: Option<usize>,
51 /// Population size per generation. `None` lets the optimizer choose.
52 pub population_size: Option<usize>,
53 /// Path to the OCEL event log the search reads from. `None` means the
54 /// optimizer uses its already-loaded observations; `Some(path)` that is
55 /// absent on disk yields `UNKNOWN` (see `TPOT2-OCEL-MISSING`).
56 pub ocel_path: Option<String>,
57}
58
59/// Result of `max/pipelineSearch`.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PipelineSearchResultMsg {
62 /// Bounded status string (`ADMITTED`, `PARTIAL`, `UNKNOWN`, `REFUSED`, ...).
63 pub status: String,
64 /// Identifiers of the fittest breeds observed, best first.
65 pub best_breeds: Vec<String>,
66 /// Fitness of the best observed breed.
67 pub best_fitness: f64,
68 /// Generations actually evolved.
69 pub generations_run: usize,
70 /// Total breed evaluations performed.
71 pub evaluations: usize,
72 /// Human-readable summary of the search outcome.
73 pub summary: String,
74}
75
76/// Parameters for `max/pipelineEvaluate`.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct PipelineEvaluateParams {
79 /// Identifier of the breed to evaluate.
80 pub breed_id: String,
81 /// Path to the OCEL event log to evaluate against. `Some(path)` that is
82 /// absent on disk yields `UNKNOWN`.
83 pub ocel_path: Option<String>,
84}
85
86/// Result of `max/pipelineEvaluate`.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PipelineEvaluateResultMsg {
89 /// Bounded status string.
90 pub status: String,
91 /// Identifier of the evaluated breed.
92 pub breed_id: String,
93 /// Fitness observed for this breed.
94 pub fitness: f64,
95 /// Human-readable summary of the evaluation outcome.
96 pub summary: String,
97}
98
99/// Parameters for `max/pipelineBreeds`.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct PipelineBreedsParams {
102 /// Optional substring filter applied to breed identifiers. `None` lists all.
103 pub filter: Option<String>,
104}
105
106/// Result of `max/pipelineBreeds`.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PipelineBreedsResultMsg {
109 /// Bounded status string.
110 pub status: String,
111 /// Identifiers of the breeds currently in the pool.
112 pub breeds: Vec<String>,
113 /// Size of the reported pool.
114 pub pool_size: usize,
115 /// Human-readable summary of the pool state.
116 pub summary: String,
117}
118
119// ---------------------------------------------------------------------------
120// TPOT2-* diagnostic family
121// ---------------------------------------------------------------------------
122
123/// Emitted when a pipeline search ends below the admission threshold
124/// (status `PARTIAL` or `UNKNOWN`). The search produced a result but it does
125/// not clear the bar; the law axis is not admitted.
126pub const TPOT2_NONCONVERGENCE: &str = "TPOT2-NONCONVERGENCE";
127
128/// Emitted when the breed pool is empty (status `REFUSED`). With no breeds to
129/// evaluate there is nothing to admit.
130pub const TPOT2_EMPTY_POOL: &str = "TPOT2-EMPTY-POOL";
131
132/// Emitted when an OCEL path is requested but absent (status `UNKNOWN`).
133/// Absence of the observation source is not a refusal — admissibility cannot be
134/// determined, so this stays `UNKNOWN` and is never coerced to `REFUSED` or
135/// `ADMITTED`.
136pub const TPOT2_OCEL_MISSING: &str = "TPOT2-OCEL-MISSING";
137
138/// Bounded status: the search cleared the admission threshold.
139pub const STATUS_ADMITTED: &str = "ADMITTED";
140/// Bounded status: a result exists but is below the admission threshold.
141pub const STATUS_PARTIAL: &str = "PARTIAL";
142/// Bounded status: admissibility cannot be determined.
143pub const STATUS_UNKNOWN: &str = "UNKNOWN";
144/// Bounded status: an explicit refusal (e.g. empty breed pool).
145pub const STATUS_REFUSED: &str = "REFUSED";
146
147/// Build a single `TPOT2-*` diagnostic with the given code, severity, and
148/// message. The LSP `code` carries the family string so downstream consumers
149/// can route on it without parsing the message.
150fn tpot2_diagnostic(code: &str, severity: DiagnosticSeverity, message: String) -> MaxDiagnostic {
151 let lsp = Diagnostic {
152 severity: Some(severity),
153 code: Some(NumberOrString::String(code.to_string())),
154 source: Some("lsp-max:tpot2".to_string()),
155 message,
156 ..Default::default()
157 };
158 MaxDiagnostic {
159 lsp,
160 diagnostic_id: code.to_string(),
161 law_id: code.to_string(),
162 // TPOT2 governs a process-mining optimizer; its law axis is Domain.
163 law_axis: crate::conformance::LawAxis::Domain,
164 violated_axes: vec![crate::conformance::LawAxis::Domain.to_string()],
165 ..Default::default()
166 }
167}
168
169/// Map a pipeline-search outcome to zero or more `TPOT2-*` diagnostics using
170/// only bounded statuses.
171///
172/// Read-only: this inspects an outcome and reports; it does not change pool or
173/// pipeline state.
174///
175/// Status handling, three-state law preserved:
176/// - `REFUSED` -> `TPOT2-EMPTY-POOL` (Error). An explicit refusal.
177/// - `UNKNOWN` -> `TPOT2-OCEL-MISSING` (Information) **and** `TPOT2-NONCONVERGENCE`
178/// (Information). `UNKNOWN` is reported as `UNKNOWN`; it is never coerced into
179/// `REFUSED` or `ADMITTED`.
180/// - `PARTIAL` -> `TPOT2-NONCONVERGENCE` (Warning) when `best_fitness` is below
181/// `admission_threshold`.
182/// - `ADMITTED` -> no diagnostics (the outcome cleared the bar).
183/// - Any other (unrecognized) status -> no diagnostics; the caller's own
184/// `Λ(a)` judgment governs outside this bounded set.
185pub fn diagnostics_for_search(
186 status: &str,
187 best_fitness: f64,
188 admission_threshold: f64,
189) -> Vec<MaxDiagnostic> {
190 match status {
191 STATUS_REFUSED => vec![tpot2_diagnostic(
192 TPOT2_EMPTY_POOL,
193 DiagnosticSeverity::ERROR,
194 "breed pool is empty; status REFUSED — no breeds to evaluate".to_string(),
195 )],
196 STATUS_UNKNOWN => vec![
197 tpot2_diagnostic(
198 TPOT2_OCEL_MISSING,
199 DiagnosticSeverity::INFORMATION,
200 "OCEL observation source absent; status UNKNOWN — admissibility \
201 cannot be determined and is not coerced to REFUSED or ADMITTED"
202 .to_string(),
203 ),
204 tpot2_diagnostic(
205 TPOT2_NONCONVERGENCE,
206 DiagnosticSeverity::INFORMATION,
207 format!(
208 "pipeline search did not reach admission threshold {admission_threshold}; \
209 status UNKNOWN — best observed fitness {best_fitness}"
210 ),
211 ),
212 ],
213 STATUS_PARTIAL if best_fitness < admission_threshold => vec![tpot2_diagnostic(
214 TPOT2_NONCONVERGENCE,
215 DiagnosticSeverity::WARNING,
216 format!(
217 "pipeline search ended below admission threshold {admission_threshold}; \
218 status PARTIAL — best observed fitness {best_fitness}"
219 ),
220 )],
221 // ADMITTED, or any status outside the bounded set, emits nothing.
222 _ => Vec::new(),
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn codes(diags: &[MaxDiagnostic]) -> Vec<&str> {
231 diags
232 .iter()
233 .filter_map(|d| match &d.lsp.code {
234 Some(NumberOrString::String(s)) => Some(s.as_str()),
235 _ => None,
236 })
237 .collect()
238 }
239
240 #[test]
241 fn partial_below_threshold_emits_nonconvergence() {
242 let diags = diagnostics_for_search(STATUS_PARTIAL, 0.40, 0.80);
243 assert_eq!(codes(&diags), vec![TPOT2_NONCONVERGENCE]);
244 assert_eq!(diags[0].lsp.severity, Some(DiagnosticSeverity::WARNING));
245 assert_eq!(diags[0].law_axis, crate::conformance::LawAxis::Domain);
246 }
247
248 #[test]
249 fn refused_empty_pool_emits_empty_pool_error() {
250 let diags = diagnostics_for_search(STATUS_REFUSED, 0.0, 0.80);
251 assert_eq!(codes(&diags), vec![TPOT2_EMPTY_POOL]);
252 assert_eq!(diags[0].lsp.severity, Some(DiagnosticSeverity::ERROR));
253 }
254
255 #[test]
256 fn admitted_outcome_emits_no_diagnostics() {
257 let diags = diagnostics_for_search(STATUS_ADMITTED, 0.95, 0.80);
258 assert!(
259 diags.is_empty(),
260 "an admitted outcome must not emit any TPOT2 diagnostic"
261 );
262 }
263
264 #[test]
265 fn unknown_path_stays_unknown_and_is_not_coerced() {
266 let diags = diagnostics_for_search(STATUS_UNKNOWN, 0.0, 0.80);
267 let emitted = codes(&diags);
268 // The OCEL-missing signal is present and the path is reported as UNKNOWN.
269 assert!(emitted.contains(&TPOT2_OCEL_MISSING));
270 assert!(emitted.contains(&TPOT2_NONCONVERGENCE));
271 // Three-state law: UNKNOWN must not collapse into the REFUSED signal.
272 assert!(
273 !emitted.contains(&TPOT2_EMPTY_POOL),
274 "UNKNOWN must never be coerced into the REFUSED (empty-pool) outcome"
275 );
276 // No diagnostic on the UNKNOWN path carries Error severity (which is the
277 // REFUSED polarity here); UNKNOWN stays informational.
278 assert!(
279 diags
280 .iter()
281 .all(|d| d.lsp.severity != Some(DiagnosticSeverity::ERROR)),
282 "UNKNOWN path must not surface a REFUSED-polarity (Error) diagnostic"
283 );
284 }
285}