summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/harness/triage.py
diff options
context:
space:
mode:
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/harness/triage.py')
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/triage.py875
1 files changed, 875 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/harness/triage.py b/debug/risc-v-gsp-rm-vm/harness/triage.py
new file mode 100644
index 0000000..2175e48
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/triage.py
@@ -0,0 +1,875 @@
+#!/usr/bin/env python3
+"""One-command new-version GSP firmware triage (docs/40, goal U46).
+
+For a NEW GSP firmware image (optionally against a ``--baseline``) this tool
+answers, in one bounded run and with one report:
+
+* **what it is** - sha256, size, ELF shape and version label (explicit
+ ``--version``, the ``gsp_version.txt`` sidecar convention, parent directory,
+ file stem, in that order);
+* **where it boots to** - the existing QEMU probe path (first exception /
+ first touch) and the S1 native boot ladder (``python3 -m sim.gsp_boot``,
+ rungs B0-B4);
+* **how the native backend compares** - the existing ``harness/sim_qemu_line``
+ A/B harness (QEMU vs native, deterministic repeat, frozen thresholds);
+* **what changed vs the baseline** - the existing ``harness/version_diff``
+ (image/symbol/corpus/probe/behavior sections), when ``--baseline`` is given;
+* **what capability surface is present** - hooks/watch/memory/session API,
+ native simulator, TCG trace plugin, MCP, demo, boot ladder;
+* **what work ``vgsp`` support needs** - a ``support_readiness`` verdict with
+ blockers and ordered next actions.
+
+Every step is wrapped: a failing step is recorded (``status: error``) and the
+others still run. Every session a step uses is listed with sha256 hashes.
+Nothing is downloaded; no hardware, GPU, mdev, kernel module or guest is
+touched; QEMU is the only process launched besides the bounded native boot.
+
+This is the flagship workflow of the simulator program (handoff
+``U46``) and remains **in development** (docs/40). The frozen value
+thresholds below were recorded on 2026-10-03 *before* the first measurement
+(docs/40 section 5); never move them silently.
+
+Run from the tool root::
+
+ python3 harness/triage.py --firmware ../../firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin --version 570.144 --out dumps/triage-570.144
+ python3 harness/triage.py --firmware <new> --baseline <old> --out dumps/triage-570.211 --dry-run
+"""
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.dirname(HERE)
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from harness.common import ( # noqa: E402
+ DEMO_ELF,
+ ensure_dir,
+ qemu_available,
+ sha256_file,
+ write_json,
+ write_text,
+)
+from harness.sim_qemu_line import ( # noqa: E402
+ MAX_INSTRUCTIONS,
+ run_harness,
+ run_qemu_baseline,
+ rung_ordinal,
+ session_hashes,
+)
+from harness.version_diff import ( # noqa: E402
+ RM610_TOKEN,
+ Rm610GateError,
+ VersionDiffOptions,
+ check_rm610_gate,
+ run_version_diff,
+)
+
+FORMAT = "greeneye-new-version-triage/1"
+PLAN_FORMAT = "greeneye-new-version-triage-plan/1"
+
+#: Instruction budget bounds (task rule: <= 2M per attempt).
+DEFAULT_BUDGET = 200_000
+#: QEMU probe wall-clock bound (matches the S4 harness default).
+DEFAULT_QEMU_DURATION_S = 0.5
+#: Per-step wall-clock net (subprocess wedge protection; no unbounded runs).
+DEFAULT_STEP_TIMEOUT_S = 300.0
+#: Native rung at which runtime observation (not just first touch) is possible.
+OBSERVABLE_RUNG = "B2"
+
+#: Frozen 2026-10-03, before the first measured triage run (docs/40 section 5).
+#: ``manual_baseline_s`` is the *conservative understatement* of the manual
+#: steps this one command replaces (run+interpret probe, native boot ladder,
+#: A/B, session hash collection and write-up for one new image).
+FROZEN_THRESHOLDS = {
+ "frozen_on": "2026-10-03",
+ "manual_baseline_s": 1800.0,
+ "triage_wall_s_max": 600.0,
+ "required_steps": ("identity", "probe", "native_boot", "ab_eval", "features"),
+}
+
+VERSION_RE = re.compile(r"v?(\d{3}\.\d+)([A-Za-z0-9._-]*)?")
+_UNAVAILABLE_MARKERS = (
+ "no module named",
+ "modulenotfounderror",
+ "importerror",
+ "cannot import",
+ "profile absent",
+ "image absent",
+ "unavailable",
+)
+
+
+# ---------------------------------------------------------------------------
+# options and helpers
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class TriageOptions:
+ firmware: str
+ baseline: str | None = None
+ version: str | None = None
+ baseline_label: str | None = None
+ out: str | None = None
+ budget: int = DEFAULT_BUDGET
+ qemu_duration: float = DEFAULT_QEMU_DURATION_S
+ step_timeout: float = DEFAULT_STEP_TIMEOUT_S
+ release: str | None = None
+ approval_token: str | None = None
+ dry_run: bool = False
+
+ def labels(self) -> list[str]:
+ return [
+ value
+ for value in (
+ self.release,
+ self.version,
+ self.baseline_label,
+ os.path.basename(self.firmware) if self.firmware else None,
+ os.path.basename(self.baseline) if self.baseline else None,
+ )
+ if value
+ ]
+
+
+def _rm_tree(path: str) -> None:
+ if path and os.path.isdir(path):
+ shutil.rmtree(path, ignore_errors=True)
+
+
+def _rel(path: str, root: str) -> str:
+ try:
+ return os.path.relpath(path, root)
+ except ValueError:
+ return path
+
+
+def _abs(path: str | None) -> str | None:
+ """Absolute, normalised path; session dirs from the harness may be tool-root relative."""
+ if not path:
+ return None
+ return os.path.normpath(path if os.path.isabs(path) else os.path.join(TOOL_ROOT, path))
+
+
+def _first_line(text: str | None, limit: int = 300) -> str | None:
+ if not text:
+ return None
+ line = text.strip().splitlines()[-1].strip() if text.strip() else ""
+ return line[:limit] or None
+
+
+def _looks_unavailable(text: str) -> bool:
+ lowered = (text or "").lower()
+ return any(marker in lowered for marker in _UNAVAILABLE_MARKERS)
+
+
+def extract_version(firmware: str, explicit: str | None = None) -> tuple[str | None, str]:
+ """Version label: explicit, ``gsp_version.txt``, parent dir, file stem.
+
+ The sidecar convention mirrors the existing probe/device-map helper
+ (``greeneye/device_map.py``); no firmware content is guessed.
+ """
+ if explicit:
+ return explicit, "explicit --version"
+ sidecar = os.path.join(os.path.dirname(os.path.abspath(firmware)), "gsp_version.txt")
+ if os.path.isfile(sidecar):
+ try:
+ with open(sidecar, "r", encoding="utf-8", errors="replace") as fh:
+ text = "".join(ch for ch in fh.read() if ch.isprintable()).strip()
+ if text:
+ return text, "gsp_version.txt sidecar"
+ except OSError:
+ pass
+ parent = os.path.basename(os.path.dirname(os.path.abspath(firmware)))
+ if parent and VERSION_RE.fullmatch(parent):
+ return parent, "parent directory name"
+ stem = os.path.splitext(os.path.basename(firmware))[0]
+ return (stem or None), "file stem"
+
+
+def _target_brief(path: str, label: str | None = None) -> dict:
+ info: dict = {"path": path, "name": os.path.basename(path)}
+ version, version_source = extract_version(path, label)
+ info["version"] = version
+ info["version_source"] = version_source
+ if os.path.isfile(path):
+ info["sha256"] = sha256_file(path)
+ info["size"] = os.path.getsize(path)
+ else:
+ info["error"] = "file not found"
+ return info
+
+
+# ---------------------------------------------------------------------------
+# steps
+# ---------------------------------------------------------------------------
+
+
+def step_identity(options: TriageOptions, out_dir: str) -> dict:
+ firmware = _target_brief(options.firmware, None)
+ version, version_source = extract_version(options.firmware, options.version)
+ firmware["version"] = version
+ firmware["version_source"] = version_source
+ if not os.path.isfile(options.firmware):
+ return {
+ "status": "error",
+ "reason": f"firmware not found: {options.firmware}",
+ "firmware": firmware,
+ }
+ result: dict = {"status": "ok", "firmware": firmware}
+ try:
+ from greeneye.elf import load_elf
+
+ image = load_elf(options.firmware)
+ result["elf"] = {
+ "is_riscv": bool(image.is_riscv),
+ "entry": image.entry,
+ "min_vaddr": image.min_vaddr,
+ "max_vaddr": image.max_vaddr,
+ "segments": len(getattr(image, "segments", ()) or ()),
+ }
+ except Exception as exc:
+ result["elf"] = {"unreadable": f"{type(exc).__name__}: {exc}"}
+ if options.baseline:
+ result["baseline"] = _target_brief(options.baseline, options.baseline_label)
+ return result
+
+
+def step_probe(options: TriageOptions, out_dir: str) -> dict:
+ result = run_qemu_baseline(
+ image=options.firmware, out_dir=out_dir, duration=options.qemu_duration
+ )
+ detail: dict = {
+ "status": "ok" if result.get("ran") else "skipped",
+ "reason": result.get("reason"),
+ "rung": result.get("rung"),
+ "stopped_pc": result.get("stopped_pc"),
+ "first_fault": result.get("first_fault"),
+ "first_touch_match": result.get("first_touch_match"),
+ "fault_count_window": result.get("fault_count_window"),
+ "device_map_entries": result.get("device_map_entries"),
+ "wall_s": result.get("wall_s"),
+ "sessions": [],
+ }
+ session = _abs(result.get("session"))
+ if session:
+ detail["sessions"].append({"name": "qemu-probe", "dir": session})
+ detail["session_hashes"] = result.get("session_hashes") or session_hashes(session)
+ return detail
+
+
+def step_native_boot(options: TriageOptions, out_dir: str) -> dict:
+ session_dir = os.path.join(out_dir, "sessions", "native-boot")
+ _rm_tree(session_dir)
+ argv = [
+ sys.executable,
+ "-m",
+ "sim.gsp_boot",
+ "--image",
+ options.firmware,
+ "--instructions",
+ str(options.budget),
+ "--record",
+ session_dir,
+ "--name",
+ "triage-native-boot",
+ "--json",
+ ]
+ try:
+ proc = subprocess.run(
+ argv,
+ cwd=TOOL_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=options.step_timeout,
+ )
+ except subprocess.TimeoutExpired:
+ return {"status": "error", "reason": f"native boot timed out after {options.step_timeout}s"}
+ except OSError as exc:
+ return {"status": "skipped", "reason": f"native boot ladder unavailable: {exc}"}
+
+ stdout, stderr = proc.stdout or "", proc.stderr or ""
+ payload = None
+ if stdout.strip():
+ try:
+ payload = json.loads(stdout)
+ except ValueError:
+ payload = None
+ if proc.returncode == 0 and isinstance(payload, dict):
+ session = _abs(payload.get("session")) or session_dir
+ return {
+ "status": "ok",
+ "rung": payload.get("rung"),
+ "instructions_executed": payload.get("instructions_executed"),
+ "cycles": payload.get("cycles"),
+ "stop": payload.get("stop"),
+ "first_unknown_mmio": payload.get("first_unknown_mmio"),
+ "mmio_unknown": payload.get("mmio_unknown"),
+ "device_accesses": payload.get("device_accesses"),
+ "milestones": payload.get("milestones"),
+ "sessions": [{"name": "native-boot", "dir": session}],
+ "session_hashes": session_hashes(session),
+ }
+ reason = _first_line(stderr) or _first_line(stdout) or f"exit {proc.returncode}"
+ status = "skipped" if _looks_unavailable(stderr + stdout) or proc.returncode == 2 else "error"
+ if os.path.isdir(session_dir) and os.listdir(session_dir):
+ return {
+ "status": status,
+ "reason": reason,
+ "sessions": [{"name": "native-boot", "dir": session_dir}],
+ "session_hashes": session_hashes(session_dir),
+ }
+ return {"status": status, "reason": reason}
+
+
+def step_ab_eval(options: TriageOptions, out_dir: str) -> dict:
+ if not os.path.isfile(options.firmware):
+ return {"status": "skipped", "reason": f"firmware absent: {options.firmware}"}
+ ab_dir = os.path.join(out_dir, "ab-eval")
+ report = run_harness(
+ image=options.firmware,
+ out_dir=ab_dir,
+ instructions=options.budget,
+ qemu_duration=options.qemu_duration,
+ repeat=2,
+ features=False,
+ run_qemu=True,
+ )
+ qemu = report.get("qemu") or {}
+ native = report.get("native") or {}
+ detail: dict = {
+ "status": "ok" if native.get("ran") else "skipped",
+ "reason": native.get("reason") if not native.get("ran") else None,
+ "qemu_rung": qemu.get("rung"),
+ "native_rung": native.get("rung"),
+ "native_identical": native.get("identical"),
+ "native_repeat": native.get("repeat"),
+ "above_qemu_line": report.get("above_qemu_line"),
+ "verdict": report.get("verdict"),
+ "threshold_checks": report.get("threshold_checks"),
+ "report_json": _abs(report.get("report_json")),
+ "report_md": _abs(report.get("report_md")),
+ "sessions": [],
+ }
+ qemu_session = _abs(qemu.get("session"))
+ if qemu_session:
+ detail["sessions"].append({"name": "ab-qemu", "dir": qemu_session})
+ for index, run in enumerate(native.get("runs") or [], start=1):
+ session = _abs(run.get("session")) or os.path.join(ab_dir, "sessions", f"native-{index}")
+ detail["sessions"].append({"name": f"ab-native-{index}", "dir": session})
+ return detail
+
+
+def step_version_diff(options: TriageOptions, out_dir: str) -> dict:
+ if not options.baseline:
+ return {"status": "skipped", "reason": "no --baseline supplied"}
+ if not os.path.isfile(options.baseline):
+ return {"status": "skipped", "reason": f"baseline absent: {options.baseline}"}
+ vd_dir = os.path.join(out_dir, "version-diff")
+ old_version, _ = extract_version(options.baseline, options.baseline_label)
+ report = run_version_diff(
+ VersionDiffOptions(
+ old=options.baseline,
+ new=options.firmware,
+ out=vd_dir,
+ old_label=options.baseline_label or old_version,
+ new_label=options.version,
+ release=options.release,
+ probe_instructions=max(1, min(options.budget, 2000)),
+ behavior_duration=0.4,
+ approval_token=options.approval_token,
+ )
+ )
+ sections = {name: section.get("status") for name, section in report["sections"].items()}
+ symbols = report["sections"].get("symbols") or {}
+ detail: dict = {
+ "status": "error" if "error" in sections.values() else "ok",
+ "reason": None,
+ "old_label": report.get("old", {}).get("label"),
+ "new_label": report.get("new", {}).get("label"),
+ "sections": sections,
+ "sections_skipped": report.get("summary", {}).get("sections_skipped"),
+ "sections_error": report.get("summary", {}).get("sections_error"),
+ "report_json": os.path.join(vd_dir, "version-diff.json"),
+ "report_md": os.path.join(vd_dir, "version-diff.md"),
+ "image_changes": _image_changes(report),
+ "symbol_changes": _symbol_changes(symbols),
+ "sessions": [],
+ }
+ sessions_root = os.path.join(vd_dir, "sessions")
+ if os.path.isdir(sessions_root):
+ for name in sorted(os.listdir(sessions_root)):
+ path = os.path.join(sessions_root, name)
+ if os.path.isdir(path):
+ detail["sessions"].append({"name": f"version-diff/{name}", "dir": path})
+ return detail
+
+
+def _image_changes(report: dict) -> dict:
+ section = (report.get("sections") or {}).get("image") or {}
+ return {
+ "status": section.get("status"),
+ "same": section.get("same"),
+ "deltas": section.get("deltas"),
+ }
+
+
+def _symbol_changes(symbols: dict) -> dict:
+ totals = symbols.get("totals") or {}
+ return {
+ "status": symbols.get("status"),
+ "reason": symbols.get("reason"),
+ "shift": symbols.get("shift"),
+ "matched_named": symbols.get("matched_named"),
+ "matched_shifted": symbols.get("matched_shifted"),
+ "added": totals.get("added"),
+ "removed": totals.get("removed"),
+ "moved": totals.get("moved"),
+ "resized_named": totals.get("resized_named"),
+ }
+
+
+def _spec_available(module: str) -> bool:
+ try:
+ return importlib.util.find_spec(module) is not None
+ except (ImportError, ValueError):
+ return False
+
+
+def step_features(options: TriageOptions, out_dir: str) -> dict:
+ checks: dict = {}
+ notes: list[str] = []
+ try:
+ import greeneye as ge
+
+ checks["greeneye_version"] = ge.__version__
+ checks["hooks_watch_step"] = all(
+ hasattr(ge.Session, name) for name in ("hook", "watch", "run", "step", "close")
+ )
+ checks["memory_struct"] = hasattr(ge, "Memory")
+ checks["native_backend"] = _spec_available("greeneye.sim_backend")
+ except Exception as exc:
+ notes.append(f"greeneye import failed: {type(exc).__name__}: {exc}")
+ checks.update({"greeneye_version": None, "hooks_watch_step": False, "memory_struct": False, "native_backend": False})
+ checks["native_boot_ladder"] = os.path.isfile(os.path.join(TOOL_ROOT, "sim", "gsp_boot.py")) and os.path.isdir(
+ os.path.join(TOOL_ROOT, "sim", "profiles")
+ )
+ checks["ab_harness"] = os.path.isfile(os.path.join(TOOL_ROOT, "harness", "sim_qemu_line.py"))
+ checks["version_diff"] = os.path.isfile(os.path.join(TOOL_ROOT, "harness", "version_diff.py"))
+ checks["tcg_trace_plugin"] = os.path.isfile(os.path.join(TOOL_ROOT, "qemu", "greeneye-trace.so"))
+ checks["mcp_server"] = os.path.isfile(os.path.join(TOOL_ROOT, "plugins", "mcp_server.py"))
+ checks["demo_firmware"] = os.path.isfile(DEMO_ELF)
+ checks["qemu"] = qemu_available()
+ unavailable = sorted(name for name, value in checks.items() if value is False)
+ if unavailable:
+ notes.append("unavailable: " + ", ".join(unavailable))
+ return {"status": "ok", "checks": checks, "unavailable": unavailable, "notes": notes}
+
+
+DEFAULT_RUNNERS = (
+ ("identity", step_identity),
+ ("probe", step_probe),
+ ("native_boot", step_native_boot),
+ ("ab_eval", step_ab_eval),
+ ("version_diff", step_version_diff),
+ ("features", step_features),
+)
+
+
+# ---------------------------------------------------------------------------
+# readiness and report assembly
+# ---------------------------------------------------------------------------
+
+
+def support_readiness(report: dict) -> dict:
+ """Blockers and next actions derived from the recorded step results."""
+ steps = {step["name"]: step for step in report["steps"]}
+ blockers: list[str] = []
+ next_actions: list[str] = []
+ identity = steps.get("identity", {})
+ if identity.get("status") != "ok":
+ blockers.append(f"firmware identity failed: {identity.get('reason') or 'unreadable'}")
+ probe = steps.get("probe", {})
+ if probe.get("status") == "error":
+ blockers.append(f"QEMU probe failed: {probe.get('reason')}")
+ native = steps.get("native_boot", {})
+ native_rung = native.get("rung")
+ native_ok = native.get("status") == "ok"
+ if not native_ok:
+ next_actions.append(
+ "native boot ladder unavailable/again incomplete "
+ f"({native.get('reason') or native.get('status')}): the S1 device model/profile is the enabler"
+ )
+ elif rung_ordinal(native_rung) is not None and rung_ordinal(native_rung) >= rung_ordinal(OBSERVABLE_RUNG):
+ next_actions.append(
+ f"native ladder is at {native_rung} (runtime-observable): inspect the milestone/stop with "
+ "hooks and watchpoints, then route the finding through the accuracy loop"
+ )
+ else:
+ next_actions.append(
+ f"native ladder stops at {native_rung}: extend the device model past the next unknown MMIO "
+ "before claiming runtime observation"
+ )
+ ab = steps.get("ab_eval", {})
+ if ab.get("status") == "ok" and not ab.get("above_qemu_line"):
+ next_actions.append(
+ f"native A/B is not yet above the QEMU line (verdict={ab.get('verdict')}): "
+ "device model and documented shims gate the claim"
+ )
+ vd = steps.get("version_diff", {})
+ baseline_supplied = bool(report.get("input", {}).get("baseline_supplied"))
+ if vd.get("status") == "error":
+ blockers.append(f"version diff errored: {vd.get('sections_error') or vd.get('reason')}")
+ elif vd.get("status") == "ok" and baseline_supplied:
+ skipped = vd.get("sections_skipped") or {}
+ if "symbols" in skipped:
+ next_actions.append(
+ "symbol inventory skipped: supply Ghidra exports / symbol DBs so the handler "
+ "inventory delta can gate the vgsp support scope"
+ )
+ if "corpus" in skipped:
+ next_actions.append(
+ "corpus diff skipped: supply --corpus-diff indexes for decompiled-change hints"
+ )
+ else:
+ next_actions.append(
+ "re-run with --baseline <previous.fwimage> for the symbol/behavior delta "
+ "(the release-to-support gate needs the A/B)"
+ )
+ features = steps.get("features", {})
+ for name in features.get("unavailable") or []:
+ if name in ("qemu", "native_boot_ladder", "hooks_watch_step", "memory_struct"):
+ next_actions.append(f"capability unavailable: {name}")
+ if not blockers and native_ok and rung_ordinal(native_rung) is not None and rung_ordinal(native_rung) >= rung_ordinal(OBSERVABLE_RUNG):
+ verdict = "sim-observable"
+ elif not blockers:
+ verdict = "first-touch-only"
+ else:
+ verdict = "blocked"
+ return {
+ "verdict": verdict,
+ "baseline_compared": bool(baseline_supplied and vd.get("status") == "ok"),
+ "observable_rung": OBSERVABLE_RUNG,
+ "blockers": blockers,
+ "next_actions": next_actions,
+ }
+
+
+def _collect_sessions(steps: list[dict], out_dir: str) -> list[dict]:
+ rows: list[dict] = []
+ seen: set[str] = set()
+ for step in steps:
+ for entry in step.get("sessions") or []:
+ path = _abs(entry.get("dir"))
+ if not path or path in seen:
+ continue
+ seen.add(path)
+ hashes = step.get("session_hashes") or {}
+ rows.append(
+ {
+ "step": step["name"],
+ "name": entry.get("name") or step["name"],
+ "dir": _rel(path, out_dir),
+ "present": os.path.isdir(path),
+ "hashes": hashes or session_hashes(path),
+ }
+ )
+ return rows
+
+
+def render_markdown(report: dict) -> str:
+ firmware = report["input"].get("firmware") or {}
+ steps = report["steps"]
+ readiness = report["support_readiness"]
+ lines = [
+ f"# New-version triage — {firmware.get('version') or firmware.get('name')}",
+ "",
+ "> Status: in development (docs/40). Simulator-only: no hardware, GPU, mdev,",
+ "> kernel module or guest was used. Frozen thresholds were recorded before the run.",
+ "",
+ f"* format: `{report['format']}`",
+ f"* generated: {report['generated_at']}",
+ f"* wall: {report['wall_s']}s (budget {report['input'].get('budget')} instructions/attempt)",
+ f"* RM610 gate: {report['rm610_gate']['status']}",
+ "",
+ "## Firmware",
+ "",
+ "| field | value |",
+ "|---|---|",
+ f"| path | `{firmware.get('path')}` |",
+ f"| sha256 | `{firmware.get('sha256')}` |",
+ f"| size | {firmware.get('size')} |",
+ f"| version | {firmware.get('version')} ({firmware.get('version_source')}) |",
+ f"| ELF | {json.dumps(report['input'].get('elf') or {}, sort_keys=True)} |",
+ ]
+ baseline = report["input"].get("baseline")
+ if baseline:
+ lines += [
+ f"| baseline | `{baseline.get('name')}` {baseline.get('sha256')} |",
+ f"| baseline version | {baseline.get('version')} ({baseline.get('version_source')}) |",
+ ]
+ lines += ["", "## Steps", "", "| step | status | wall_s | key result |", "|---|---|---|---|"]
+ for step in steps:
+ key = (
+ step.get("reason")
+ or step.get("verdict")
+ or step.get("rung")
+ or step.get("status")
+ )
+ lines.append(f"| {step['name']} | {step['status']} | {step.get('wall_s')} | {str(key)[:120]} |")
+ lines += [
+ "",
+ "## Support readiness",
+ "",
+ f"* verdict: **{readiness['verdict']}**",
+ f"* baseline compared: {readiness['baseline_compared']}",
+ ]
+ for label, items in (("blockers", readiness["blockers"]), ("next actions", readiness["next_actions"])):
+ lines.append(f"* {label}:")
+ if not items:
+ lines.append(" * none")
+ for item in items:
+ lines.append(f" * {item}")
+ sessions = report.get("sessions") or []
+ lines += ["", "## Sessions used", "", "| step | name | dir | session.json sha256 |", "|---|---|---|---|"]
+ if not sessions:
+ lines.append("| - | - | - | (none recorded) |")
+ for row in sessions:
+ sha = (row.get("hashes") or {}).get("session.json") or "-"
+ lines.append(f"| {row['step']} | {row['name']} | `{row['dir']}` | `{sha}` |")
+ checks = report["threshold_checks"]
+ lines += [
+ "",
+ "## Value (frozen thresholds, docs/40 section 5)",
+ "",
+ "| metric | value |",
+ "|---|---|",
+ f"| triage wall_s | {checks['triage_wall_s']} |",
+ f"| wall_s max | {checks['triage_wall_s_max']} |",
+ f"| within max | {checks['wall_within_max']} |",
+ f"| manual baseline_s | {checks['manual_baseline_s']} |",
+ f"| speedup_x | {checks['speedup_x']} |",
+ f"| beats manual baseline | {checks['beats_manual_baseline']} |",
+ "",
+ "## Artifacts",
+ "",
+ ]
+ for key in ("report_json", "report_md"):
+ lines.append(f"* {key}: `{report['artifacts'][key]}`")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def run_triage(options: TriageOptions, runners=None) -> dict:
+ """Run every step (wrapped), write ``triage-report.json|.md``, return it."""
+ check_rm610_gate(options.labels(), approval_token=options.approval_token)
+ if not options.out:
+ raise ValueError("--out is required unless --dry-run is used")
+ if options.budget <= 0 or options.budget > MAX_INSTRUCTIONS:
+ raise ValueError(f"--budget must be in 1..{MAX_INSTRUCTIONS}, got {options.budget}")
+ out = os.path.abspath(options.out)
+ ensure_dir(out)
+ started = time.monotonic()
+ steps: list[dict] = []
+ for name, fn in runners or DEFAULT_RUNNERS:
+ step_started = time.monotonic()
+ try:
+ detail = fn(options, out)
+ if not isinstance(detail, dict):
+ detail = {"status": "ok", "result": detail}
+ except Exception as exc:
+ detail = {"status": "error", "reason": f"{type(exc).__name__}: {exc}"}
+ entry = {"name": name, "wall_s": round(time.monotonic() - step_started, 3)}
+ entry.update(detail)
+ steps.append(entry)
+ wall_s = round(time.monotonic() - started, 3)
+ report: dict = {
+ "format": FORMAT,
+ "tool": "harness/triage.py",
+ "dry_run": False,
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))},
+ "input": {
+ "firmware": _target_brief(options.firmware, options.version),
+ "baseline": _target_brief(options.baseline, options.baseline_label) if options.baseline else None,
+ "baseline_supplied": options.baseline is not None,
+ "budget": options.budget,
+ "qemu_duration": options.qemu_duration,
+ "triage_command": (
+ "python3 harness/triage.py --firmware <firmware>"
+ + (" --baseline <baseline>" if options.baseline else "")
+ + (f" --version {options.version}" if options.version else "")
+ + f" --budget {options.budget} --out <out>"
+ ),
+ },
+ "thresholds": dict(FROZEN_THRESHOLDS),
+ "steps": steps,
+ "support_readiness": {},
+ "wall_s": wall_s,
+ "artifacts": {},
+ }
+ firmware = report["input"]["firmware"]
+ if firmware.get("error"):
+ report["input"]["firmware"]["status"] = "error"
+ report["input"]["elf"] = next((s.get("elf") for s in steps if s["name"] == "identity"), None)
+ report["errors"] = [step["name"] for step in steps if step["status"] == "error"]
+ report["sessions"] = _collect_sessions(steps, out)
+ report["support_readiness"] = support_readiness(report)
+ checks = {
+ "triage_wall_s": wall_s,
+ "triage_wall_s_max": FROZEN_THRESHOLDS["triage_wall_s_max"],
+ "wall_within_max": wall_s <= FROZEN_THRESHOLDS["triage_wall_s_max"],
+ "manual_baseline_s": FROZEN_THRESHOLDS["manual_baseline_s"],
+ "speedup_x": round(FROZEN_THRESHOLDS["manual_baseline_s"] / wall_s, 1) if wall_s > 0 else None,
+ "beats_manual_baseline": wall_s < FROZEN_THRESHOLDS["manual_baseline_s"],
+ "required_steps_missing": [
+ name
+ for name in FROZEN_THRESHOLDS["required_steps"]
+ if steps_by_name(steps, name).get("status") not in ("ok", "skipped")
+ ],
+ }
+ report["threshold_checks"] = checks
+ artifacts = {
+ "report_json": os.path.join(out, "triage-report.json"),
+ "report_md": os.path.join(out, "triage-report.md"),
+ }
+ report["artifacts"] = artifacts
+ write_json(artifacts["report_json"], report)
+ write_text(artifacts["report_md"], render_markdown(report))
+ return report
+
+
+def steps_by_name(steps: list[dict], name: str) -> dict:
+ return next((step for step in steps if step["name"] == name), {})
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def build_plan(options: TriageOptions) -> dict:
+ out = os.path.abspath(options.out) if options.out else "<out>"
+ return {
+ "format": PLAN_FORMAT,
+ "tool": "harness/triage.py",
+ "dry_run": True,
+ "status": "planned",
+ "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))},
+ "firmware": {"path": options.firmware, "version": options.version or "auto"},
+ "baseline": options.baseline,
+ "out": out,
+ "budget": options.budget,
+ "steps": [
+ {"name": name, "budget": options.budget if name in ("native_boot", "ab_eval") else None}
+ for name, _fn in DEFAULT_RUNNERS
+ ],
+ "side_effects": {
+ "writes": [
+ os.path.join(out, "triage-report.json"),
+ os.path.join(out, "triage-report.md"),
+ os.path.join(out, "sessions/"),
+ os.path.join(out, "ab-eval/"),
+ os.path.join(out, "version-diff/ (only with --baseline)"),
+ ],
+ "hardware": "none",
+ "network": "none",
+ "max_instructions_per_attempt": MAX_INSTRUCTIONS,
+ },
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="risc-v-gsp-rm-debugger triage", description=__doc__.splitlines()[0])
+ parser.add_argument("--firmware", required=True, help="new GSP firmware image (read-only)")
+ parser.add_argument("--baseline", help="previous firmware image for the version diff (optional)")
+ parser.add_argument("--version", help="version label (default: gsp_version.txt / dir / stem)")
+ parser.add_argument("--baseline-label", help="version label recorded for the baseline")
+ parser.add_argument("--out", help="report directory (required unless --dry-run)")
+ parser.add_argument("--budget", type=int, default=DEFAULT_BUDGET,
+ help=f"instruction budget per native attempt (1..{MAX_INSTRUCTIONS})")
+ parser.add_argument("--qemu-duration", type=float, default=DEFAULT_QEMU_DURATION_S,
+ help="QEMU probe wall-clock bound in seconds")
+ parser.add_argument("--step-timeout", type=float, default=DEFAULT_STEP_TIMEOUT_S,
+ help="per-step wall-clock net in seconds")
+ parser.add_argument("--release", help="release label; an rm610 release is gated by docs/23")
+ parser.add_argument("--approval-token", help=f"explicit RM610 token ({RM610_TOKEN})")
+ parser.add_argument("--dry-run", action="store_true",
+ help="print the plan and exit without opening targets or creating --out")
+ return parser
+
+
+def options_from_args(args: argparse.Namespace) -> TriageOptions:
+ return TriageOptions(
+ firmware=args.firmware,
+ baseline=args.baseline,
+ version=args.version,
+ baseline_label=args.baseline_label,
+ out=args.out,
+ budget=args.budget,
+ qemu_duration=args.qemu_duration,
+ step_timeout=args.step_timeout,
+ release=args.release,
+ approval_token=args.approval_token,
+ dry_run=args.dry_run,
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ options = options_from_args(args)
+ try:
+ check_rm610_gate(options.labels(), approval_token=options.approval_token)
+ except Rm610GateError as exc:
+ print(f"[triage] RM610 gate refused: {exc}", file=sys.stderr)
+ return 3
+ if options.dry_run:
+ plan = build_plan(options)
+ print(json.dumps(plan, indent=2, sort_keys=True))
+ print(f"[triage] dry-run: {len(plan['steps'])} steps planned; no targets opened, no --out created")
+ return 0
+ if not options.out:
+ parser.error("--out is required unless --dry-run is used")
+ if options.budget <= 0 or options.budget > MAX_INSTRUCTIONS:
+ parser.error(f"--budget must be in 1..{MAX_INSTRUCTIONS}")
+ if not os.path.isfile(options.firmware):
+ print(f"[triage] skipped: firmware absent: {options.firmware}", file=sys.stderr)
+ return 2
+ try:
+ report = run_triage(options)
+ except Rm610GateError as exc:
+ print(f"[triage] RM610 gate refused: {exc}", file=sys.stderr)
+ return 3
+ except ValueError as exc:
+ parser.error(str(exc))
+ except Exception as exc:
+ print(f"[triage] error: {type(exc).__name__}: {exc}", file=sys.stderr)
+ return 2
+ for step in report["steps"]:
+ reason = f" reason={step['reason']}" if step.get("reason") else ""
+ print(f"[triage] {step['name']}: status={step['status']}{reason}")
+ readiness = report["support_readiness"]
+ print(f"[triage] readiness: {readiness['verdict']} (baseline_compared={readiness['baseline_compared']})")
+ for action in readiness["next_actions"][:6]:
+ print(f"[triage] next: {action}")
+ checks = report["threshold_checks"]
+ print(f"[triage] wall: {checks['triage_wall_s']}s vs manual baseline {checks['manual_baseline_s']}s "
+ f"(speedup {checks['speedup_x']}x)")
+ print(f"[triage] wrote {report['artifacts']['report_json']} + {report['artifacts']['report_md']}")
+ return 1 if report["errors"] else 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())