"""Constraint residual checks and LaTeX compilation.""" from __future__ import annotations import shutil import subprocess from pathlib import Path from statement_to_tikz.ir import GeometryIR, SolvedScene from statement_to_tikz.solve import evaluate_residuals def residual_report(scene: SolvedScene, tol: float = 1e-4) -> dict: residuals = evaluate_residuals(scene.ir, scene.coordinates) max_r = max(residuals) if residuals else 0.0 return { "max_residual": max_r, "residuals": residuals, "within_tol": max_r <= tol, "tol": tol, "mode": scene.mode.value, "message": scene.message, } def compile_tex( tex_path: Path, *, engine: str = "pdflatex", timeout: float = 60.0, ) -> dict: """Compile a .tex file in its directory. Returns status dict.""" tex_path = Path(tex_path) if not tex_path.is_file(): return {"ok": False, "error": f"missing file: {tex_path}"} latexmk = shutil.which("latexmk") pdflatex = shutil.which(engine) cwd = tex_path.parent if latexmk: cmd = [ latexmk, "-pdf", f"-{engine}", "-interaction=nonstopmode", "-halt-on-error", tex_path.name, ] elif pdflatex: cmd = [ engine, "-interaction=nonstopmode", "-halt-on-error", tex_path.name, ] else: return { "ok": False, "error": f"neither latexmk nor {engine} found on PATH", "skipped": True, } try: proc = subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired: return {"ok": False, "error": "compile timeout"} pdf = tex_path.with_suffix(".pdf") ok = proc.returncode == 0 and pdf.is_file() return { "ok": ok, "returncode": proc.returncode, "pdf": str(pdf) if pdf.is_file() else None, "stdout_tail": (proc.stdout or "")[-2000:], "stderr_tail": (proc.stderr or "")[-1000:], } def verify_scene( scene: SolvedScene, tex_path: Path | None = None, *, tol: float = 1e-4, compile: bool = True, ) -> dict: report = residual_report(scene, tol=tol) report["compile"] = None if compile and tex_path is not None: report["compile"] = compile_tex(tex_path) return report def check_ir_consistency(ir: GeometryIR) -> list[str]: """Return list of soft warnings about IR consistency.""" names = set(ir.point_names()) warnings: list[str] = [] for seg in ir.segments: for p in (seg.a, seg.b): if p not in names: warnings.append(f"segment references unknown point {p!r}") for c in ir.constraints: ctype = c.type fields = c.model_dump() for key, val in fields.items(): if key in ("type", "value", "degrees"): continue if isinstance(val, str) and key not in ("circle",) and len(val) <= 8: # heuristic: short strings that look like point names if key in ( "a", "b", "a1", "a2", "b1", "b2", "v1", "v2", "vertex", "point", "c", "d", ): if val not in names: warnings.append(f"constraint {ctype} unknown point {val!r}") if key == "points" and isinstance(val, list): for p in val: if p not in names: warnings.append(f"constraint {ctype} unknown point {p!r}") if key == "circle" and isinstance(val, str): circ_ids = {ci.id for ci in ir.circles} if val not in circ_ids: warnings.append(f"on_circle unknown circle {val!r}") return warnings