#!/usr/bin/env python3 """Generate the K0 kinematics configuration graph and oracle sample definitions.""" from __future__ import annotations import argparse import hashlib import json import re from collections import defaultdict from pathlib import Path from typing import Iterable HAL_KEYS = {"HALFILE", "POSTGUI_HALFILE", "POSTTASK_HALFILE"} COMPONENT_PATHS = { "corexy_by_hal": "linuxcnc/src/hal/components/corexy_by_hal.comp", "matrixkins": "linuxcnc/src/hal/components/matrixkins.comp", "millturn": "linuxcnc/src/hal/components/millturn.comp", "userkins": "linuxcnc/src/hal/components/userkins.comp", "xyzab_tdr_kins": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp", } SAMPLE_INPUTS = { "cartesian": {"joint": [10.0, -5.0, 2.5]}, "coupled_linear": {"joint": [12.0, 4.0, 3.0]}, "five_axis": {"joint": [10.0, 20.0, 30.0, 15.0, -20.0]}, "five_axis_trt": {"joint": [100.0, 50.0, 200.0, 30.0, 45.0]}, "parallel_delta": {"pose": {"tran": [5.0, -3.0, -250.0], "abcuvw": [0.0] * 6}}, "parallel_tripod": {"pose": {"tran": [1.0, 2.0, 100.0], "abcuvw": [2.0, -1.0, 0.0, 0.0, 0.0, 0.0]}}, "parallel_hexapod": {"pose": {"tran": [0.0, 0.0, 100.0], "abcuvw": [1.0, -1.0, 2.0, 0.0, 0.0, 0.0]}}, "parallel_pentapod": {"pose": {"tran": [0.0, 0.0, 75.0], "abcuvw": [1.0, 2.0, 0.0, 0.0, 0.0, 0.0]}}, "serial_robot": {"joint": [0.0, -30.0, 45.0, 10.0, 20.0, -15.0]}, "switchable": {"joint": [5.0, 6.0, 7.0, 30.0], "switch_sequence": [0, 1, 0]}, "user_static": {"pose": {"tran": [10.0, 20.0, 30.0], "abcuvw": [0.0] * 6}}, } def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def stable_hash(rows: Iterable[dict[str, object]]) -> str: data = "".join(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" for row in rows) return hashlib.sha256(data.encode("utf-8")).hexdigest() def read_jsonl(path: Path) -> list[dict[str, object]]: if not path.is_file(): return [] return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] def relative(root: Path, path: Path) -> str: return path.resolve().relative_to(root.resolve()).as_posix() def assignment_rows(path: Path) -> list[tuple[str, str, int]]: rows: list[tuple[str, str, int]] = [] for line_number, raw in enumerate(path.read_text(encoding="utf-8", errors="strict").splitlines(), 1): line = raw.split("#", 1)[0].split(";", 1)[0].strip() if not line or "=" not in line: continue key, value = line.split("=", 1) rows.append((key.strip().upper(), value.strip().strip('"').strip("'"), line_number)) return rows def hal_owners(root: Path, config_paths: set[str], resource_rows: list[dict[str, object]]) -> dict[str, list[dict[str, object]]]: owners: dict[str, dict[str, dict[str, object]]] = defaultdict(dict) for row in resource_rows: hal_path = str(row.get("path", "")) entrypoint = str(row.get("entrypoint", "")) if row.get("resource_kind") == "hal" and row.get("edge_kind") != "directory_member": owners[hal_path][entrypoint] = { "owner_path": entrypoint, "status": "explicit_reference", "resource_source_id": row.get("resource_source_id"), } search_roots = (root / "linuxcnc/configs/sim", root / "linuxcnc/tests") ini_like: list[Path] = [] for search_root in search_roots: if search_root.is_dir(): ini_like.extend(path for path in search_root.rglob("*") if path.is_file() and (path.suffix.lower() in {".ini", ".inc"} or ".ini." in path.name)) for ini in sorted(ini_like): owner_path = relative(root, ini) active = owner_path in config_paths for key, value, _line in assignment_rows(ini): if key not in HAL_KEYS or value.startswith("LIB:"): continue target = ini.parent / value.split()[0] if not target.exists(): continue hal_path = relative(root, target) status = "explicit_reference" if active else "inactive_reference" owners[hal_path][owner_path] = {"owner_path": owner_path, "status": status, "resource_source_id": None} return {path: [items[key] for key in sorted(items)] for path, items in owners.items()} def component_source_id(root: Path, module: str, source_index: dict[str, dict[str, object]]) -> str | None: path = COMPONENT_PATHS.get(module.lower()) if path and (root / path).is_file(): row = source_index.get(path) return str(row.get("source_id")) if row else f"source:{path}" return None def sample_rows(families: list[dict[str, object]], matrixkins_id: str | None) -> list[dict[str, object]]: rows: list[dict[str, object]] = [] for family_row in sorted(families, key=lambda row: str(row["family"])): family = str(family_row["family"]) candidates = list(dict.fromkeys(str(value) for value in family_row.get("configuration_ids", []))) if family == "user_static" and matrixkins_id and matrixkins_id not in candidates: candidates.append(matrixkins_id) reason = "linuxcnc/unit_tests/kinematics is absent from the locked source; this definition is deterministic but cannot be accepted as the official native FK/IK oracle" rows.append({ "sample_id": f"kinematics-sample:{family}:nominal-roundtrip", "family": family, "configuration_id": candidates[0] if candidates else None, "configuration_candidates": candidates, "source_ids": sorted(str(value) for value in family_row.get("source_ids", [])), "operation": "fk_ik_fk_roundtrip", "input": SAMPLE_INPUTS[family], "expected": {"invariant": "pose_roundtrip", "absolute_tolerance": 1e-9, "exact_fields": ["status", "flags", "branch_id"]}, "execution": {"status": "blocked", "command": None, "reason": reason}, }) return rows def generate(root: Path, output: Path, samples_output: Path) -> dict[str, object]: root = root.resolve() closure_path = root / "coverage/kinematics-source-closure.report.json" closure = json.loads(closure_path.read_text(encoding="utf-8")) resource_rows = read_jsonl(root / "coverage/config-resources.jsonl") source_index = {str(row.get("path")): row for row in read_jsonl(root / "coverage/source-index.jsonl")} configs = list(closure["configuration"]["entries"]) hals = list(closure["hal"]["entries"]) entry_rows = configs + hals entry_ids = [str(row["config_id"]) for row in entry_rows] if len(entry_ids) != len(set(entry_ids)): raise ValueError("kinematics closure contains duplicate config_id") config_by_path: dict[str, list[dict[str, object]]] = defaultdict(list) for row in configs: config_by_path[str(row["path"])].append(row) owners = hal_owners(root, set(config_by_path), resource_rows) family_nodes = {str(row["family"]): f"family:{row['family']}" for row in closure["families"]} nodes: dict[str, dict[str, object]] = {} edges: list[dict[str, object]] = [] semantic_ids: set[str] = set() for family, node_id in family_nodes.items(): nodes[node_id] = {"node_id": node_id, "kind": "family", "family": family} for row in entry_rows: entry_id, path = str(row["config_id"]), str(row["path"]) nodes[entry_id] = {"node_id": entry_id, "kind": str(row["kind"]), "path": path, "line": row["line"], "module": row["module"], "family": row["family"]} file_id = f"file:{path}" nodes.setdefault(file_id, {"node_id": file_id, "kind": "file", "path": path}) edges.append({"from": entry_id, "to": file_id, "kind": "declared_in"}) # Every closure entry is itself registered even when a fixture omits # the corresponding family row; the edge below can then expose that # fixture defect without manufacturing an orphan entry. semantic_ids.add(entry_id) family = str(row["family"]) if family in family_nodes: edges.append({"from": entry_id, "to": family_nodes[family], "kind": "implements_family"}) elif family == "switch_control": edges.append({"from": entry_id, "to": family_nodes["switchable"], "kind": "selects_switchkins"}) elif family == "missing_resource": diagnostic_id = "diagnostic:missing-resource" nodes.setdefault(diagnostic_id, {"node_id": diagnostic_id, "kind": "diagnostic"}) edges.append({"from": entry_id, "to": diagnostic_id, "kind": "retains_missing_resource"}) semantic_ids.add(entry_id) elif family == "template_entry": owner_rows = owners.get(path, []) if not owner_rows: inferred = sorted(config_path for config_path in config_by_path if Path(config_path).parent == Path(path).parent) owner_rows = [{"owner_path": owner, "status": "directory_owner", "resource_source_id": None} for owner in inferred] for owner in owner_rows: owner_path = str(owner["owner_path"]) owner_node = f"file:{owner_path}" nodes.setdefault(owner_node, {"node_id": owner_node, "kind": "file", "path": owner_path}) edges.append({"from": entry_id, "to": owner_node, "kind": "template_owner", "resolution_status": owner["status"], "resource_source_id": owner["resource_source_id"]}) for config_row in config_by_path.get(owner_path, []): edges.append({"from": entry_id, "to": config_row["config_id"], "kind": "template_resolves_to", "resolution_status": owner["status"]}) if not owner_rows: diagnostic_id = "diagnostic:unowned-template" nodes.setdefault(diagnostic_id, {"node_id": diagnostic_id, "kind": "diagnostic"}) edges.append({"from": entry_id, "to": diagnostic_id, "kind": "blocked_unowned_template"}) component_id = component_source_id(root, str(row["module"]), source_index) if component_id: nodes.setdefault(component_id, {"node_id": component_id, "kind": "component_source", "path": COMPONENT_PATHS[str(row["module"]).lower()]}) edges.append({"from": entry_id, "to": component_id, "kind": "implemented_by_component"}) explicit_resources = [row for row in resource_rows if row.get("edge_kind") != "directory_member" and str(row.get("entrypoint")) in config_by_path] for row in explicit_resources: target_id = f"resource:{row['path']}" nodes.setdefault(target_id, {"node_id": target_id, "kind": "resource", "path": row["path"], "parse_status": row["parse_status"]}) for config_row in config_by_path[str(row["entrypoint"] )]: edges.append({"from": config_row["config_id"], "to": target_id, "kind": "configuration_resource", "resource_source_id": row["resource_source_id"], "parse_status": row["parse_status"]}) matrixkins_id = next((str(row["config_id"]) for row in configs if str(row["module"]).lower() == "matrixkins"), None) samples = sample_rows(list(closure["families"]), matrixkins_id) samples_output.parent.mkdir(parents=True, exist_ok=True) samples_output.write_text("".join(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" for row in samples), encoding="utf-8") orphan_ids = sorted(set(entry_ids) - semantic_ids) unowned_templates = sorted(row["config_id"] for row in hals if row["family"] == "template_entry" and not any(edge["from"] == row["config_id"] and edge["kind"] == "template_owner" for edge in edges)) samples_without_config = sorted(row["sample_id"] for row in samples if not row["configuration_id"]) report: dict[str, object] = { "schema": "kinematics-config-graph-report.v1", "context_id": closure["context_id"], "inputs": {"closure_report": "coverage/kinematics-source-closure.report.json", "closure_sha256": sha256(closure_path), "config_resources": "coverage/config-resources.jsonl"}, "counts": {"entry_nodes": len(entry_ids), "configuration_entries": len(configs), "hal_entries": len(hals), "family_nodes": len(family_nodes), "nodes": len(nodes), "edges": len(edges), "samples": len(samples)}, "nodes": [nodes[key] for key in sorted(nodes)], "edges": sorted(edges, key=lambda row: (str(row["from"]), str(row["kind"]), str(row["to"]))), "samples": {"path": relative(root, samples_output), "sha256": sha256(samples_output), "sample_ids": [row["sample_id"] for row in samples]}, "closure": {"expected_entry_nodes": int(closure["configuration"]["entry_count"]) + int(closure["hal"]["entry_count"]), "all_entry_ids_registered": len(entry_ids) == len(set(entry_ids)), "orphan_entry_ids": orphan_ids, "unowned_template_ids": unowned_templates, "samples_without_configuration": samples_without_config, "official_oracle_status": "blocked", "official_oracle_reason": "linuxcnc/unit_tests/kinematics is absent from the locked source"}, } report["graph_fingerprint_sha256"] = stable_hash(report["nodes"] + report["edges"]) 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/kinematics-config-graph.report.json")) parser.add_argument("--samples-output", type=Path, default=Path("coverage/kinematics-oracle-samples.jsonl")) args = parser.parse_args() report = generate(args.repo_root, args.repo_root / args.output, args.repo_root / args.samples_output) print(f"kinematics_config_graph=generated entries={report['counts']['entry_nodes']} edges={report['counts']['edges']} samples={report['counts']['samples']} orphans={len(report['closure']['orphan_entry_ids'])}") return 0 if __name__ == "__main__": raise SystemExit(main())