pounce-studio-core 0.7.0

Pure-Rust parsers and analysis helpers for pounce solve reports and POUNCEIT iter-dumps.
Documentation
//! Round-trip tests against the same JSON fixtures the MCP server uses.
//!
//! The fixtures live in `studio/mcp/fixtures/` and were generated by
//! running the `pounce` CLI on the bundled builtins with
//! `--json-detail full`. Tests embed them via `include_str!` so they
//! travel with the test binary (no working-directory assumptions).

use pounce_studio_core::analysis::{
    compare_runs, convergence_trace, diagnose, find_stalls, get_iterate, restoration_windows,
    summarize, Severity,
};
use pounce_studio_core::iter_dump::IterDumpTrace;
use pounce_studio_core::markdown::render_inspect;
use pounce_studio_core::report::{Error, InputDescriptor, SolveReport, SOLVE_REPORT_SCHEMA};

const ROSENBROCK: &str = include_str!("../../../studio/mcp/fixtures/rosenbrock.json");
const STALLED: &str = include_str!("../../../studio/mcp/fixtures/rosenbrock-stalled.json");
const QUADRATIC: &str = include_str!("../../../studio/mcp/fixtures/quadratic.json");
const CIRCLE: &str = include_str!("../../../studio/mcp/fixtures/circle.json");
const EQ_TRACE: &[u8] = include_bytes!("../../../studio/mcp/fixtures/eq-quadratic.iterdump");

#[test]
fn fixtures_round_trip_schema() {
    for (name, src) in [
        ("rosenbrock", ROSENBROCK),
        ("rosenbrock-stalled", STALLED),
        ("quadratic", QUADRATIC),
        ("circle", CIRCLE),
    ] {
        let r = SolveReport::from_json_str(src).unwrap_or_else(|e| panic!("{name}: {e}"));
        assert_eq!(r.schema, SOLVE_REPORT_SCHEMA, "{name}");
    }
}

#[test]
fn loads_cbf_file_input_descriptor() {
    // M36: the writer (`pounce-solve-report`) emits a `CbfFile` input variant
    // (`"kind": "cbf-file"`) for `.cbf` conic instances, but this reader's
    // mirror enum was missing it, so serde's internally-tagged enum hard-failed
    // and the *whole* report was rejected. Rewrite a good fixture's input to a
    // cbf-file descriptor and confirm it now loads and decodes to `CbfFile`.
    let mut v: serde_json::Value = serde_json::from_str(ROSENBROCK).unwrap();
    v["fair_metadata"]["input"] = serde_json::json!({
        "kind": "cbf-file",
        "path": "/tmp/cblib/instance.cbf",
        "size_bytes": 4096,
    });
    let src = serde_json::to_string(&v).unwrap();

    let r = SolveReport::from_json_str(&src).expect("cbf-file report must load");
    match r.fair_metadata.input {
        InputDescriptor::CbfFile { path, size_bytes } => {
            assert_eq!(path, "/tmp/cblib/instance.cbf");
            assert_eq!(size_bytes, Some(4096));
        }
        other => panic!("expected CbfFile, got {other:?}"),
    }
}

#[test]
fn rejects_wrong_schema() {
    let bogus = r#"{"schema":"other/v1"}"#;
    let err = SolveReport::from_json_str(bogus).expect_err("should fail");
    assert!(matches!(err, Error::SchemaMismatch { .. }));
}

#[test]
fn rosenbrock_summary_matches_known_outcome() {
    let r = SolveReport::from_json_str(ROSENBROCK).unwrap();
    let s = summarize(&r);
    assert_eq!(s.status, "SolveSucceeded");
    assert!(s.iteration_count >= 1);
    assert!(s.iterations_captured >= 1);
    assert_eq!(s.restoration_calls, 0);
    assert_eq!(s.n_variables, 2);
}

#[test]
fn convergence_trace_columns_aligned() {
    let r = SolveReport::from_json_str(ROSENBROCK).unwrap();
    let t = convergence_trace(&r);
    let n = t.iter.len();
    assert_eq!(t.objective.len(), n);
    assert_eq!(t.inf_pr.len(), n);
    assert_eq!(t.alpha_primal_char.len(), n);
}

#[test]
fn diagnose_success_includes_converged() {
    let r = SolveReport::from_json_str(ROSENBROCK).unwrap();
    let findings = diagnose(&r);
    assert!(
        findings.iter().any(|f| f.code == "converged"),
        "missing 'converged' in {findings:?}",
    );
    // No noisy stall warning on a clean run.
    assert!(
        !findings.iter().any(|f| f.code == "convergence_stall"),
        "stall should be suppressed on clean convergence: {findings:?}",
    );
}

#[test]
fn diagnose_stalled_includes_max_iter_error() {
    let r = SolveReport::from_json_str(STALLED).unwrap();
    let findings = diagnose(&r);
    assert!(findings
        .iter()
        .any(|f| { f.code == "max_iter_exceeded" && matches!(f.severity, Severity::Error) }));
}

#[test]
fn get_iterate_returns_first_row_with_derived_log10() {
    let r = SolveReport::from_json_str(ROSENBROCK).unwrap();
    let iter0 = get_iterate(&r, 0).unwrap();
    assert_eq!(iter0.raw.iter, 0);
    // mu starts at the default mu_init = 0.1, so log10 should be defined.
    assert!(iter0.log10_mu.is_some());
}

#[test]
fn restoration_windows_empty_on_clean_run() {
    let r = SolveReport::from_json_str(ROSENBROCK).unwrap();
    assert!(restoration_windows(&r).is_empty());
}

#[test]
fn find_stalls_runs_without_panic() {
    let r = SolveReport::from_json_str(ROSENBROCK).unwrap();
    let _ = find_stalls(&r); // any count is fine; non-panic is the test
}

#[test]
fn compare_runs_aligns_two_reports() {
    let a = SolveReport::from_json_str(ROSENBROCK).unwrap();
    let b = SolveReport::from_json_str(STALLED).unwrap();
    let rows = compare_runs([("ok", &a), ("stalled", &b)]);
    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0].label, "ok");
    assert_eq!(rows[1].label, "stalled");
    assert_eq!(rows[0].status, "SolveSucceeded");
    assert_eq!(rows[1].status, "MaximumIterationsExceeded");
}

#[test]
fn iter_dump_parses_real_solver_trace() {
    let trace = IterDumpTrace::from_bytes(EQ_TRACE).expect("parse");
    assert_eq!(trace.header.format_version, 1);
    assert_eq!(trace.header.name, "eq-quadratic");
    assert!(!trace.records.is_empty());
    // First iter should expose at least the primal variables.
    assert_eq!(trace.records[0].x.len() as u32, trace.header.n);
}

#[test]
fn markdown_renders_full_report() {
    let r = SolveReport::from_json_str(ROSENBROCK).unwrap();
    let md = render_inspect(&r);
    assert!(md.contains("# Pounce solve report"));
    assert!(md.contains("## Findings"));
    assert!(md.contains("## Convergence trajectory"));
    // Final iter row should appear in the table.
    assert!(md.contains("converged"));
}