1use serde::Serialize;
9
10#[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
60pub fn builtin(name: &str) -> Option<&'static BuiltinInfo> {
62 BUILTINS.iter().find(|b| b.name == name)
63}
64
65pub fn all_builtins() -> Vec<&'static BuiltinInfo> {
67 BUILTINS.iter().collect()
68}
69
70#[derive(Debug, Clone, Serialize)]
74pub struct Suggestion {
75 pub option: String,
76 pub value: String,
77 pub why: String,
78}
79
80#[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
101pub 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#[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
182pub 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
199pub 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#[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
324pub 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#[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
407pub 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 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 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 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#[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#[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
592pub 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 if let Some(rest) = trimmed.strip_prefix_ignore_ascii_case_re("MODEL") {
600 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 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 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 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}