openFOAM-RANS-to-GPU/scripts/update_loop_diagnostic_context.py

496 lines
22 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Generate concise loop context from GPU RANS verifier reports."""
from __future__ import annotations
import argparse
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONTEXT = ROOT / ".loop/diagnostic-context.md"
DEFAULT_BASELINE = ROOT / ".loop/diagnostic-baseline.json"
REPORT_GLOBS = (
"tmp/**/verifier_report.json",
"tmp/**/report.json",
)
TMP_REPORT_GLOBS = (
"*gpu*/report.json",
"*gpu*/verifier_report.json",
"worker_gpu_rans_solver*/report.json",
"judge_gpu_rans_solver*/report.json",
"*gpu*rans*/report.json",
"*gpu*rans*/verifier_report.json",
)
def load_json(path: Path) -> Any | None:
try:
return json.loads(path.read_text())
except Exception:
return None
def is_verifier_report(value: Any) -> bool:
if not isinstance(value, Mapping):
return False
return any(key in value for key in ("verifier_evidence", "first_divergence_summary", "artifact_comparisons", "differential_trace"))
def is_gpu_report(value: Mapping[str, Any], path: Path) -> bool:
backend = value.get("backend") if isinstance(value.get("backend"), Mapping) else {}
requested = str(backend.get("requested") or "").lower()
selected = str(backend.get("selected") or "").lower()
return requested == "gpu" or selected == "gpu" or "gpu" in path.parent.name.lower()
def discover_latest_report(root: Path) -> tuple[Path | None, Mapping[str, Any] | None]:
candidates: list[Path] = []
for pattern in REPORT_GLOBS:
candidates.extend(root.glob(pattern))
tmp_root = Path("/tmp")
if tmp_root.exists():
for pattern in TMP_REPORT_GLOBS:
candidates.extend(tmp_root.glob(pattern))
unique = sorted({path.resolve() for path in candidates if path.is_file()}, key=lambda path: path.stat().st_mtime, reverse=True)
reports: list[tuple[Path, Mapping[str, Any]]] = []
for path in unique:
data = load_json(path)
if is_verifier_report(data):
reports.append((path, data)) # type: ignore[arg-type]
if not reports:
return None, None
gpu_reports = [(path, data) for path, data in reports if is_gpu_report(data, path)]
return (gpu_reports or reports)[0]
def get_path(value: Mapping[str, Any], path: str) -> Any:
cursor: Any = value
for part in path.split("."):
if not isinstance(cursor, Mapping):
return None
cursor = cursor.get(part)
return cursor
def finite_number(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def fmt(value: Any) -> str:
number = finite_number(value)
if number is None:
if value is True:
return "true"
if value is False:
return "false"
if value is None:
return "-"
return str(value)
if number == 0.0:
return "0"
if abs(number) >= 1e4 or abs(number) < 1e-3:
return f"{number:.3e}"
return f"{number:.6g}"
def status_word(value: Any) -> str:
if value is True:
return "passed"
if value is False:
return "failed"
return str(value or "unknown")
def first_nested_key(value: Any, target: str, path: str = "") -> tuple[str, Mapping[str, Any]] | None:
if isinstance(value, Mapping):
for key, item in value.items():
next_path = f"{path}.{key}" if path else str(key)
if key == target and isinstance(item, Mapping):
return next_path, item
found = first_nested_key(item, target, next_path)
if found is not None:
return found
elif isinstance(value, list):
for index, item in enumerate(value):
found = first_nested_key(item, target, f"{path}[{index}]")
if found is not None:
return found
return None
def artifact_checks(report: Mapping[str, Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
comparisons = report.get("artifact_comparisons", {}) if isinstance(report.get("artifact_comparisons"), Mapping) else {}
for family in ("pressure_inputs", "matrix_operator", "solver"):
family_report = comparisons.get(family) if isinstance(comparisons.get(family), Mapping) else {}
for check in family_report.get("checks", []) if isinstance(family_report.get("checks"), list) else []:
if not isinstance(check, Mapping):
continue
largest = check.get("largest_difference") if isinstance(check.get("largest_difference"), Mapping) else {}
location = largest.get("location") if isinstance(largest.get("location"), Mapping) else {}
out.append(
{
"family": family,
"name": check.get("name"),
"allclose": check.get("allclose"),
"reason": check.get("reason"),
"max_abs": check.get("max_abs"),
"mean_abs": check.get("mean_abs"),
"rms_abs": check.get("rms_abs"),
"location": location,
}
)
out.sort(key=lambda item: (item.get("allclose") is True, item["family"], str(item.get("name"))))
return out
def field_checks(report: Mapping[str, Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
modes = report.get("modes", {}) if isinstance(report.get("modes"), Mapping) else {}
for mode_name in ("run_one", "split"):
mode = modes.get(mode_name) if isinstance(modes.get(mode_name), Mapping) else {}
comparisons = mode.get("comparisons", {}) if isinstance(mode.get("comparisons"), Mapping) else {}
for field, data in comparisons.items():
if not isinstance(data, Mapping):
continue
out.append(
{
"mode": mode_name,
"field": field,
"allclose": data.get("allclose"),
"max_abs": data.get("max_abs"),
"mean_abs": data.get("mean_abs"),
"rms_abs": data.get("rms_abs"),
"location": data.get("location"),
}
)
out.sort(key=lambda item: (item.get("allclose") is True, item["mode"], str(item.get("field"))))
return out
def differential_trace_checks(report: Mapping[str, Any]) -> list[dict[str, Any]]:
trace = report.get("differential_trace", {}) if isinstance(report.get("differential_trace"), Mapping) else {}
checks = trace.get("checkpoints", []) if isinstance(trace.get("checkpoints"), list) else []
out: list[dict[str, Any]] = []
for check in checks:
if not isinstance(check, Mapping):
continue
largest = check.get("largest_difference") if isinstance(check.get("largest_difference"), Mapping) else {}
location = largest.get("location") if isinstance(largest.get("location"), Mapping) else {}
metadata = check.get("metadata") if isinstance(check.get("metadata"), Mapping) else {}
diff = check.get("difference_stats") if isinstance(check.get("difference_stats"), Mapping) else {}
out.append(
{
"name": check.get("name"),
"status": check.get("status"),
"reason": check.get("reason"),
"family": metadata.get("family"),
"lifecycle_phase": metadata.get("lifecycle_phase"),
"substitution_supported": metadata.get("substitution_supported"),
"max_abs": diff.get("max_abs"),
"mean_abs": diff.get("mean_abs"),
"rms_abs": diff.get("rms_abs"),
"location": location,
}
)
out.sort(key=lambda item: (item.get("status") == "passed", str(item.get("name"))))
return out
def extract_metrics(report: Mapping[str, Any]) -> dict[str, Any]:
metrics: dict[str, Any] = {
"status.passed": report.get("status") == "passed",
"verifier.passed": get_path(report, "verifier_evidence.passed") is True,
}
first = report.get("first_divergence_summary") if isinstance(report.get("first_divergence_summary"), Mapping) else {}
if first:
metrics["first.target"] = first.get("first_target")
metrics["first.family"] = first.get("artifact_family")
metrics["first.stage_group"] = first.get("stage_group")
trace = report.get("differential_trace") if isinstance(report.get("differential_trace"), Mapping) else {}
if trace:
first_trace = trace.get("first_divergence") if isinstance(trace.get("first_divergence"), Mapping) else {}
metrics["trace.status"] = trace.get("status")
metrics["trace.first_checkpoint"] = first_trace.get("name") if first_trace else None
metrics["trace.failed_count"] = finite_number(trace.get("failed_count")) or 0
metrics["trace.missing_count"] = finite_number(trace.get("missing_count")) or 0
if first_trace:
for key in ("max_abs", "mean_abs", "rms_abs"):
number = finite_number(first_trace.get(key))
if number is not None:
metrics[f"trace.first.{key}"] = number
for check in artifact_checks(report):
base = f"artifact.{check['family']}.{check['name']}"
metrics[f"{base}.allclose"] = check.get("allclose") is True
for key in ("max_abs", "mean_abs", "rms_abs"):
number = finite_number(check.get(key))
if number is not None:
metrics[f"{base}.{key}"] = number
for check in field_checks(report):
base = f"field.{check['mode']}.{check['field']}"
metrics[f"{base}.allclose"] = check.get("allclose") is True
for key in ("max_abs", "mean_abs", "rms_abs"):
number = finite_number(check.get(key))
if number is not None:
metrics[f"{base}.{key}"] = number
preconditioner = first_nested_key(report, "preconditioner_diagnostic")
if preconditioner is not None:
_, data = preconditioner
for key in (
"gpu_dilu_vs_reference_delta_l2",
"diagonal_vs_reference_delta_l2",
):
number = finite_number(data.get(key))
if number is not None:
metrics[f"preconditioner.{key}"] = number
for path, metric_name in (
("gpu_dilu_vs_openfoam_reference.max_abs", "preconditioner.gpu_dilu_vs_reference.max_abs"),
("gpu_dilu_vs_openfoam_reference.rms_abs", "preconditioner.gpu_dilu_vs_reference.rms_abs"),
("diagonal_vs_openfoam_reference.max_abs", "preconditioner.diagonal_vs_reference.max_abs"),
("diagonal_vs_openfoam_reference.rms_abs", "preconditioner.diagonal_vs_reference.rms_abs"),
):
number = finite_number(get_path(data, path))
if number is not None:
metrics[metric_name] = number
return metrics
def classify_delta(current: Mapping[str, Any], baseline: Mapping[str, Any] | None) -> list[dict[str, Any]]:
if not baseline:
return [{"metric": name, "status": "newly_available", "current": value, "baseline": None} for name, value in sorted(current.items())]
out: list[dict[str, Any]] = []
previous = baseline.get("metrics", {}) if isinstance(baseline.get("metrics"), Mapping) else {}
for name, value in sorted(current.items()):
old = previous.get(name)
status = "newly_available"
if old is not None:
if isinstance(value, bool) and isinstance(old, bool):
if value == old:
status = "unchanged"
elif value and not old:
status = "improved"
else:
status = "regressed"
else:
now_num = finite_number(value)
old_num = finite_number(old)
if now_num is not None and old_num is not None:
tolerance = max(1e-15, abs(old_num) * 1e-9)
if abs(now_num - old_num) <= tolerance:
status = "unchanged"
elif now_num < old_num:
status = "improved"
else:
status = "regressed"
else:
status = "unchanged" if value == old else "changed"
out.append({"metric": name, "status": status, "current": value, "baseline": old})
return out
def metric_priority(item: Mapping[str, Any]) -> tuple[int, str]:
status_order = {"regressed": 0, "improved": 1, "newly_available": 2, "changed": 3, "unchanged": 4}
return status_order.get(str(item.get("status")), 9), str(item.get("metric"))
def render_location(location: Any) -> str:
if not isinstance(location, Mapping):
return "-"
entity = location.get("entity_kind") or "array"
index = location.get("entity_index")
component = location.get("component_index")
if component is None:
return f"{entity}[{index}]"
return f"{entity}[{index}] component={component}"
def render_context(report_path: Path | None, report: Mapping[str, Any] | None, baseline: Mapping[str, Any] | None) -> str:
generated = datetime.now(timezone.utc).isoformat()
if report is None or report_path is None:
return "\n".join(
[
"# GPU RANS Loop Diagnostic Context",
"",
f"Generated: {generated}",
"",
"No verifier report was found under repo tmp/ or /tmp GPU RANS work directories.",
"Next action: run `scripts/verify_gpu_rans_solver.sh --work <work> --report <work>/report.json` or the focused verifier, then rerun this hook.",
"",
]
)
first = report.get("first_divergence_summary") if isinstance(report.get("first_divergence_summary"), Mapping) else {}
artifacts = report.get("intermediate_artifacts", {}) if isinstance(report.get("intermediate_artifacts"), Mapping) else {}
families = artifacts.get("families", []) if isinstance(artifacts.get("families"), list) else []
metrics = extract_metrics(report)
deltas = classify_delta(metrics, baseline)
checks = artifact_checks(report)
fields = field_checks(report)
trace = report.get("differential_trace", {}) if isinstance(report.get("differential_trace"), Mapping) else {}
trace_first = trace.get("first_divergence") if isinstance(trace.get("first_divergence"), Mapping) else {}
trace_substitution = trace.get("substitution") if isinstance(trace.get("substitution"), Mapping) else {}
trace_checks = differential_trace_checks(report)
preconditioner = first_nested_key(report, "preconditioner_diagnostic")
solver_trace = first_nested_key(report, "linear_solver_trace")
lines = [
"# GPU RANS Loop Diagnostic Context",
"",
f"Generated: {generated}",
f"Latest report: `{report_path}`",
f"Report status: `{report.get('status')}`",
f"Verifier evidence passed: `{get_path(report, 'verifier_evidence.passed')}`",
"",
"## First divergence",
"",
f"- Target: `{first.get('first_target') if first else None}`",
f"- Artifact family: `{first.get('artifact_family') if first else None}`",
f"- Stage group: `{first.get('stage_group') if first else None}`",
f"- Evidence path: `{first.get('evidence_path') if first else None}`",
f"- Field/reason: `{first.get('field') if first else None}` / `{first.get('reason') if first else None}`",
"",
"## Differential trace",
"",
f"- Status: `{trace.get('status') if trace else None}`",
f"- First checkpoint: `{trace_first.get('name') if trace_first else None}`",
f"- Lifecycle: `{get_path(trace_first, 'metadata.lifecycle_phase') if trace_first else None}`",
f"- max_abs / rms_abs: `{fmt(trace_first.get('max_abs') if trace_first else None)}` / `{fmt(trace_first.get('rms_abs') if trace_first else None)}`",
f"- Substitution requested/loaded: `{trace_substitution.get('requested') if trace_substitution else []}` / `{trace_substitution.get('loaded') if trace_substitution else []}`",
f"- Source artifacts: reference=`{get_path(trace, 'roles.reference.path') if trace else None}`, candidate=`{get_path(trace, 'roles.candidate.path') if trace else None}`",
"",
"| Checkpoint | Status | Lifecycle | max_abs | rms_abs | Location | Substitute? |",
"|---|---:|---|---:|---:|---|---:|",
]
for check in trace_checks[:12]:
lines.append(
"| {name} | {status} | {phase} | {max_abs} | {rms_abs} | {location} | {substitute} |".format(
name=check.get("name"),
status=check.get("status"),
phase=check.get("lifecycle_phase"),
max_abs=fmt(check.get("max_abs")),
rms_abs=fmt(check.get("rms_abs")),
location=render_location(check.get("location")),
substitute=status_word(check.get("substitution_supported")),
)
)
lines.extend(["", "## Solver phase evidence", "", "| Family | Status | Why |", "|---|---:|---|"])
for family in families:
if not isinstance(family, Mapping):
continue
lines.append(f"| {family.get('name')} | {family.get('status')} | {family.get('why')} |")
lines.extend(["", "## Numeric artifact comparisons", "", "| Family | Check | Status | max_abs | mean_abs | rms_abs | Location |", "|---|---|---:|---:|---:|---:|---|"])
for check in checks[:16]:
lines.append(
"| {family} | {name} | {status} | {max_abs} | {mean_abs} | {rms_abs} | {location} |".format(
family=check.get("family"),
name=check.get("name"),
status=status_word(check.get("allclose")),
max_abs=fmt(check.get("max_abs")),
mean_abs=fmt(check.get("mean_abs")),
rms_abs=fmt(check.get("rms_abs")),
location=render_location(check.get("location")),
)
)
lines.extend(["", "## Field comparison symptoms", "", "| Mode | Field | Status | max_abs | mean_abs | rms_abs | Location |", "|---|---|---:|---:|---:|---:|---|"])
for check in fields[:12]:
lines.append(
"| {mode} | {field} | {status} | {max_abs} | {mean_abs} | {rms_abs} | {location} |".format(
mode=check.get("mode"),
field=check.get("field"),
status=status_word(check.get("allclose")),
max_abs=fmt(check.get("max_abs")),
mean_abs=fmt(check.get("mean_abs")),
rms_abs=fmt(check.get("rms_abs")),
location=render_location(check.get("location")),
)
)
lines.extend(["", "## Linear solver and preconditioner trace", ""])
if preconditioner is None:
lines.append("No preconditioner diagnostic artifact found in the latest report.")
else:
path, data = preconditioner
lines.extend(
[
f"Preconditioner evidence path: `{path}`",
f"- GPU DILU vs OpenFOAM reference max_abs: `{fmt(get_path(data, 'gpu_dilu_vs_openfoam_reference.max_abs'))}`",
f"- GPU DILU vs OpenFOAM reference rms_abs: `{fmt(get_path(data, 'gpu_dilu_vs_openfoam_reference.rms_abs'))}`",
f"- Diagonal/current vs OpenFOAM reference max_abs: `{fmt(get_path(data, 'diagonal_vs_openfoam_reference.max_abs'))}`",
f"- Residual entering preconditioner recorded: `{data.get('residual_entering_preconditioner') is not None}`",
]
)
if solver_trace is not None:
path, data = solver_trace
lines.append(f"Solver trace path: `{path}`")
for point in data.get("trace_points", []) if isinstance(data.get("trace_points"), list) else []:
if isinstance(point, Mapping):
lines.append(f"- `{point.get('name')}`: {point.get('step')}")
lines.extend(["", "## Delta versus retained baseline", "", "| Metric | Status | Current | Baseline |", "|---|---:|---:|---:|"])
for item in sorted(deltas, key=metric_priority)[:24]:
lines.append(f"| `{item.get('metric')}` | {item.get('status')} | {fmt(item.get('current'))} | {fmt(item.get('baseline'))} |")
lines.extend(
[
"",
"## Next target hint",
"",
f"Focus first on `{first.get('first_target') if first else 'unknown'}`. Treat downstream field symptoms as unreliable until that artifact or missing evidence closes.",
"",
]
)
return "\n".join(lines)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=ROOT)
parser.add_argument("--report", type=Path, default=None, help="Explicit report path; otherwise discover newest verifier report")
parser.add_argument("--context", type=Path, default=DEFAULT_CONTEXT)
parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
root = args.root.resolve()
if args.report is not None:
report_path = args.report.resolve()
loaded = load_json(report_path)
report = loaded if is_verifier_report(loaded) else None
else:
report_path, report = discover_latest_report(root)
baseline = load_json(args.baseline) if args.baseline.exists() else None
baseline_mapping = baseline if isinstance(baseline, Mapping) else None
args.context.parent.mkdir(parents=True, exist_ok=True)
args.context.write_text(render_context(report_path, report, baseline_mapping), encoding="utf-8")
if report is not None and report_path is not None:
current = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"report": str(report_path),
"metrics": extract_metrics(report),
"first_divergence_summary": report.get("first_divergence_summary"),
"differential_trace": report.get("differential_trace"),
}
args.baseline.parent.mkdir(parents=True, exist_ok=True)
args.baseline.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"updated diagnostic context: {args.context} from {report_path}")
else:
print(f"updated diagnostic context without report: {args.context}")
return 0
if __name__ == "__main__":
raise SystemExit(main())