Skip to main content

pounce_studio_core/
preflight.rs

1//! Pre-flight problem inspection: builtin metadata, AMPL `.nl` header
2//! parsing, GAMS `.gms` header / Solve-directive parsing, and `.lst`
3//! SOLVE SUMMARY extraction.
4//!
5//! Ported from `studio/mcp/pounce_studio_mcp/server.py`. All entry
6//! points are byte-slice / `&str` based so the module stays WASM-clean.
7
8use serde::Serialize;
9
10// ---- builtin problems ------------------------------------------------
11
12/// Metadata for a CLI `--problem <name>` builtin.
13#[derive(Debug, Clone, Serialize)]
14pub struct BuiltinInfo {
15    pub name: &'static str,
16    pub n_variables: i32,
17    pub n_constraints: i32,
18    pub class: &'static str,
19    pub notes: &'static str,
20}
21
22const BUILTINS: &[BuiltinInfo] = &[
23    BuiltinInfo {
24        name: "quadratic",
25        n_variables: 2,
26        n_constraints: 0,
27        class: "unconstrained quadratic",
28        notes: "Convex QP; trivial — single Newton step from any start.",
29    },
30    BuiltinInfo {
31        name: "rosenbrock",
32        n_variables: 2,
33        n_constraints: 0,
34        class: "unconstrained nonlinear",
35        notes: "Classic non-convex banana valley; tests line search.",
36    },
37    BuiltinInfo {
38        name: "bounded-quadratic",
39        n_variables: 2,
40        n_constraints: 0,
41        class: "bound-constrained quadratic",
42        notes: "Active-set quadratic; exercises bound multipliers.",
43    },
44    BuiltinInfo {
45        name: "eq-quadratic",
46        n_variables: 3,
47        n_constraints: 1,
48        class: "equality-constrained quadratic",
49        notes: "QP with one linear equality; tests KKT factorisation.",
50    },
51    BuiltinInfo {
52        name: "circle",
53        n_variables: 2,
54        n_constraints: 1,
55        class: "equality-constrained nonlinear",
56        notes: "Nonlinear equality; tests restoration entry.",
57    },
58];
59
60/// Look up a builtin by name.
61pub fn builtin(name: &str) -> Option<&'static BuiltinInfo> {
62    BUILTINS.iter().find(|b| b.name == name)
63}
64
65/// All builtin names.
66pub fn all_builtins() -> Vec<&'static BuiltinInfo> {
67    BUILTINS.iter().collect()
68}
69
70// ---- option suggestions ---------------------------------------------
71
72/// One advisory option suggestion. Never auto-applied.
73#[derive(Debug, Clone, Serialize)]
74pub struct Suggestion {
75    pub option: String,
76    pub value: String,
77    pub why: String,
78}
79
80// ---- NL header parsing -----------------------------------------------
81
82/// Dimensions / format detected from an AMPL `.nl` file header.
83#[derive(Debug, Clone, Default, Serialize)]
84pub struct NlHeader {
85    pub format: String,
86    pub n_variables: Option<i32>,
87    pub n_constraints: Option<i32>,
88    pub n_objectives: Option<i32>,
89    pub n_ranges: Option<i32>,
90    pub n_equalities: Option<i32>,
91    pub n_nonlinear_constraints: Option<i32>,
92    pub n_nonlinear_objectives: Option<i32>,
93    pub n_nonlinear_vars_in_cons: Option<i32>,
94    pub n_nonlinear_vars_in_obj: Option<i32>,
95    pub n_nonlinear_vars_in_both: Option<i32>,
96    pub nnz_jacobian: Option<i32>,
97    pub nnz_objective_gradient: Option<i32>,
98    pub warnings: Vec<String>,
99}
100
101/// Parse the first ~10 lines of an AMPL `.nl` file. Tolerant — partial
102/// parses still return what we got.
103pub fn parse_nl_header(bytes: &[u8]) -> NlHeader {
104    let text = String::from_utf8_lossy(bytes);
105    let lines: Vec<&str> = text.lines().take(10).collect();
106
107    let mut out = NlHeader::default();
108    if lines.is_empty() || lines[0].is_empty() {
109        out.format = "unknown".into();
110        out.warnings.push("empty .nl file".into());
111        return out;
112    }
113    out.format = match lines[0].as_bytes().first() {
114        Some(b'g') => "text".into(),
115        Some(b'b') => "binary".into(),
116        _ => "unknown".into(),
117    };
118    if out.format == "binary" {
119        out.warnings.push("binary .nl: header parse skipped".into());
120        return out;
121    }
122
123    let ints = |line: &str| -> Vec<i32> {
124        line.split_whitespace()
125            .filter_map(|t| t.parse::<i32>().ok())
126            .collect()
127    };
128
129    if let Some(line) = lines.get(1) {
130        let v = ints(line);
131        if v.len() >= 5 {
132            out.n_variables = Some(v[0]);
133            out.n_constraints = Some(v[1]);
134            out.n_objectives = Some(v[2]);
135            out.n_ranges = Some(v[3]);
136            out.n_equalities = Some(v[4]);
137        } else {
138            out.warnings.push("could not parse dimensions line".into());
139        }
140    }
141    if let Some(line) = lines.get(2) {
142        let v = ints(line);
143        if v.len() >= 2 {
144            out.n_nonlinear_constraints = Some(v[0]);
145            out.n_nonlinear_objectives = Some(v[1]);
146        }
147    }
148    if let Some(line) = lines.get(4) {
149        let v = ints(line);
150        if v.len() >= 3 {
151            out.n_nonlinear_vars_in_cons = Some(v[0]);
152            out.n_nonlinear_vars_in_obj = Some(v[1]);
153            out.n_nonlinear_vars_in_both = Some(v[2]);
154        }
155    }
156    for idx in [6_usize, 7] {
157        if let Some(line) = lines.get(idx) {
158            let v = ints(line);
159            if v.len() == 2 && out.nnz_jacobian.is_none() {
160                out.nnz_jacobian = Some(v[0]);
161                out.nnz_objective_gradient = Some(v[1]);
162                break;
163            }
164        }
165    }
166    out
167}
168
169/// Result of analyzing an NL file or builtin.
170#[derive(Debug, Clone, Serialize)]
171pub struct NlAnalysis {
172    pub kind: String,
173    pub name: Option<String>,
174    pub path: Option<String>,
175    pub dimensions: serde_json::Value,
176    pub class: String,
177    pub notes: Option<String>,
178    pub warnings: Vec<String>,
179    pub suggestions: Vec<Suggestion>,
180}
181
182/// Build an analysis from an NL header.
183pub fn analyze_nl(path: &str, header: NlHeader) -> NlAnalysis {
184    let class = classify_nl(&header);
185    let warnings = nl_warnings(&header);
186    let suggestions = nl_suggestions(&header);
187    NlAnalysis {
188        kind: "nl_file".into(),
189        name: None,
190        path: Some(path.into()),
191        dimensions: serde_json::to_value(&header).unwrap_or(serde_json::Value::Null),
192        class,
193        notes: None,
194        warnings,
195        suggestions,
196    }
197}
198
199/// Build an analysis from a builtin name.
200pub fn analyze_builtin(name: &str) -> Result<NlAnalysis, String> {
201    let b = builtin(name).ok_or_else(|| {
202        let names: Vec<&str> = BUILTINS.iter().map(|b| b.name).collect();
203        format!("unknown builtin {name:?}; valid: {names:?}")
204    })?;
205    let dims = serde_json::json!({
206        "n_variables": b.n_variables,
207        "n_constraints": b.n_constraints,
208    });
209    let header = NlHeader {
210        n_variables: Some(b.n_variables),
211        n_constraints: Some(b.n_constraints),
212        ..Default::default()
213    };
214    Ok(NlAnalysis {
215        kind: "builtin".into(),
216        name: Some(b.name.into()),
217        path: None,
218        dimensions: dims,
219        class: b.class.into(),
220        notes: Some(b.notes.into()),
221        warnings: nl_warnings(&header),
222        suggestions: nl_suggestions(&header),
223    })
224}
225
226fn classify_nl(h: &NlHeader) -> String {
227    let n_con = h.n_constraints.unwrap_or(0);
228    let nlc = h.n_nonlinear_constraints.unwrap_or(0);
229    let nlo = h.n_nonlinear_objectives.unwrap_or(0);
230    let n_eq = h.n_equalities.unwrap_or(0);
231    let is_nl = nlc > 0 || nlo > 0;
232    if n_con == 0 {
233        return if is_nl {
234            "unconstrained nonlinear".into()
235        } else {
236            "unconstrained linear/quadratic".into()
237        };
238    }
239    let nl_or_lin = if is_nl {
240        "nonlinear"
241    } else {
242        "linear/quadratic"
243    };
244    let eq_or_gen = if n_eq == n_con {
245        "equality-constrained"
246    } else {
247        "general-constrained"
248    };
249    format!("{nl_or_lin} {eq_or_gen}")
250}
251
252fn nl_warnings(h: &NlHeader) -> Vec<String> {
253    let mut out = h.warnings.clone();
254    let n_var = h.n_variables.unwrap_or(0);
255    let n_con = h.n_constraints.unwrap_or(0);
256    if n_var == 0 {
257        out.push("zero variables parsed — header read may have failed".into());
258    }
259    if (n_var + n_con) > 50_000 {
260        out.push(format!(
261            "very large problem ({n_var} vars, {n_con} cons); expect long solve times \
262             and consider running with `--dump` for diagnostics.",
263        ));
264    }
265    if h.n_objectives == Some(0) {
266        out.push("no objective: this is a feasibility problem, not optimisation.".into());
267    }
268    out
269}
270
271fn nl_suggestions(h: &NlHeader) -> Vec<Suggestion> {
272    let mut out = Vec::new();
273    let n_var = h.n_variables.unwrap_or(0);
274    let n_con = h.n_constraints.unwrap_or(0);
275    let nlc = h.n_nonlinear_constraints.unwrap_or(0);
276    let nlo = h.n_nonlinear_objectives.unwrap_or(0);
277    let n_eq = h.n_equalities.unwrap_or(0);
278    let size = n_var + n_con;
279
280    if size > 1_000 && nlc == 0 && nlo == 0 {
281        out.push(Suggestion {
282            option: "mu_strategy".into(),
283            value: "adaptive".into(),
284            why: "purely linear/quadratic — adaptive mu usually converges in fewer iters.".into(),
285        });
286    }
287    if size > 10_000 {
288        out.push(Suggestion {
289            option: "max_iter".into(),
290            value: "1000".into(),
291            why: "default 3000 is fine but raise tol expectations for large problems.".into(),
292        });
293    }
294    if nlc > 0 && n_eq == n_con && n_con > 0 {
295        out.push(Suggestion {
296            option: "bound_relax_factor".into(),
297            value: "0".into(),
298            why: "all constraints equality + nonlinear: relaxing bounds can blur the feasible \
299                  manifold; setting to 0 keeps it sharp."
300                .into(),
301        });
302    }
303    out
304}
305
306// ---- GAMS .gms header parsing ---------------------------------------
307
308/// Dimensions parsed from a `gams convert`-emitted `.gms` header.
309#[derive(Debug, Clone, Default, Serialize)]
310pub struct GmsHeader {
311    pub n_equations_total: Option<i32>,
312    pub n_equality_eqs: Option<i32>,
313    pub n_ge_eqs: Option<i32>,
314    pub n_le_eqs: Option<i32>,
315    pub n_variables_total: Option<i32>,
316    pub n_continuous_vars: Option<i32>,
317    pub n_binary_vars: Option<i32>,
318    pub n_integer_vars: Option<i32>,
319    pub nnz_total: Option<i32>,
320    pub nnz_constant: Option<i32>,
321    pub nnz_nonlinear: Option<i32>,
322}
323
324/// Parse the comment block emitted by `gams convert`. Lines look like:
325///
326/// ```text
327/// *  Equation counts
328/// *     Total       E       G       L       N       X
329/// *       109     108       0       1       0       0
330/// ```
331pub fn parse_gms_convert_header(text: &str) -> GmsHeader {
332    let mut out = GmsHeader::default();
333    let star_lines: Vec<&str> = text.lines().filter(|l| l.starts_with('*')).collect();
334
335    fn next_int_line<'a>(lines: &'a [&'a str], start: usize) -> Option<Vec<i32>> {
336        let end = (start + 5).min(lines.len());
337        for line in lines.iter().take(end).skip(start + 1) {
338            let nums: Vec<i32> = line
339                .trim_start_matches('*')
340                .split_whitespace()
341                .filter_map(|t| t.parse::<i32>().ok())
342                .collect();
343            if !nums.is_empty() {
344                return Some(nums);
345            }
346        }
347        None
348    }
349
350    for (i, line) in star_lines.iter().enumerate() {
351        if line.contains("Equation counts") {
352            if let Some(nums) = next_int_line(&star_lines, i) {
353                if !nums.is_empty() {
354                    out.n_equations_total = Some(nums[0]);
355                }
356                if nums.len() >= 2 {
357                    out.n_equality_eqs = Some(nums[1]);
358                }
359                if nums.len() >= 3 {
360                    out.n_ge_eqs = Some(nums[2]);
361                }
362                if nums.len() >= 4 {
363                    out.n_le_eqs = Some(nums[3]);
364                }
365            }
366        } else if line.contains("Variable counts") {
367            if let Some(nums) = next_int_line(&star_lines, i) {
368                if !nums.is_empty() {
369                    out.n_variables_total = Some(nums[0]);
370                }
371                if nums.len() >= 2 {
372                    out.n_continuous_vars = Some(nums[1]);
373                }
374                if nums.len() >= 3 {
375                    out.n_binary_vars = Some(nums[2]);
376                }
377                if nums.len() >= 4 {
378                    out.n_integer_vars = Some(nums[3]);
379                }
380            }
381        } else if line.contains("Nonzero counts") {
382            if let Some(nums) = next_int_line(&star_lines, i) {
383                if !nums.is_empty() {
384                    out.nnz_total = Some(nums[0]);
385                }
386                if nums.len() >= 2 {
387                    out.nnz_constant = Some(nums[1]);
388                }
389                if nums.len() >= 3 {
390                    out.nnz_nonlinear = Some(nums[2]);
391                }
392            }
393        }
394    }
395    out
396}
397
398/// Parsed `Solve <model> using <TYPE> [minimizing|maximizing] <objvar>;` line.
399#[derive(Debug, Clone, Serialize)]
400pub struct GmsSolveDirective {
401    pub model_name: String,
402    pub model_type: String,
403    pub direction: Option<String>,
404    pub objective_var: Option<String>,
405}
406
407/// Hand-rolled parser for the `Solve` directive. Case-insensitive,
408/// scans all lines for the first match.
409pub fn parse_gms_solve_directive(text: &str) -> Option<GmsSolveDirective> {
410    for line in text.lines() {
411        let lc = line.to_ascii_lowercase();
412        let trimmed = lc.trim_start();
413        if !trimmed.starts_with("solve") {
414            continue;
415        }
416        let tokens: Vec<&str> = line.split_whitespace().collect();
417        if tokens.len() < 4 {
418            continue;
419        }
420        // First token: "Solve" (matches case-insensitively).
421        if !tokens[0].eq_ignore_ascii_case("solve") {
422            continue;
423        }
424        let model_name = tokens[1]
425            .trim_end_matches(',')
426            .trim_end_matches(';')
427            .to_string();
428        // Expect "using" at tokens[2].
429        if !tokens[2].eq_ignore_ascii_case("using") {
430            continue;
431        }
432        let model_type = tokens[3]
433            .trim_end_matches(',')
434            .trim_end_matches(';')
435            .to_ascii_uppercase();
436
437        let mut direction: Option<String> = None;
438        let mut objective_var: Option<String> = None;
439        if tokens.len() >= 6 {
440            let d = tokens[4].to_ascii_lowercase();
441            if d == "minimizing" || d == "maximizing" {
442                direction = Some(d);
443                // Strip trailing `;` from objective var.
444                let mut v = tokens[5].to_string();
445                if let Some(s) = v.strip_suffix(';') {
446                    v = s.to_string();
447                }
448                objective_var = Some(v);
449            }
450        }
451        return Some(GmsSolveDirective {
452            model_name,
453            model_type,
454            direction,
455            objective_var,
456        });
457    }
458    None
459}
460
461/// Result of analyzing a `.gms` file.
462#[derive(Debug, Clone, Serialize)]
463pub struct GmsAnalysis {
464    pub path: String,
465    pub dimensions: GmsHeader,
466    pub solve_directive: Option<GmsSolveDirective>,
467    pub class: String,
468    pub supported_by_pounce: Option<bool>,
469    pub suggestions: Vec<Suggestion>,
470    pub warnings: Vec<String>,
471}
472
473pub fn analyze_gms(path: &str, text: &str) -> GmsAnalysis {
474    let dims = parse_gms_convert_header(text);
475    let solve = parse_gms_solve_directive(text);
476    let model_type = solve.as_ref().map(|s| s.model_type.as_str());
477
478    let mut warnings = Vec::new();
479    if dims.n_variables_total.is_none() && dims.n_equations_total.is_none() {
480        warnings.push(
481            "no `gams convert` header found — dimensions could not be parsed. \
482             POUNCE will still solve the model; the suggestion list is conservative."
483                .into(),
484        );
485    }
486    if solve.is_none() {
487        warnings.push("no `Solve` directive found in file — is this a complete model?".into());
488    }
489    if let Some(mt @ ("MINLP" | "MIP")) = model_type {
490        warnings.push(format!(
491            "model type {mt} is not supported by POUNCE (integer variables present).",
492        ));
493    }
494    if dims.n_binary_vars.unwrap_or(0) > 0 || dims.n_integer_vars.unwrap_or(0) > 0 {
495        warnings.push(
496            "discrete variables present; POUNCE solves the continuous relaxation only.".into(),
497        );
498    }
499
500    let supported = model_type.map(|t| matches!(t, "NLP" | "DNLP" | "RMINLP"));
501    GmsAnalysis {
502        path: path.into(),
503        class: classify_gms(model_type, &dims),
504        suggestions: suggest_gms(&dims, model_type),
505        solve_directive: solve,
506        dimensions: dims,
507        supported_by_pounce: supported,
508        warnings,
509    }
510}
511
512fn classify_gms(model_type: Option<&str>, dims: &GmsHeader) -> String {
513    let Some(mt) = model_type else {
514        return "unknown".into();
515    };
516    let base = match mt {
517        "NLP" => "nonlinear program (continuous)",
518        "DNLP" => "non-differentiable NLP",
519        "RMINLP" => "relaxed mixed-integer NLP",
520        "MINLP" => "mixed-integer NLP",
521        "LP" => "linear program",
522        "MIP" => "mixed-integer linear",
523        "QCP" => "quadratically constrained program",
524        "CNS" => "constrained nonlinear system",
525        _ => return format!("{mt} model"),
526    };
527    if matches!(mt, "NLP" | "DNLP") && dims.nnz_nonlinear == Some(0) {
528        format!("{base} (linear in nonzero pattern — should solve trivially)")
529    } else {
530        base.to_string()
531    }
532}
533
534fn suggest_gms(dims: &GmsHeader, model_type: Option<&str>) -> Vec<Suggestion> {
535    let mut out = Vec::new();
536    let nnl = dims.nnz_nonlinear.unwrap_or(0);
537    let nnz_total = dims.nnz_total.unwrap_or(1);
538
539    if let Some(mt @ ("MINLP" | "MIP")) = model_type {
540        out.push(Suggestion {
541            option: "(none)".into(),
542            value: "".into(),
543            why: format!(
544                "model type is {mt}; POUNCE handles only NLP/DNLP/RMINLP. Either relax \
545                 the integrality (RMINLP) or pick a different solver.",
546            ),
547        });
548        return out;
549    }
550
551    out.push(Suggestion {
552        option: "mu_strategy".into(),
553        value: "adaptive".into(),
554        why: "matches GAMS-IPOPT's effective default (optipopt.def). pounce's compile-time \
555              default is `monotone`, which stalls some hard NLPs."
556            .into(),
557    });
558    if nnl > 0 && (nnl as f64) > 0.5 * (nnz_total as f64) {
559        out.push(Suggestion {
560            option: "tol".into(),
561            value: "1e-6".into(),
562            why: "heavily nonlinear pattern: tightening below 1e-6 often leads to dual \
563                  stagnation on degenerate KKT systems."
564                .into(),
565        });
566    }
567    out
568}
569
570// ---- GAMS .lst SOLVE SUMMARY parsing --------------------------------
571
572/// Parsed fields from a GAMS listing's `S O L V E   S U M M A R Y` block.
573#[derive(Debug, Clone, Default, Serialize)]
574pub struct LstSummary {
575    pub model: Option<String>,
576    pub objective_var: Option<String>,
577    pub solver: Option<String>,
578    pub from_line: Option<i32>,
579    pub solver_status_code: Option<i32>,
580    pub solver_status: Option<String>,
581    pub model_status_code: Option<i32>,
582    pub model_status: Option<String>,
583    pub objective_value: Option<serde_json::Value>,
584    pub resource_used_secs: Option<serde_json::Value>,
585    pub resource_limit_secs: Option<f64>,
586    pub iteration_count: Option<serde_json::Value>,
587    pub iteration_limit: Option<i32>,
588    pub evaluation_errors: Option<serde_json::Value>,
589    pub solver_status_file: Option<String>,
590}
591
592/// Parse a GAMS `.lst` listing. Tolerant: missing fields stay None.
593pub fn parse_lst_summary(text: &str) -> LstSummary {
594    let mut out = LstSummary::default();
595
596    for line in text.lines() {
597        let trimmed = line.trim_start();
598        // MODEL <name>  ... OBJECTIVE <var>
599        if let Some(rest) = trimmed.strip_prefix_ignore_ascii_case_re("MODEL") {
600            // Look for "MODEL <name> ... OBJECTIVE <var>"
601            let toks: Vec<&str> = rest.split_whitespace().collect();
602            if !toks.is_empty() {
603                out.model.get_or_insert(toks[0].to_string());
604            }
605            if let Some(idx) = toks
606                .iter()
607                .position(|t| t.eq_ignore_ascii_case("OBJECTIVE"))
608            {
609                if let Some(v) = toks.get(idx + 1) {
610                    out.objective_var.get_or_insert(v.to_string());
611                }
612            }
613        }
614        if let Some(rest) = trimmed.strip_prefix_ignore_ascii_case_re("SOLVER") {
615            // "SOLVER <name> FROM LINE <n>"
616            let toks: Vec<&str> = rest.split_whitespace().collect();
617            if !toks.is_empty() && out.solver.is_none() {
618                out.solver = Some(toks[0].to_string());
619            }
620            if let Some(idx) = toks.iter().position(|t| t.eq_ignore_ascii_case("LINE")) {
621                if let Some(v) = toks.get(idx + 1) {
622                    if let Ok(n) = v.parse::<i32>() {
623                        out.from_line.get_or_insert(n);
624                    }
625                }
626            }
627        }
628        // **** SOLVER STATUS  N  <text>
629        if let Some(rest) = line.strip_prefix("**** SOLVER STATUS") {
630            let toks: Vec<&str> = rest.split_whitespace().collect();
631            if let Some(code) = toks.first().and_then(|s| s.parse::<i32>().ok()) {
632                out.solver_status_code = Some(code);
633                if toks.len() > 1 {
634                    out.solver_status = Some(toks[1..].join(" "));
635                }
636            }
637        }
638        if let Some(rest) = line.strip_prefix("**** MODEL STATUS") {
639            let toks: Vec<&str> = rest.split_whitespace().collect();
640            if let Some(code) = toks.first().and_then(|s| s.parse::<i32>().ok()) {
641                out.model_status_code = Some(code);
642                if toks.len() > 1 {
643                    out.model_status = Some(toks[1..].join(" "));
644                }
645            }
646        }
647        if let Some(rest) = line.strip_prefix("**** OBJECTIVE VALUE") {
648            if let Some(v) = rest.split_whitespace().next() {
649                let val = v
650                    .parse::<f64>()
651                    .map(serde_json::Value::from)
652                    .unwrap_or_else(|_| serde_json::Value::String(v.into()));
653                out.objective_value = Some(val);
654            }
655        }
656        if let Some(rest) = trimmed.strip_prefix_ignore_ascii_case_re("RESOURCE USAGE, LIMIT") {
657            let toks: Vec<&str> = rest.split_whitespace().collect();
658            if let Some(v) = toks.first() {
659                let val = v
660                    .parse::<f64>()
661                    .map(serde_json::Value::from)
662                    .unwrap_or_else(|_| serde_json::Value::String((*v).to_string()));
663                out.resource_used_secs = Some(val);
664            }
665            if let Some(v) = toks.get(1) {
666                if let Ok(n) = v.parse::<f64>() {
667                    out.resource_limit_secs.get_or_insert(n);
668                }
669            }
670        }
671        if let Some(rest) = trimmed.strip_prefix_ignore_ascii_case_re("ITERATION COUNT, LIMIT") {
672            let toks: Vec<&str> = rest.split_whitespace().collect();
673            if let Some(v) = toks.first() {
674                let val = v
675                    .parse::<i64>()
676                    .map(serde_json::Value::from)
677                    .unwrap_or_else(|_| serde_json::Value::String((*v).to_string()));
678                out.iteration_count = Some(val);
679            }
680            if let Some(v) = toks.get(1) {
681                if let Ok(n) = v.parse::<i32>() {
682                    out.iteration_limit.get_or_insert(n);
683                }
684            }
685        }
686        if let Some(rest) = trimmed.strip_prefix_ignore_ascii_case_re("EVALUATION ERRORS") {
687            let toks: Vec<&str> = rest.split_whitespace().collect();
688            if let Some(v) = toks.first() {
689                let val = v
690                    .parse::<i64>()
691                    .map(serde_json::Value::from)
692                    .unwrap_or_else(|_| serde_json::Value::String((*v).to_string()));
693                out.evaluation_errors = Some(val);
694            }
695        }
696    }
697
698    // Embedded solver-status block. Two formats:
699    //   (a) `=C ...` between SOLVER STATUS FILE LISTED BELOW / ABOVE
700    //   (b) lines after `--- POUNCE` and before `---- ` / `EXECUTION TIME`
701    let mut block: Vec<String> = Vec::new();
702    let mut in_block = false;
703    for line in text.lines() {
704        if line.contains("SOLVER STATUS FILE LISTED BELOW") {
705            in_block = true;
706            continue;
707        }
708        if line.contains("SOLVER STATUS FILE LISTED ABOVE") {
709            in_block = false;
710            continue;
711        }
712        if in_block && line.starts_with("=C") {
713            block.push(line[2..].trim_end().to_string());
714        }
715    }
716    if block.is_empty() {
717        let mut capturing = false;
718        for line in text.lines() {
719            if !capturing && line.starts_with("--- POUNCE") {
720                capturing = true;
721            }
722            if capturing {
723                if line.starts_with("---- ") || line.starts_with("EXECUTION TIME") {
724                    break;
725                }
726                block.push(line.trim_end().to_string());
727            }
728        }
729    }
730    if !block.is_empty() {
731        out.solver_status_file = Some(block.join("\n").trim_end().to_string());
732    }
733
734    out
735}
736
737trait StripPrefixCi {
738    fn strip_prefix_ignore_ascii_case_re<'a>(&'a self, prefix: &str) -> Option<&'a str>;
739}
740
741impl StripPrefixCi for str {
742    fn strip_prefix_ignore_ascii_case_re<'a>(&'a self, prefix: &str) -> Option<&'a str> {
743        if self.len() < prefix.len() {
744            return None;
745        }
746        let (head, tail) = self.split_at(prefix.len());
747        if head.eq_ignore_ascii_case(prefix) {
748            Some(tail)
749        } else {
750            None
751        }
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn nl_header_basic() {
761        let bytes = b"g3 1 1 0  # problem foo\n 5 3 1 0 2  # vars cons obj range eq\n 1 1  # nlc nlo\nplaceholder\n 2 1 0  # nlvc nlvo nlvb\n";
762        let h = parse_nl_header(bytes);
763        assert_eq!(h.format, "text");
764        assert_eq!(h.n_variables, Some(5));
765        assert_eq!(h.n_constraints, Some(3));
766        assert_eq!(h.n_nonlinear_constraints, Some(1));
767    }
768
769    #[test]
770    fn nl_header_empty_file() {
771        let h = parse_nl_header(b"");
772        assert_eq!(h.format, "unknown");
773        assert!(h.warnings.iter().any(|w| w.contains("empty")));
774    }
775
776    #[test]
777    fn nl_header_binary_short_circuits() {
778        let h = parse_nl_header(b"b3 1 1 0\nignored\n");
779        assert_eq!(h.format, "binary");
780    }
781
782    #[test]
783    fn gms_solve_directive_simple() {
784        let text = "Variables x, z;\nEquation foo;\n\nSolve mymodel using NLP minimizing z;\n";
785        let d = parse_gms_solve_directive(text).expect("should parse");
786        assert_eq!(d.model_name, "mymodel");
787        assert_eq!(d.model_type, "NLP");
788        assert_eq!(d.direction.as_deref(), Some("minimizing"));
789        assert_eq!(d.objective_var.as_deref(), Some("z"));
790    }
791
792    #[test]
793    fn gms_solve_directive_lower_case() {
794        let text = "solve hs071 using nlp minimizing obj ;\n";
795        let d = parse_gms_solve_directive(text).expect("should parse");
796        assert_eq!(d.model_type, "NLP");
797        assert_eq!(d.direction.as_deref(), Some("minimizing"));
798    }
799
800    #[test]
801    fn gms_convert_header_counts() {
802        let text = "* Equation counts\n*    Total       E       G       L       N       X\n*       10       8       1       1       0       0\n* Variable counts\n*    Total    cont  binary integer    sos1    sos2   scont    sint\n*       12      11       0       1       0       0       0       0\n* Nonzero counts\n*    Total   const      NL     DLL\n*       30      20      10       0\n";
803        let h = parse_gms_convert_header(text);
804        assert_eq!(h.n_equations_total, Some(10));
805        assert_eq!(h.n_equality_eqs, Some(8));
806        assert_eq!(h.n_variables_total, Some(12));
807        assert_eq!(h.n_integer_vars, Some(1));
808        assert_eq!(h.nnz_nonlinear, Some(10));
809    }
810
811    #[test]
812    fn lst_summary_parses_status() {
813        let text = "                MODEL   m       OBJECTIVE  z\n                SOLVER  POUNCE  FROM LINE  10\n**** SOLVER STATUS     1 Normal Completion\n**** MODEL STATUS      2 Locally Optimal\n**** OBJECTIVE VALUE  3.14159\n RESOURCE USAGE, LIMIT  0.123  1000.000\n ITERATION COUNT, LIMIT  42  5000\n EVALUATION ERRORS  0  0\n";
814        let s = parse_lst_summary(text);
815        assert_eq!(s.solver_status_code, Some(1));
816        assert_eq!(s.model_status_code, Some(2));
817        assert_eq!(s.iteration_limit, Some(5000));
818    }
819}