Files
linuxcncWebWASM/scripts/generate_configuration_regression.py

416 lines
20 KiB
Python

#!/usr/bin/env python3
"""Generate the deterministic K8 full-configuration regression report.
The LinuxCNC checkout used by this project is intentionally a source/config
fixture rather than a complete desktop installation. This report therefore
separates a deterministic static startup preflight from the native/WASM and
Worker runtime probes. Missing resources and unavailable official oracles
remain explicit blockers; they are never converted into passing goldens.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
from collections import defaultdict
from pathlib import Path
from typing import Any, Iterable
CONTEXT_PATH = Path("context-lock.json")
GRAPH_PATH = Path("coverage/kinematics-config-graph.report.json")
RESOURCE_PATH = Path("coverage/config-resources.jsonl")
RESOURCE_REPORT_PATH = Path("coverage/config-resources.report.json")
RECOVERY_PATH = Path("coverage/workspace-recovery.report.json")
REGISTRY_PATH = Path("coverage/kinematics-registry.report.json")
RUNTIME_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("kinematics", ("build-native/sim_kinematics_test",)),
("motion", ("build-native/sim_kinematics_motion_test",)),
("feedback", ("build-native/sim_kinematics_feedback_test",)),
("home_limit", ("build-native/sim_kinematics_home_limit_test",)),
("io", ("build-native/sim_io_test",)),
)
WASM_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("kinematics", ("node", "build-wasm/sim_kinematics_wasm_test.js")),
("motion", ("node", "build-wasm/sim_kinematics_motion_wasm_test.js")),
("feedback", ("node", "build-wasm/sim_kinematics_feedback_wasm_test.js")),
("home_limit", ("node", "build-wasm/sim_kinematics_home_limit_wasm_test.js")),
("io", ("node", "build-wasm/sim_io_wasm_test.js")),
)
WORKER_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("replay", ("node", "web/worker/test_replay.mjs")),
("workspace_recovery", ("node", "web/workspace/test_recovery.mjs")),
("sim_session", ("node", "web/worker/test_sim_session.mjs")),
)
UNRESOLVED_STATUSES = {"unresolved", "host_or_variable_path", "outside_repo"}
KINEMATICS_SAMPLE_KEYS = {
"corexykins": "corexy",
"rosekins": "rose",
"maxkins": "maxkins",
"rotatekins": "rotatekins",
"5axiskins": "fiveaxiskins",
"xyzac-trt-kins": "xyzac_trt",
"xyzbc-trt-kins": "xyzbc_trt",
"scarakins": "scara",
"pumakins": "puma",
"scorbot-kins": "scorbot",
"genserkins": "genser",
"lineardeltakins": "lineardelta",
"rotarydeltakins": "rotarydelta",
"tripodkins": "tripod",
"genhexkins": "genhex",
"pentakins": "pentapod",
}
def canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def digest(value: Any) -> str:
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
def sha256(path: Path) -> str:
handle = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
handle.update(block)
return handle.hexdigest()
def read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"{path} must contain an object")
return value
def read_jsonl(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
value = json.loads(line)
if not isinstance(value, dict):
raise ValueError(f"{path}:{line_number} must contain an object")
rows.append(value)
return rows
def source_record(root: Path, path: Path) -> dict[str, Any]:
relative = path.relative_to(root).as_posix()
return {"path": relative, "sha256": sha256(path)} if path.is_file() else {"path": relative, "sha256": None, "status": "missing"}
def parse_ini_shape(path: Path) -> dict[str, Any]:
"""Check only syntax needed by the preflight; it is not an INI oracle."""
if not path.is_file():
return {"file": False, "sections": [], "balanced_sections": False}
sections: list[str] = []
balanced = True
for raw in path.read_text(encoding="utf-8", errors="strict").splitlines():
line = raw.split("#", 1)[0].split(";", 1)[0].strip()
if not line:
continue
if line.startswith("["):
if not line.endswith("]"):
balanced = False
else:
sections.append(line[1:-1].strip().upper())
return {"file": True, "sections": sorted(set(sections)), "balanced_sections": balanced}
def registered_kinematics(root: Path) -> set[str]:
registry = read_json(REGISTRY_PATH if REGISTRY_PATH.is_absolute() else root / REGISTRY_PATH)
rows = registry.get("samples", {}).get("native", {}).get("entries", [])
return {str(row.get("name")) for row in rows if row.get("name")}
def graph_entries(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
graph = read_json(root / GRAPH_PATH)
entries = [row for row in graph.get("nodes", []) if row.get("kind") == "kinematics"]
entries.sort(key=lambda row: str(row.get("node_id", "")))
return graph, entries
def resource_summaries(root: Path, resource_report: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
by_entrypoint: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
by_entrypoint[str(row.get("entrypoint", ""))].append(row)
indexed = {str(path) for path in resource_report.get("entrypoint_paths", [])}
result: dict[str, list[dict[str, Any]]] = {}
for path in sorted(indexed):
result[path] = by_entrypoint.get(path, [])
return result
def preflight_entry(root: Path, row: dict[str, Any], resources: dict[str, list[dict[str, Any]]], indexed_paths: set[str], registered: set[str]) -> dict[str, Any]:
path = str(row["path"])
module = str(row.get("module", ""))
resource_rows = resources.get(path, [])
missing = sorted({str(item.get("path")) for item in resource_rows if item.get("parse_status") == "missing"})
unresolved = sorted({str(item.get("path")) for item in resource_rows if item.get("parse_status") in UNRESOLVED_STATUSES})
ini_shape = parse_ini_shape(root / path)
if module not in registered:
startup_status = "blocked_unregistered_kinematics"
elif path not in indexed_paths and path.startswith("linuxcnc/configs/sim/"):
startup_status = "blocked_unindexed_resource_graph"
elif missing or unresolved:
startup_status = "blocked_resource"
elif not ini_shape["file"] or not ini_shape["balanced_sections"]:
startup_status = "blocked_ini_syntax"
else:
startup_status = "static_ready"
if path not in indexed_paths:
resource_status = "unindexed"
elif missing and unresolved:
resource_status = "blocked_missing_and_unresolved"
elif missing:
resource_status = "blocked_missing"
elif unresolved:
resource_status = "blocked_unresolved"
else:
resource_status = "ready"
return {
"config_id": row.get("node_id"),
"path": path,
"line": row.get("line"),
"module": module,
"family": row.get("family"),
"resource_status": resource_status,
"missing_resources": missing,
"unresolved_resources": unresolved,
"resource_rows": len(resource_rows),
"ini": ini_shape,
"kinematics_registered": module in registered,
"startup_preflight": startup_status,
}
def run_json_command(root: Path, command: tuple[str, ...]) -> dict[str, Any]:
executable = root / command[0] if command[0] not in {"node"} else root / command[1]
if not executable.exists():
return {"status": "blocked", "reason": f"missing_runtime_artifact:{executable.relative_to(root).as_posix()}"}
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
if completed.returncode != 0:
return {"status": "failed", "exit_code": completed.returncode, "stderr_sha256": digest(completed.stderr)}
json_lines = [line.strip() for line in completed.stdout.splitlines() if line.lstrip().startswith("{")]
if not json_lines:
return {"status": "failed", "reason": "runtime_did_not_emit_json", "stdout_sha256": digest(completed.stdout)}
try:
sample = json.loads(json_lines[-1])
except json.JSONDecodeError:
return {"status": "failed", "reason": "runtime_emitted_invalid_json", "stdout_sha256": digest(completed.stdout)}
return {"status": "passed", "schema": sample.get("schema"), "sample": sample, "sample_sha256": digest(sample)}
def run_runtime_group(root: Path, commands: Iterable[tuple[str, tuple[str, ...]]]) -> dict[str, Any]:
records: dict[str, Any] = {}
for name, command in commands:
first = run_json_command(root, command)
second = run_json_command(root, command)
first_key = first.get("sample_sha256")
second_key = second.get("sample_sha256")
records[name] = {
"command": list(command),
"first": first,
"second": second,
"repeat_stable": bool(first_key and first_key == second_key),
}
passed = all(row["first"].get("status") == "passed" and row["repeat_stable"] for row in records.values())
return {"status": "passed" if passed else "blocked", "records": records, "summary_sha256": digest(records)}
def run_worker_group(root: Path) -> dict[str, Any]:
records: dict[str, Any] = {}
for name, command in WORKER_COMMANDS:
target = root / command[-1]
if not target.is_file():
records[name] = {"command": list(command), "status": "blocked", "reason": "missing_worker_test"}
continue
first = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
second = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
first_value = {"exit_code": first.returncode, "stdout": first.stdout, "stderr": first.stderr}
second_value = {"exit_code": second.returncode, "stdout": second.stdout, "stderr": second.stderr}
records[name] = {
"command": list(command),
"status": "passed" if first.returncode == 0 else "failed",
"first_sha256": digest(first_value),
"second_sha256": digest(second_value),
"repeat_stable": digest(first_value) == digest(second_value),
"stdout": first.stdout.strip(),
}
passed = all(value.get("status") == "passed" and value.get("repeat_stable") for value in records.values())
return {"status": "passed" if passed else "blocked", "records": records, "summary_sha256": digest(records)}
def extract_state_hashes(value: Any, path: str = "") -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
if isinstance(value, dict):
for key, child in sorted(value.items()):
child_path = f"{path}.{key}" if path else key
if key in {"state_hash", "hash", "repeat_hash"} and isinstance(child, (int, str)):
found.append({"path": child_path, "value": child})
found.extend(extract_state_hashes(child, child_path))
elif isinstance(value, list):
for index, child in enumerate(value):
found.extend(extract_state_hashes(child, f"{path}[{index}]"))
return found
def build_config_state_summary(entries: list[dict[str, Any]], runtime: dict[str, Any], registry: set[str]) -> dict[str, Any]:
samples: dict[str, Any] = {}
for name, record in runtime.get("records", {}).items():
sample = record.get("first", {}).get("sample")
if isinstance(sample, dict):
samples[name] = {
"schema": sample.get("schema"),
"sample_sha256": record["first"].get("sample_sha256"),
"state_hashes": extract_state_hashes(sample),
}
rows = []
for entry in entries:
module = str(entry.get("module", ""))
sample_key = KINEMATICS_SAMPLE_KEYS.get(module)
rows.append({
"config_id": entry.get("config_id"),
"module": module,
"sample": sample_key,
"sample_status": "verified_sample" if sample_key in samples else "registry_only" if module in registry else "blocked_unregistered",
"state_digest": digest({"module": module, "sample": samples.get(sample_key), "registry": module in registry}),
})
rows.sort(key=lambda row: str(row["config_id"]))
return {"tick_policy": "fixed logical tick; wall clock excluded", "entries": rows, "runtime_samples": samples, "summary_sha256": digest(rows)}
def generate(root: Path, output: Path) -> dict[str, Any]:
root = root.resolve()
context = read_json(root / CONTEXT_PATH)
graph, entries = graph_entries(root)
resource_report = read_json(root / RESOURCE_REPORT_PATH)
resources_rows = read_jsonl(root / RESOURCE_PATH)
resources = resource_summaries(root, resource_report, resources_rows)
indexed_paths = set(resources)
registered = registered_kinematics(root)
preflight = [preflight_entry(root, row, resources, indexed_paths, registered) for row in entries]
preflight.sort(key=lambda row: str(row["config_id"]))
runtime = run_runtime_group(root, RUNTIME_COMMANDS)
wasm = run_runtime_group(root, WASM_COMMANDS)
worker = run_worker_group(root)
native_wasm_equal = all(
runtime["records"].get(name, {}).get("first", {}).get("sample_sha256")
== wasm["records"].get(name, {}).get("first", {}).get("sample_sha256")
and runtime["records"].get(name, {}).get("first", {}).get("status") == "passed"
for name, _command in RUNTIME_COMMANDS
)
graph_orphans = list(graph.get("closure", {}).get("orphan_entry_ids", []))
unowned = list(graph.get("closure", {}).get("unowned_template_ids", []))
resource_orphans = sorted({str(row.get("entrypoint")) for row in resources_rows if str(row.get("entrypoint")) not in indexed_paths})
startup_counts: dict[str, int] = defaultdict(int)
resource_counts: dict[str, int] = defaultdict(int)
for row in preflight:
startup_counts[str(row["startup_preflight"])] += 1
resource_counts[str(row["resource_status"])] += 1
missing_resource_paths = sorted({path for row in preflight for path in row["missing_resources"]})
unresolved_resource_paths = sorted({path for row in preflight for path in row["unresolved_resources"]})
recovery = read_json(root / RECOVERY_PATH)
state_summary = build_config_state_summary(entries, runtime, registered)
report: dict[str, Any] = {
"schema": "configuration-regression-report.v1",
"context_id": context.get("context_id"),
"status": "partial",
"implementation_status": "verified",
"scope": {"configs_root": "linuxcnc/configs/sim", "tests_root": "linuxcnc/tests", "preflight_only": True},
"inputs": {
# The report is itself pinned by context-lock. Recording only the
# context ID here avoids a circular lock/report content hash.
"context_lock": {"path": CONTEXT_PATH.as_posix(), "context_id": context.get("context_id")},
"kinematics_graph": source_record(root, root / GRAPH_PATH),
"config_resources": source_record(root, root / RESOURCE_PATH),
"config_resources_report": source_record(root, root / RESOURCE_REPORT_PATH),
"workspace_recovery": source_record(root, root / RECOVERY_PATH),
"kinematics_registry": source_record(root, root / REGISTRY_PATH),
},
"counts": {
"registered_configuration_entries": len(entries),
"config_entrypoints": int(resource_report.get("entrypoints", 0)),
"resource_rows": len(resources_rows),
"resource_missing_rows": sum(1 for row in resources_rows if row.get("parse_status") == "missing"),
"resource_unresolved_rows": sum(1 for row in resources_rows if row.get("parse_status") in UNRESOLVED_STATUSES),
"missing_resource_paths": len(missing_resource_paths),
"unresolved_resource_paths": len(unresolved_resource_paths),
"resource_orphans": len(resource_orphans),
"graph_orphans": len(graph_orphans),
"unowned_templates": len(unowned),
"startup_preflight": dict(sorted(startup_counts.items())),
"resource_status": dict(sorted(resource_counts.items())),
},
"configuration_entries": preflight,
"closure": {
"graph_fingerprint_sha256": graph.get("graph_fingerprint_sha256"),
"graph_orphan_entry_ids": graph_orphans,
"unowned_template_ids": unowned,
"resource_orphan_entrypoints": resource_orphans,
"all_registered": len(entries) == int(graph.get("counts", {}).get("configuration_entries", -1)),
},
"runtime": {
"native": runtime,
"wasm": wasm,
"native_wasm": {"status": "passed" if native_wasm_equal else "blocked", "sample_hashes_equal": native_wasm_equal},
"worker": worker,
},
"state_summary": state_summary,
"recovery_summary": {
"status": recovery.get("status"),
"implementation_status": recovery.get("implementation_status"),
"checkpoint_schema": recovery.get("replay", {}).get("schema"),
"replay_state_hash": recovery.get("replay", {}).get("state_hash"),
"continuous_state_hash": recovery.get("replay", {}).get("continuous_state_hash"),
"endpoints_equal": recovery.get("replay", {}).get("endpoints_equal"),
"oracle_status": recovery.get("oracle", {}).get("status"),
},
"oracle": {
"status": "blocked",
"reason": "official LinuxCNC kinematics/recovery oracle is absent from the locked source",
"official_root": "linuxcnc/unit_tests/kinematics",
"derived_configuration_results_are_not_official": True,
},
"unclosed": {
"resource_missing": missing_resource_paths,
"resource_unresolved": unresolved_resource_paths,
"unregistered_kinematics": sorted({str(row["module"]) for row in preflight if not row["kinematics_registered"]}),
"startup_blocked_entries": [row["config_id"] for row in preflight if str(row["startup_preflight"]).startswith("blocked")],
},
"report_sha256": None,
}
report["report_sha256"] = digest({key: value for key, value in report.items() if key != "report_sha256"})
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
return report
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--output", type=Path, default=Path("coverage/configuration-regression.report.json"))
args = parser.parse_args()
report = generate(args.repo_root, args.repo_root / args.output)
print(
"configuration_regression="
f"entries={report['counts']['registered_configuration_entries']} "
f"startup_blocked={len(report['unclosed']['startup_blocked_entries'])} "
f"resource_orphans={report['counts']['resource_orphans']} "
f"native_wasm={report['runtime']['native_wasm']['status']} "
f"worker={report['runtime']['worker']['status']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())