Skip to main content

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}