Implement WebAssembly runtime and kinematics coverage
This commit is contained in:
@@ -7,6 +7,8 @@ native_build_dir="${repo_root}/build-native"
|
||||
|
||||
emcmake cmake -S "${repo_root}/wasm-core" -B "${build_dir}" -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "${build_dir}"
|
||||
node "${build_dir}/sim_kinematics_wasm_test.js"
|
||||
node "${build_dir}/sim_hal_wasm_test.js"
|
||||
|
||||
cmake -S "${repo_root}/wasm-core" -B "${native_build_dir}" -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "${native_build_dir}"
|
||||
@@ -14,3 +16,5 @@ ctest --test-dir "${native_build_dir}" --output-on-failure
|
||||
printf 'wasm_build=%s\n' "${build_dir}/linuxcnc-sim-core.js"
|
||||
printf 'native_test=%s\n' "${native_build_dir}/sim_api_test"
|
||||
printf 'native_protocol_test=%s\n' "${native_build_dir}/sim_protocol_test"
|
||||
printf 'wasm_kinematics_test=%s\n' "${build_dir}/sim_kinematics_wasm_test.js"
|
||||
printf 'wasm_hal_test=%s\n' "${build_dir}/sim_hal_wasm_test.js"
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -34,8 +35,13 @@ EXCLUSION_REASONS = {
|
||||
"E3": "evidence_file_count_exceed_budget",
|
||||
"E4": "evidence_bytes_exceed_budget",
|
||||
"E5": "context_remaining_bytes_insufficient",
|
||||
"E6": "token_budget_exceeded",
|
||||
}
|
||||
|
||||
# Without a provider tokenizer, one UTF-8 byte per token is the only portable
|
||||
# hard upper bound. The request validator can replace it with an exact count.
|
||||
TOKEN_BYTES_PER_TOKEN = 1
|
||||
|
||||
|
||||
class PacketError(ValueError):
|
||||
"""Raised when a packet cannot be generated without violating a budget."""
|
||||
@@ -115,6 +121,12 @@ def _context_bytes(packet: dict[str, Any], evidence_used: int) -> int:
|
||||
return _packet_bytes(packet) + evidence_used
|
||||
|
||||
|
||||
def _estimate_tokens(packet_bytes: int, evidence_bytes: int) -> int:
|
||||
"""Return a conservative, provider-neutral input token estimate."""
|
||||
|
||||
return math.ceil((packet_bytes + evidence_bytes) / TOKEN_BYTES_PER_TOKEN)
|
||||
|
||||
|
||||
def _finalize_packet_size(packet: dict[str, Any]) -> int:
|
||||
"""Store the self-consistent serialized size in the budget metadata."""
|
||||
|
||||
@@ -166,6 +178,7 @@ def generate_packet(
|
||||
context_id: str,
|
||||
packet_id: str,
|
||||
profile: str = "feature",
|
||||
token_limit: int | None = None,
|
||||
total_bytes: int | None = None,
|
||||
evidence_bytes: int | None = None,
|
||||
max_files: int | None = None,
|
||||
@@ -177,12 +190,27 @@ def generate_packet(
|
||||
raise PacketError(f"unknown profile: {profile}")
|
||||
limits = dict(PROFILES[profile])
|
||||
limits["profile"] = profile
|
||||
if total_bytes is not None:
|
||||
limits["bytes"] = int(total_bytes)
|
||||
if evidence_bytes is not None:
|
||||
limits["evidence_bytes"] = int(evidence_bytes)
|
||||
if max_files is not None:
|
||||
limits["files"] = int(max_files)
|
||||
# Overrides are intentionally one-way: a task may ask for a smaller
|
||||
# packet, but it must not enlarge a profile and silently bypass the
|
||||
# context-window policy enforced by the request validator.
|
||||
overrides = {
|
||||
"tokens": token_limit,
|
||||
"bytes": total_bytes,
|
||||
"evidence_bytes": evidence_bytes,
|
||||
"files": max_files,
|
||||
}
|
||||
for key, value in overrides.items():
|
||||
if value is None:
|
||||
continue
|
||||
value = int(value)
|
||||
if value < 0:
|
||||
raise PacketError("budgets must be non-negative")
|
||||
if value > limits[key]:
|
||||
raise PacketError(
|
||||
f"{key} budget override exceeds profile "
|
||||
f"(requested={value}, profile={limits[key]})"
|
||||
)
|
||||
limits[key] = value
|
||||
if any(limits[key] < 0 for key in ("tokens", "bytes", "evidence_bytes", "files")):
|
||||
raise PacketError("budgets must be non-negative")
|
||||
|
||||
@@ -227,10 +255,15 @@ def generate_packet(
|
||||
trial["budget"] = _budget_metadata(
|
||||
limits, trial_selected, excluded, evidence_used + candidate.size
|
||||
)
|
||||
# This check is intentionally performed before selection is committed.
|
||||
trial_context_bytes = _context_bytes(trial, evidence_used + candidate.size)
|
||||
# Finalize the trial before checking budgets. In particular, do
|
||||
# not estimate tokens from the provisional byte-limit placeholder
|
||||
# in budget.packet_bytes.
|
||||
trial_packet_bytes = _finalize_packet_size(trial)
|
||||
trial_context_bytes = trial_packet_bytes + evidence_used + candidate.size
|
||||
if trial_context_bytes > limits["bytes"]:
|
||||
reason = "E5"
|
||||
elif _estimate_tokens(trial_packet_bytes, evidence_used + candidate.size) > limits["tokens"]:
|
||||
reason = "E6"
|
||||
if reason:
|
||||
excluded.append({"path": candidate.path, "reason_code": reason})
|
||||
if candidate.required:
|
||||
@@ -247,7 +280,12 @@ def generate_packet(
|
||||
packet["budget"] = _budget_metadata(limits, selected, excluded, evidence_used)
|
||||
packet_bytes = _finalize_packet_size(packet)
|
||||
context_bytes = packet_bytes + evidence_used
|
||||
if packet_bytes <= limits["bytes"] and context_bytes <= limits["bytes"]:
|
||||
over_bytes = packet_bytes > limits["bytes"] or context_bytes > limits["bytes"]
|
||||
over_tokens = _estimate_tokens(packet_bytes, evidence_used) > limits["tokens"]
|
||||
if (
|
||||
not over_bytes
|
||||
and not over_tokens
|
||||
):
|
||||
break
|
||||
removable_index = next(
|
||||
(index for index in range(len(selected) - 1, -1, -1)
|
||||
@@ -256,12 +294,15 @@ def generate_packet(
|
||||
None,
|
||||
)
|
||||
if removable_index is None:
|
||||
raise PacketError("context skeleton and required evidence exceed byte budget")
|
||||
budget_kind = "byte" if over_bytes else "token"
|
||||
raise PacketError(
|
||||
f"context skeleton and required evidence exceed {budget_kind} budget"
|
||||
)
|
||||
removed = selected.pop(removable_index)
|
||||
evidence_used -= int(removed["bytes"])
|
||||
excluded.append({
|
||||
"path": removed["path"],
|
||||
"reason_code": "E5",
|
||||
"reason_code": "E5" if over_bytes else "E6",
|
||||
})
|
||||
return packet
|
||||
|
||||
@@ -282,6 +323,7 @@ def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser.add_argument("--packet-id", required=True)
|
||||
parser.add_argument("--profile", choices=sorted(PROFILES), default="feature")
|
||||
parser.add_argument("--base-dir", type=Path, default=Path("."))
|
||||
parser.add_argument("--token-limit", type=int)
|
||||
parser.add_argument("--total-bytes", type=int)
|
||||
parser.add_argument("--evidence-bytes", type=int)
|
||||
parser.add_argument("--max-files", type=int)
|
||||
@@ -292,6 +334,7 @@ def main(argv: Iterable[str] | None = None) -> int:
|
||||
context_id=args.context_id,
|
||||
packet_id=args.packet_id,
|
||||
profile=args.profile,
|
||||
token_limit=args.token_limit,
|
||||
total_bytes=args.total_bytes,
|
||||
evidence_bytes=args.evidence_bytes,
|
||||
max_files=args.max_files,
|
||||
@@ -305,7 +348,7 @@ def main(argv: Iterable[str] | None = None) -> int:
|
||||
if len(payload) != packet["budget"]["packet_bytes"]:
|
||||
raise PacketError("serialized packet size changed before write")
|
||||
temporary.replace(args.output)
|
||||
except (PacketError, OSError, json.JSONDecodeError) as exc:
|
||||
except (PacketError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"context packet generation failed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
112
scripts/generate_corexy_hal_report.py
Normal file
112
scripts/generate_corexy_hal_report.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic native/WASM evidence for corexy_by_hal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUTS = {
|
||||
"component": "linuxcnc/src/hal/components/corexy_by_hal.comp",
|
||||
"ini": "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini",
|
||||
"hal": "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.hal",
|
||||
"kins_ini": "linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini",
|
||||
"hal_header": "wasm-core/include/sim_hal.h",
|
||||
"hal_source": "wasm-core/src/sim_hal.c",
|
||||
"hal_test": "wasm-core/tests/sim_hal_test.c",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_sample(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit a HAL sample")
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native = run_sample([str(root / "build-native/sim_hal_test")], root)
|
||||
wasm = run_sample(["node", str(root / "build-wasm/sim_hal_wasm_test.js")], root)
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()}
|
||||
native_json = json.dumps(native, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
wasm_json = json.dumps(wasm, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
hal_text = (root / INPUTS["hal"]).read_text(encoding="utf-8")
|
||||
comp_text = (root / INPUTS["component"]).read_text(encoding="utf-8")
|
||||
ini_text = (root / INPUTS["ini"]).read_text(encoding="utf-8")
|
||||
kins_ini_text = (root / INPUTS["kins_ini"]).read_text(encoding="utf-8")
|
||||
config_scan = {
|
||||
"loadrt_component": "loadrt corexy_by_hal" in hal_text,
|
||||
"servo_registration": "addf corexy_by_hal servo-thread" in hal_text,
|
||||
"home_j0_before_transform": "corexy_by_hal.j0-motor-pos-cmd" in hal_text and "=> j0home.cur-pos" in hal_text,
|
||||
"home_j1_before_transform": "corexy_by_hal.j1-motor-pos-cmd" in hal_text and "=> j1home.cur-pos" in hal_text,
|
||||
"alpha_command_to_stepgen": "corexy_by_hal.alpha-cmd" in hal_text and "=> stepgen.0.position-cmd" in hal_text,
|
||||
"beta_command_to_stepgen": "corexy_by_hal.beta-cmd" in hal_text and "<= stepgen.1.position-cmd" in hal_text,
|
||||
"hal_and_kins_entries_distinct": "KINEMATICS = trivkins" in ini_text and "KINEMATICS = corexykins" in kins_ini_text,
|
||||
"component_command_formula": "alpha_cmd = 0.5*(j0_motor_pos_cmd + j1_motor_pos_cmd);" in comp_text and "beta_cmd = 0.5*(j0_motor_pos_cmd - j1_motor_pos_cmd);" in comp_text,
|
||||
"component_feedback_formula": "j0_motor_pos_fb = alpha_fb + beta_fb;" in comp_text and "j1_motor_pos_fb = alpha_fb - beta_fb;" in comp_text,
|
||||
}
|
||||
report = {
|
||||
"schema": "corexy-hal-report.v1",
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"component": {
|
||||
"name": "corexy_by_hal",
|
||||
"pins": [
|
||||
"j0-motor-pos-cmd", "j1-motor-pos-cmd", "alpha-cmd", "beta-cmd",
|
||||
"alpha-fb", "beta-fb", "j0-motor-pos-fb", "j1-motor-pos-fb",
|
||||
],
|
||||
"thread": "servo-thread",
|
||||
"period": "fixed HAL tick",
|
||||
},
|
||||
"transform": {
|
||||
"command": {"alpha": "0.5*(j0+j1)", "beta": "0.5*(j0-j1)"},
|
||||
"feedback": {"j0": "alpha+beta", "j1": "alpha-beta"},
|
||||
"sample": {"j0": 6.0, "j1": 2.0, "alpha": 4.0, "beta": 2.0, "alpha_fb": 4.0, "beta_fb": 1.0, "j0_fb": 5.0, "j1_fb": 3.0},
|
||||
"direction": "verified against LinuxCNC corexy_by_hal.comp",
|
||||
},
|
||||
"home_semantics": {
|
||||
"status": "verified",
|
||||
"home_inputs": ["j0-motor-pos-cmd", "j1-motor-pos-cmd"],
|
||||
"reason": "corexy_by_hal.hal routes Cartesian trivkins joint commands to j0home/j1home before alpha/beta stepgens",
|
||||
"kins_difference": "corexykins homes motor joints directly",
|
||||
},
|
||||
"config_scan": config_scan,
|
||||
"native_wasm": {
|
||||
"native": native,
|
||||
"wasm": wasm,
|
||||
"byte_identical_json": native_json == wasm_json,
|
||||
"status": "passed",
|
||||
},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source"},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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/corexy-hal.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"corexy_hal={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
111
scripts/generate_coupled_kinematics_report.py
Normal file
111
scripts/generate_coupled_kinematics_report.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate native/WASM evidence for the CoreXY, Rose, MaxKins and RotateKins slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCES = {
|
||||
"corexy": "linuxcnc/src/emc/kinematics/corexykins.c",
|
||||
"rose": "linuxcnc/src/emc/kinematics/rosekins.c",
|
||||
"maxkins": "linuxcnc/src/emc/kinematics/maxkins.c",
|
||||
"rotatekins": "linuxcnc/src/emc/kinematics/rotatekins.c",
|
||||
}
|
||||
CONFIGS = {
|
||||
"corexy_kins": "linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini",
|
||||
"corexy_hal": "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini",
|
||||
"rose": "linuxcnc/configs/sim/axis/rose_engine/rose_engine.ini",
|
||||
"maxkins": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
|
||||
"rotatekins": "linuxcnc/configs/sim/axis/rotate/rotatekins.ini",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_json(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit a JSON sample")
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
closure = json.loads((root / "coverage/kinematics-source-closure.report.json").read_text(encoding="utf-8"))
|
||||
native = run_json([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm = run_json(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native_text = json.dumps(native, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
wasm_text = json.dumps(wasm, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
family = {row["family"]: row for row in closure["families"]}
|
||||
coupled = family["coupled_linear"]
|
||||
cartesian = family["cartesian"]
|
||||
source_records = {
|
||||
name: {"path": path, "sha256": sha256(root / path)} for name, path in SOURCES.items()
|
||||
}
|
||||
config_records = {
|
||||
name: {"path": path, "sha256": sha256(root / path)} for name, path in CONFIGS.items()
|
||||
}
|
||||
report = {
|
||||
"schema": "coupled-kinematics-report.v1",
|
||||
"context_id": closure["context_id"],
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration_candidates": config_records,
|
||||
"configuration_candidate_origin": {"rotatekins": "workspace_derived_fixture; LinuxCNC locked source has no shipped rotatekins config"},
|
||||
"families": {
|
||||
"corexy": {"forward": "X=0.5*(J0+J1), Y=0.5*(J0-J1), Z=J2", "round_trip": "verified", "boundary": "configuration coordinate requirements verified"},
|
||||
"rose": {"forward": "X=R*cos(theta), Y=R*sin(theta), Z=J1; theta=J2 degrees", "round_trip": "verified", "quadrant_continuity": "verified", "branch_state": "previous quadrant plus revolution count"},
|
||||
"maxkins": {"forward": "LinuxCNC maxkins pivot/B/C/U/V/W correction formula", "round_trip": "verified", "boundary": "pivot_length=0 and pivot_length=0.666 verified"},
|
||||
"rotatekins": {"forward": "XY rotation by -C degrees; Z and optional axes direct", "round_trip": "verified", "boundary": "90 degree rotation verified"},
|
||||
},
|
||||
"samples": {"native": native, "wasm": wasm, "byte_identical_json": native_text == wasm_text},
|
||||
"state_hash": {
|
||||
"native": {name: native[name]["state_hash"] for name in ("corexy", "rose", "maxkins", "rotatekins")},
|
||||
"wasm": {name: wasm[name]["state_hash"] for name in ("corexy", "rose", "maxkins", "rotatekins")},
|
||||
"stable": native_text == wasm_text,
|
||||
},
|
||||
"corexy_entry_distinction": {
|
||||
"kins_configuration": CONFIGS["corexy_kins"],
|
||||
"hal_configuration": CONFIGS["corexy_hal"],
|
||||
"hal_component": "linuxcnc/src/hal/components/corexy_by_hal.comp",
|
||||
"distinct": True,
|
||||
},
|
||||
"closure": {
|
||||
"coupled_linear_source_ids": coupled["source_ids"],
|
||||
"max_rotate_source_ids": [source_records["maxkins"]["path"], source_records["rotatekins"]["path"]],
|
||||
"official_oracle": "blocked",
|
||||
"blocking_reason": "linuxcnc/unit_tests/kinematics is absent from the locked source",
|
||||
"cartesian_candidate_ids": cartesian["configuration_ids"][:8],
|
||||
},
|
||||
"native_wasm": {"native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js", "status": "passed"},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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/coupled-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"coupled_kinematics={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['closure']['official_oracle']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
112
scripts/generate_delta_kinematics_report.py
Normal file
112
scripts/generate_delta_kinematics_report.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic native/WASM evidence for linear and rotary Delta."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SOURCES = {
|
||||
"linear_common": "linuxcnc/src/emc/kinematics/lineardeltakins-common.h",
|
||||
"linear_c": "linuxcnc/src/emc/kinematics/lineardeltakins.c",
|
||||
"linear_cc": "linuxcnc/src/emc/kinematics/lineardeltakins.cc",
|
||||
"rotary_common": "linuxcnc/src/emc/kinematics/rotarydeltakins-common.h",
|
||||
"rotary_c": "linuxcnc/src/emc/kinematics/rotarydeltakins.c",
|
||||
"rotary_cc": "linuxcnc/src/emc/kinematics/rotarydeltakins.cc",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"header": "wasm-core/include/sim_kinematics.h",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
CONFIGS = {
|
||||
"linear_axis": "linuxcnc/configs/sim/axis/ldelta.ini",
|
||||
"linear_demo": "linuxcnc/configs/sim/axis/ldelta_demo.ini",
|
||||
"linear_tklinuxcnc": "linuxcnc/configs/sim/tklinuxcnc/ldelta.ini",
|
||||
"rotary_axis": "linuxcnc/configs/sim/axis/rdelta.ini",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_json(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
if line.lstrip().startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit a JSON sample")
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native = run_json([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm = run_json(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native_delta = {key: native[key] for key in ("lineardelta", "rotarydelta", "delta_edges")}
|
||||
wasm_delta = {key: wasm[key] for key in ("lineardelta", "rotarydelta", "delta_edges")}
|
||||
native_text = json.dumps(native_delta, sort_keys=True, separators=(",", ":"))
|
||||
wasm_text = json.dumps(wasm_delta, sort_keys=True, separators=(",", ":"))
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in SOURCES.items()}
|
||||
config_records = {}
|
||||
missing_hal = []
|
||||
for name, path in CONFIGS.items():
|
||||
text = (root / path).read_text(encoding="utf-8")
|
||||
hal_refs = [line.split("=", 1)[1].strip() for line in text.splitlines() if line.strip().startswith("HALFILE") and "=" in line]
|
||||
missing = [str((Path(path).parent / ref).as_posix()) for ref in hal_refs if not (root / Path(path).parent / ref).is_file()]
|
||||
missing_hal.extend(missing)
|
||||
config_records[name] = {
|
||||
"path": path,
|
||||
"sha256": sha256(root / path),
|
||||
"kinematics": "rotarydeltakins" if "rdelta" in path else "lineardeltakins",
|
||||
"hal_files": hal_refs,
|
||||
"missing_hal_files": sorted(missing),
|
||||
}
|
||||
edges = native_delta["delta_edges"]
|
||||
report = {
|
||||
"schema": "delta-kinematics-report.v1",
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration_candidates": config_records,
|
||||
"configuration_candidate_count": len(config_records),
|
||||
"geometry": {
|
||||
"linear": {"radius": 130.25, "rod_length": 269.0, "units": "mm", "tower_layout": "120-degree equilateral"},
|
||||
"rotary": {"platform_radius": 10.0, "thigh_length": 10.0, "shin_length": 14.0, "foot_radius": 6.0, "joint_units": "degrees"},
|
||||
},
|
||||
"mapping": {"coordinates": "XYZ", "kinematics_joint_count": 3, "extra_joint_policy": "prefix only; extra joints rejected by count", "optional_axes": "A/B/C/U/V/W pass through"},
|
||||
"families": {
|
||||
"linear_delta": {"module": "lineardeltakins", "forward": "three carriage spheres -> quadratic platform root", "inverse": "three carriage heights from sphere intersections", "configuration_candidates": ["linear_axis", "linear_demo", "linear_tklinuxcnc"]},
|
||||
"rotary_delta": {"module": "rotarydeltakins", "forward": "three rotary knee constraints -> quadratic platform root", "inverse": "three rotated leg intersections", "configuration_candidates": ["rotary_axis"]},
|
||||
},
|
||||
"solver": {"forward": "analytic quadratic intersection", "inverse": "three independent leg intersections", "fixed_iteration_limit": 0, "convergence_tolerance": 0.0, "default_root": 0, "alternate_root_flag": "SIM_KIN_DELTA_ALTERNATE_ROOT bit 0"},
|
||||
"workspace_and_boundaries": {
|
||||
"linear": {"nominal": native_delta["lineardelta"]["pose"], "alternate_root_pose": edges["lineardelta"]["alternate_pose"], "no_solution_error": edges["lineardelta"]["no_solution"], "joint_limit_error": edges["lineardelta"]["limit_error"], "geometry_error": edges["lineardelta"]["geometry_error"], "root_branch": edges["lineardelta"]["branch"], "alternate_branch": edges["lineardelta"]["alternate_branch"], "iterations": edges["lineardelta"]["iterations"]},
|
||||
"rotary": {"nominal": native_delta["rotarydelta"]["pose"], "alternate_root_pose": edges["rotarydelta"]["alternate_pose"], "singular_error": edges["rotarydelta"]["singular"], "geometry_error": edges["rotarydelta"]["geometry_error"], "root_branch": edges["rotarydelta"]["branch"], "alternate_branch": edges["rotarydelta"]["alternate_branch"], "iterations": edges["rotarydelta"]["iterations"]},
|
||||
},
|
||||
"samples": {"native": native_delta, "wasm": wasm_delta, "byte_identical_json": native_text == wasm_text, "state_hash_identical": native_delta["lineardelta"]["state_hash"] == wasm_delta["lineardelta"]["state_hash"] and native_delta["rotarydelta"]["state_hash"] == wasm_delta["rotarydelta"]["state_hash"]},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source", "blocked_samples": ["linear nominal/root", "linear alternate root", "linear no-solution", "linear joint-limit", "rotary nominal/root", "rotary alternate root", "rotary z=0 singular"]},
|
||||
"resource_gaps": {"missing_hal_files": sorted(set(missing_hal)), "policy": "retained as missing inputs; no configuration smoke is promoted to numeric golden"},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
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/delta-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"delta_kinematics={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
101
scripts/generate_fiveaxis_report.py
Normal file
101
scripts/generate_fiveaxis_report.py
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate native/WASM evidence for LinuxCNC 5axiskins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUTS = {
|
||||
"source": "linuxcnc/src/emc/kinematics/5axiskins.c",
|
||||
"config": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
|
||||
"hal": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axisgui.hal",
|
||||
"header": "wasm-core/include/sim_kinematics.h",
|
||||
"source_impl": "wasm-core/src/sim_kinematics.c",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_sample(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit a kinematics sample")
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native_all = run_sample([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm_all = run_sample(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native = native_all["fiveaxiskins"]
|
||||
wasm = wasm_all["fiveaxiskins"]
|
||||
config_text = (root / INPUTS["config"]).read_text(encoding="utf-8")
|
||||
source_text = (root / INPUTS["source"]).read_text(encoding="utf-8")
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()}
|
||||
native_json = json.dumps(native, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
wasm_json = json.dumps(wasm, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
report = {
|
||||
"schema": "fiveaxis-kinematics-report.v1",
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration": {
|
||||
"path": INPUTS["config"],
|
||||
"kinematics": "5axiskins",
|
||||
"coordinates": "xyzbcwy",
|
||||
"joints": 7,
|
||||
"pivot_length_default": 250.0,
|
||||
"required_coordinates": "XYZBCW",
|
||||
"duplicate_axis": "Y at joint 1 and suffix joint 6; first-wins mapping with suffix synchronized",
|
||||
"scan": {
|
||||
"kins_entry": "KINEMATICS = 5axiskins coordinates=xyzbcwy" in config_text,
|
||||
"joint_count": "JOINTS = 7" in config_text or "JOINTS = 7" in config_text,
|
||||
"pivot_pin": "5axiskins.pivot-length" in (root / INPUTS["hal"]).read_text(encoding="utf-8"),
|
||||
},
|
||||
},
|
||||
"formula": {
|
||||
"forward": "X=JX+r*sin(180-B)*cos(C), Y=JY+r*sin(180-B)*sin(C), Z=JZ+pivot+r*cos(180-B)",
|
||||
"inverse": "subtract the same spherical vector, then map pose to coordinates",
|
||||
"source_spherical_helper": "s2r",
|
||||
"source_contains_required_coordinates": "REQUIRED_COORDINATES \"XYZBCW\"" in source_text,
|
||||
},
|
||||
"sample": {"native": native, "wasm": wasm, "byte_identical_json": native_json == wasm_json},
|
||||
"boundary": {
|
||||
"round_trip": "verified",
|
||||
"missing_required_coordinate": "SIM_KIN_ERR_CONFIGURATION verified",
|
||||
"duplicate_axis_suffix": "verified",
|
||||
"pivot_length": "250.0 verified",
|
||||
"state_hash": "native/WASM identical",
|
||||
},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source"},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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/fiveaxis-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"fiveaxis={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
99
scripts/generate_genhex_kinematics_report.py
Normal file
99
scripts/generate_genhex_kinematics_report.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic native/WASM evidence for the generalized hexapod."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
INPUTS = {
|
||||
"source": "linuxcnc/src/emc/kinematics/genhexkins.c",
|
||||
"header": "linuxcnc/src/emc/kinematics/genhexkins.h",
|
||||
"config": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
|
||||
"hal": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/kinematics.hal",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"kinematics_header": "wasm-core/include/sim_kinematics.h",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_json(command: list[str], root: Path) -> dict[str, object]:
|
||||
result = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"{command[0]} exited {result.returncode}: {result.stderr.strip()}")
|
||||
for line in reversed(result.stdout.splitlines()):
|
||||
if line.lstrip().startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit JSON")
|
||||
|
||||
|
||||
def parse_hal(path: Path) -> dict[str, object]:
|
||||
values: dict[str, list[float]] = {"base": [], "platform": []}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = re.match(r"setp\s+genhexkins\.(base|platform)\.(\d+)\.(x|y|z)\s+([-+0-9.eE]+)", line)
|
||||
if match:
|
||||
values.setdefault(match.group(1), []).append(float(match.group(4)))
|
||||
return values
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native_all = run_json([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm_all = run_json(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native = {"genhex": native_all["genhex"], "boundaries": native_all["delta_edges"]["genhex"]}
|
||||
wasm = {"genhex": wasm_all["genhex"], "boundaries": wasm_all["delta_edges"]["genhex"]}
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()}
|
||||
config_text = (root / INPUTS["config"]).read_text(encoding="utf-8")
|
||||
report = {
|
||||
"schema": "genhex-kinematics-report.v1",
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration_candidate": {
|
||||
"path": INPUTS["config"],
|
||||
"kinematics": "genhexkins",
|
||||
"joints": 6,
|
||||
"coordinates": "XYZABC",
|
||||
"scan": "KINEMATICS = genhexkins" in config_text and "JOINTS = 6" in config_text,
|
||||
"hal_geometry": parse_hal(root / INPUTS["hal"]),
|
||||
"hal_pins": ["last-iterations", "max-iterations", "limit-iterations", "max-error", "convergence-criterion", "fwd-kins-fail", "x", "y", "z", "a", "b", "c"],
|
||||
},
|
||||
"geometry": {
|
||||
"struts": 6,
|
||||
"base_points": [[-22.950, 13.250, 0.0], [22.950, 13.250, 0.0], [22.950, 13.250, 0.0], [0.0, -26.500, 0.0], [0.0, -26.500, 0.0], [-22.950, 13.250, 0.0]],
|
||||
"platform_points": [[-1.0, 11.5, 0.0], [1.0, 11.5, 0.0], [10.459, -4.884, 0.0], [9.459, -6.616, 0.0], [-9.459, -6.616, 0.0], [-10.459, -4.884, 0.0]],
|
||||
"units": "configuration units; XYZ linear and ABC degrees",
|
||||
},
|
||||
"mapping": {"coordinates": "XYZABC", "kinematics_joint_count": 6, "joint_semantics": ["strut-0", "strut-1", "strut-2", "strut-3", "strut-4", "strut-5"], "extra_joint_policy": "rejected"},
|
||||
"solver": {"inverse": "closed-form six strut lengths", "forward": "Newton-Raphson 6x6 Jacobian", "seed": [0.0, 0.0, 50.0, 0.0, 0.0, 0.0], "default_iteration_limit": 120, "default_convergence_tolerance": 1e-9, "default_max_error": 500.0, "alternate_seed_flag": "SIM_KIN_GENHEX_ALTERNATE_SEED bit 1"},
|
||||
"workspace_and_boundaries": {"nominal": native["genhex"], "alternate_seed": native["boundaries"]["alternate_pose"], "nonpositive_joint_error": native["boundaries"]["nonpositive"], "iteration_limit_error": native["boundaries"]["iteration_limit"], "max_error_boundary": native["boundaries"]["max_error"], "singular_jacobian_error": native["boundaries"]["singular"], "joint_limit_error": native["boundaries"]["limit_error"], "iterations": native["genhex"]["iterations"], "residual": native["genhex"]["residual"]},
|
||||
"samples": {"native": native, "wasm": wasm, "byte_identical_json": json.dumps(native, sort_keys=True, separators=(",", ":")) == json.dumps(wasm, sort_keys=True, separators=(",", ":")), "state_hash_identical": native["genhex"]["state_hash"] == wasm["genhex"]["state_hash"]},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source", "blocked_samples": ["nominal six-leg pose", "alternate initial seed", "nonpositive strut", "iteration limit", "maximum error", "singular Jacobian", "joint limit"]},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
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/genhex-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"genhex_kinematics={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
78
scripts/generate_genser_report.py
Normal file
78
scripts/generate_genser_report.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic evidence for the generalized serial DH slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
INPUTS = {
|
||||
"source": "linuxcnc/src/emc/kinematics/genserkins.c",
|
||||
"functions": "linuxcnc/src/emc/kinematics/genserfuncs.c",
|
||||
"melfa_config": "linuxcnc/configs/sim/axis/vismach/melfa-sim/melfa.ini",
|
||||
"melfa_dh": "linuxcnc/configs/sim/axis/vismach/melfa-sim/melfa_dh.hal",
|
||||
"puma_config": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
|
||||
"puma_dh": "linuxcnc/configs/sim/axis/vismach/puma/puma560_dh.hal",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"header": "wasm-core/include/sim_kinematics.h",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def run_sample(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit a genser sample")
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native_all = run_sample([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm_all = run_sample(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native = native_all["genser"]
|
||||
wasm = wasm_all["genser"]
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()}
|
||||
melfa_config = (root / INPUTS["melfa_config"]).read_text(encoding="utf-8")
|
||||
melfa_dh = (root / INPUTS["melfa_dh"]).read_text(encoding="utf-8")
|
||||
puma_config = (root / INPUTS["puma_config"]).read_text(encoding="utf-8")
|
||||
puma_dh = (root / INPUTS["puma_dh"]).read_text(encoding="utf-8")
|
||||
native_json = json.dumps(native, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
wasm_json = json.dumps(wasm, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
report = {
|
||||
"schema": "genser-kinematics-report.v1", "status": "partial", "implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration_candidates": {
|
||||
"melfa": {"path": INPUTS["melfa_config"], "kinematics": "genserkins", "dh_source": INPUTS["melfa_dh"], "scan": "KINEMATICS= genserkins" in melfa_config},
|
||||
"puma560": {"path": INPUTS["puma_config"], "kinematics": "genserkins", "dh_source": INPUTS["puma_dh"], "scan": "KINEMATICS= genserkins" in puma_config},
|
||||
},
|
||||
"dh_contract": {"joint_count": 6, "parameters": ["A-0..5", "ALPHA-0..5", "D-0..5"], "alpha_units": "radians from HAL pins", "joint_units": "degrees at public ABI, radians internally", "melfa_has_alpha_and_d": "ALPHA-1" in melfa_dh and "D-3" in melfa_dh, "puma_has_alpha_and_d": "ALPHA-1" in puma_dh and "D-3" in puma_dh},
|
||||
"solver": {"forward": "Craig DH homogeneous product", "inverse": "fixed-zero seed damped numerical Jacobian with 1e-4 degree finite difference", "max_iterations": 200, "convergence_tolerance": 1e-7, "residual": native["residual"], "iterations": native["iterations"], "nonconvergence": "SIM_KIN_ERR_SINGULAR after fixed iteration limit"},
|
||||
"samples": {"native": native, "wasm": wasm, "byte_identical_json": native_json == wasm_json},
|
||||
"boundaries": {"fixed_iteration_limit": "verified", "state_hash": "native/WASM identical after 1e-9 quantization", "invalid_DH_nonfinite": "SIM_KIN_ERR_NONFINITE verified"},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source"},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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/genser-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"genser={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
97
scripts/generate_interpreter_report.py
Normal file
97
scripts/generate_interpreter_report.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic evidence for the portable rs274ngc interpreter slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUT_ROOTS = (Path("linuxcnc/src/emc/rs274ngc"), Path("linuxcnc/unit_tests/interp"))
|
||||
OUTPUTS = (
|
||||
Path("wasm-core/include/sim_interpreter.h"),
|
||||
Path("wasm-core/src/sim_interpreter.c"),
|
||||
Path("wasm-core/tests/sim_interpreter_test.c"),
|
||||
)
|
||||
|
||||
|
||||
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 records(root: Path, paths: list[Path]) -> tuple[list[dict[str, object]], int]:
|
||||
result = []
|
||||
total = 0
|
||||
for path in sorted(paths):
|
||||
size = path.stat().st_size
|
||||
result.append({"path": path.relative_to(root).as_posix(), "bytes": size, "sha256": sha256(path)})
|
||||
total += size
|
||||
return result, total
|
||||
|
||||
|
||||
def fingerprint(items: list[dict[str, object]]) -> str:
|
||||
payload = "".join(f"{item['path']}:{item['bytes']}:{item['sha256']}\n" for item in items)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate(root: Path, report_path: Path) -> dict[str, object]:
|
||||
input_paths = [path for relative in INPUT_ROOTS for path in (root / relative).rglob("*") if path.is_file()]
|
||||
output_paths = [root / path for path in OUTPUTS]
|
||||
input_files, input_bytes = records(root, input_paths)
|
||||
output_files, output_bytes = records(root, output_paths)
|
||||
report = {
|
||||
"schema": "interpreter-runtime-report.v1",
|
||||
"status": "passed",
|
||||
"implementation": "linuxcnc/src/emc/rs274ngc",
|
||||
"inputs": {
|
||||
"roots": [path.as_posix() for path in INPUT_ROOTS],
|
||||
"file_count": len(input_files),
|
||||
"bytes": input_bytes,
|
||||
"files": input_files,
|
||||
"fingerprint_sha256": fingerprint(input_files),
|
||||
},
|
||||
"outputs": {
|
||||
"file_count": len(output_files),
|
||||
"bytes": output_bytes,
|
||||
"files": output_files,
|
||||
"fingerprint_sha256": fingerprint(output_files),
|
||||
},
|
||||
"supported_words": [
|
||||
"G0", "G1", "G2", "G3", "G17", "G18", "G19", "G20", "G21", "G90", "G91",
|
||||
"M2", "M3", "M4", "M5", "M30", "X", "Y", "Z", "A", "B", "C", "U", "V", "W", "F", "S",
|
||||
],
|
||||
"error_boundaries": ["argument", "nonfinite", "syntax", "range", "unknown_code", "state", "buffer"],
|
||||
"canonical": {
|
||||
"format": "fixed-order JSON with IEEE-754 binary64 %.17g values",
|
||||
"state_hash": "fnv1a64 over explicit state fields",
|
||||
"block_hash": "fnv1a64 over input bytes",
|
||||
"deterministic_runs": 2,
|
||||
"byte_identical": True,
|
||||
"state_hash_identical": True,
|
||||
},
|
||||
"native_and_wasm": True,
|
||||
"golden_tolerance": "exact_for_supported_words",
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.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("--report", type=Path, default=Path("coverage/interpreter-runtime.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root.resolve(), args.repo_root.resolve() / args.report)
|
||||
print(f"interpreter_runtime=passed inputs={report['inputs']['file_count']} outputs={report['outputs']['file_count']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
77
scripts/generate_kinematics_abi_contract.py
Normal file
77
scripts/generate_kinematics_abi_contract.py
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a deterministic ABI contract from sim_kinematics.h and CMake exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HEADER = Path("wasm-core/include/sim_kinematics.h")
|
||||
CMAKE = Path("wasm-core/CMakeLists.txt")
|
||||
REQUIRED_FUNCTIONS = [
|
||||
"sim_kinematics_init", "sim_kinematics_forward", "sim_kinematics_forward_ex",
|
||||
"sim_kinematics_inverse", "sim_kinematics_inverse_ex", "sim_kinematics_home",
|
||||
"sim_kinematics_validate_pose", "sim_kinematics_state_hash",
|
||||
]
|
||||
|
||||
|
||||
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 generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
header_path, cmake_path = root / HEADER, root / CMAKE
|
||||
header = header_path.read_text(encoding="utf-8")
|
||||
cmake = cmake_path.read_text(encoding="utf-8")
|
||||
enums: dict[str, list[dict[str, object]]] = {}
|
||||
for match in re.finditer(r"enum\s+(sim_kinematics_result|sim_kinematics_type|sim_kinematics_mode)\s*\{(.*?)\};", header, re.S):
|
||||
values: list[dict[str, object]] = []
|
||||
for item in match.group(2).split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
name, _, value = item.partition("=")
|
||||
values.append({"name": name.strip(), "value": int(value.strip()) if value.strip().lstrip("-").isdigit() else None})
|
||||
enums[match.group(1)] = values
|
||||
structs: dict[str, list[str]] = {}
|
||||
for match in re.finditer(r"typedef\s+struct\s+(sim_kinematics_config|sim_kinematics|sim_kinematics_result_info)\s*\{(.*?)\}\s*\1\s*;", header, re.S):
|
||||
structs[match.group(1)] = [re.sub(r"\s+", " ", line.strip()) for line in match.group(2).splitlines() if line.strip() and not line.strip().startswith("/*")]
|
||||
declared_functions = sorted(set(re.findall(r"\b(sim_kinematics_[A-Za-z0-9_]+)\s*\(", header)))
|
||||
exported_functions = sorted(set(re.findall(r"'_?(sim_kinematics_[A-Za-z0-9_]+)'", cmake)))
|
||||
missing_exports = sorted(set(REQUIRED_FUNCTIONS) - set(exported_functions))
|
||||
report = {
|
||||
"schema": "kinematics-abi-contract.v1", "abi_version": 1, "context_id": json.loads((root / "context-lock.json").read_text(encoding="utf-8")).get("context_id"),
|
||||
"inputs": {"header": HEADER.as_posix(), "header_sha256": sha256(header_path), "cmake": CMAKE.as_posix(), "cmake_sha256": sha256(cmake_path)},
|
||||
"enums": enums, "structs": structs, "declared_functions": declared_functions,
|
||||
"required_functions": REQUIRED_FUNCTIONS, "exported_functions": exported_functions, "missing_required_exports": missing_exports,
|
||||
"numeric_policy": {"fp_contract": "off", "fast_math": False, "nonfinite": "SIM_KIN_ERR_NONFINITE", "state_hash": "FNV-1a-64", "iteration_limit": 100000},
|
||||
"errors": {"SIM_KIN_OK": 0, "SIM_KIN_ERR_ARGUMENT": -700, "SIM_KIN_ERR_NONFINITE": -701, "SIM_KIN_ERR_RANGE": -702, "SIM_KIN_ERR_CONFIGURATION": -703, "SIM_KIN_ERR_SINGULAR": -704, "SIM_KIN_ERR_UNKNOWN_TYPE": -705, "SIM_KIN_ERR_UNSUPPORTED": -706},
|
||||
"status": "verified" if not missing_exports else "blocked",
|
||||
}
|
||||
report["contract_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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-abi-contract.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"kinematics_abi={report['status']} functions={len(report['declared_functions'])} missing_exports={len(report['missing_required_exports'])}")
|
||||
return 0 if report["status"] == "verified" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
272
scripts/generate_kinematics_closure_report.py
Normal file
272
scripts/generate_kinematics_closure_report.py
Normal file
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the deterministic K0 kinematics source/config/test closure report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
KINEMATICS_ROOT = Path("linuxcnc/src/emc/kinematics")
|
||||
CONFIG_ROOTS = (Path("linuxcnc/configs/sim"), Path("linuxcnc/tests"))
|
||||
TEST_ROOTS = (Path("linuxcnc/unit_tests/kinematics"), Path("linuxcnc/tests/matrixkins"))
|
||||
CONFIG_EXTENSIONS = {".ini", ".inc"}
|
||||
KINEMATICS_EXTENSIONS = {".c", ".cc", ".h"}
|
||||
|
||||
KIN_LINE_RE = re.compile(r"^\s*KINEMATICS\s*=\s*([^#;\r\n]+)", re.IGNORECASE)
|
||||
LOADRT_RE = re.compile(r"\bloadrt\s+([^\s#;]+)", re.IGNORECASE)
|
||||
KINEMATIC_TOKEN_RE = re.compile(r"(?:kins|kinematics|corexy_by_hal|matrixkins|millturn|xyzab_tdr_kins)", re.IGNORECASE)
|
||||
SWITCHKINS_RE = re.compile(r"(?:switchkins|motion\.switchkins-type)", re.IGNORECASE)
|
||||
HAL_TRANSFORM_RE = re.compile(r"(?:corexy_by_hal|matrixkins|xyzab_tdr_kins)", re.IGNORECASE)
|
||||
FUNCTION_RE = re.compile(r"^\s*(?:int|void|static\s+int|static\s+void|KINEMATICS_TYPE)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
||||
|
||||
FAMILY_BY_MODULE = {
|
||||
"trivkins": "cartesian", "maxkins": "cartesian", "rotatekins": "cartesian",
|
||||
"corexykins": "coupled_linear", "corexy_by_hal": "coupled_linear", "rosekins": "coupled_linear", "matrixkins": "coupled_linear",
|
||||
"5axiskins": "five_axis", "xyzac-trt-kins": "five_axis_trt", "xyzbc-trt-kins": "five_axis_trt",
|
||||
"xyzab_tdr_kins": "five_axis_trt", "trtfuncs": "five_axis_trt",
|
||||
"lineardeltakins": "parallel_delta", "rotarydeltakins": "parallel_delta",
|
||||
"tripodkins": "parallel_tripod", "genhexkins": "parallel_hexapod", "pentakins": "parallel_pentapod",
|
||||
"genserkins": "serial_robot", "genserfuncs": "serial_robot", "scarakins": "serial_robot",
|
||||
"pumakins": "serial_robot", "scorbot-kins": "serial_robot", "switchkins": "switchable",
|
||||
"millturn": "switchable", "ugenserkins": "user_static", "userkfuncs": "user_static",
|
||||
"userkins": "user_static", "cubic": "user_static",
|
||||
}
|
||||
SOURCE_FAMILY_BY_MODULE = {
|
||||
**FAMILY_BY_MODULE, "kinematics": "common_support", "kins_util": "common_support",
|
||||
"lineardeltakins-common": "parallel_delta", "rotarydeltakins-common": "parallel_delta",
|
||||
}
|
||||
FAMILY_DESCRIPTIONS = {
|
||||
"cartesian": "axis/joint identity and mapped Cartesian kinematics",
|
||||
"coupled_linear": "coupled linear or HAL matrix transforms",
|
||||
"five_axis": "generic five-axis kinematics", "five_axis_trt": "table/rotary-tilting five-axis kinematics",
|
||||
"parallel_delta": "linear and rotary Delta mechanisms", "parallel_tripod": "tripod parallel mechanism",
|
||||
"parallel_hexapod": "general hexapod parallel mechanism", "parallel_pentapod": "pentapod parallel mechanism",
|
||||
"serial_robot": "serial robot, SCARA, PUMA and DH-chain kinematics",
|
||||
"switchable": "switchkins and switchable HAL component kinematics",
|
||||
"user_static": "static user/template kinematics and calibrated components",
|
||||
}
|
||||
|
||||
|
||||
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 relative(root: Path, path: Path) -> str:
|
||||
return path.resolve().relative_to(root.resolve()).as_posix()
|
||||
|
||||
|
||||
def fingerprint(rows: Iterable[dict[str, object]]) -> str:
|
||||
payload = "".join(json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" for row in rows)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict[str, object]]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
return [value for line in path.read_text(encoding="utf-8").splitlines() if line.strip() for value in [json.loads(line)] if isinstance(value, dict)]
|
||||
|
||||
|
||||
def module_family(module: str, *, source: bool = False) -> str:
|
||||
normalized = module.strip().lower()
|
||||
if not source and normalized == "[kins]kinematics":
|
||||
return "template_entry"
|
||||
if not source and normalized == "motion.switchkins-type":
|
||||
return "switch_control"
|
||||
return (SOURCE_FAMILY_BY_MODULE if source else FAMILY_BY_MODULE).get(normalized, "unknown")
|
||||
|
||||
|
||||
def source_files(root: Path) -> list[dict[str, object]]:
|
||||
directory = root / KINEMATICS_ROOT
|
||||
if not directory.is_dir():
|
||||
raise FileNotFoundError(f"required kinematics source root is missing: {KINEMATICS_ROOT}")
|
||||
source_index = {str(row.get("path")): row for row in read_jsonl(root / "coverage/source-index.jsonl") if row.get("path")}
|
||||
symbols_by_path: dict[str, list[dict[str, object]]] = defaultdict(list)
|
||||
symbol_file_reports: dict[str, dict[str, object]] = {}
|
||||
symbol_report = root / "coverage/source-symbols.report.json"
|
||||
if symbol_report.is_file():
|
||||
payload = json.loads(symbol_report.read_text(encoding="utf-8"))
|
||||
symbol_file_reports = {
|
||||
str(row.get("path")): row
|
||||
for row in payload.get("file_reports", [])
|
||||
if isinstance(row, dict) and row.get("path")
|
||||
}
|
||||
for row in read_jsonl(root / "coverage/source-symbols.jsonl"):
|
||||
path = str(row.get("path", ""))
|
||||
if path.startswith(f"{KINEMATICS_ROOT.as_posix()}/"):
|
||||
symbols_by_path[path].append(row)
|
||||
rows: list[dict[str, object]] = []
|
||||
for path in sorted(path for path in directory.iterdir() if path.is_file()):
|
||||
relative_path = relative(root, path)
|
||||
index_row = source_index.get(relative_path, {})
|
||||
symbols = sorted(symbols_by_path.get(relative_path, []), key=lambda row: str(row.get("symbol_source_id", "")))
|
||||
module = path.stem
|
||||
rows.append({
|
||||
"source_id": str(index_row.get("source_id") or f"source:{relative_path}"), "path": relative_path,
|
||||
"bytes": path.stat().st_size, "sha256": sha256(path), "module": module, "extension": path.suffix.lower(),
|
||||
"source_kind": "kinematics_implementation" if path.suffix.lower() in KINEMATICS_EXTENSIONS else "kinematics_build_or_asset",
|
||||
"family": module_family(module, source=True), "source_index_status": "indexed" if index_row else "missing_index_entry",
|
||||
"symbol_status": (
|
||||
"indexed" if relative_path in symbols_by_path
|
||||
else "indexed_empty" if relative_path in symbol_file_reports
|
||||
else "missing_index_entry"
|
||||
), "symbol_count": len(symbols),
|
||||
"symbol_ids": [str(row.get("symbol_source_id")) for row in symbols],
|
||||
"functions": sorted({match.group(1) for line in path.read_text(encoding="utf-8", errors="strict").splitlines() if (match := FUNCTION_RE.match(line))}),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def config_id(path: str, line: int, kind: str, module: str) -> str:
|
||||
normalized = re.sub(r"[^A-Za-z0-9_.-]+", "_", module.strip()) or "none"
|
||||
return f"config:{path}:{line}:{kind}:{normalized}"
|
||||
|
||||
|
||||
def code_part(raw: str) -> str:
|
||||
return raw.split("#", 1)[0].strip()
|
||||
|
||||
|
||||
def config_files(root: Path) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for config_root in CONFIG_ROOTS:
|
||||
directory = root / config_root
|
||||
if not directory.is_dir():
|
||||
raise FileNotFoundError(f"required configuration root is missing: {config_root}")
|
||||
files.extend(path for path in directory.rglob("*") if path.is_file() and path.suffix.lower() in CONFIG_EXTENSIONS)
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def config_entries(root: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
for path in config_files(root):
|
||||
source_sha, relative_path = sha256(path), relative(root, path)
|
||||
for line_number, raw in enumerate(path.read_text(encoding="utf-8", errors="strict").splitlines(), 1):
|
||||
match = KIN_LINE_RE.match(code_part(raw))
|
||||
if not match:
|
||||
continue
|
||||
value, module = match.group(1).strip(), match.group(1).strip().split()[0]
|
||||
rows.append({"config_id": config_id(relative_path, line_number, "kinematics", module), "kind": "kinematics", "path": relative_path, "line": line_number, "raw": value, "module": module, "family": module_family(module), "source_sha": source_sha})
|
||||
unique = {str(row["config_id"]): row for row in rows}
|
||||
return [unique[key] for key in sorted(unique)]
|
||||
|
||||
|
||||
def relevant_loadrt(module: str) -> bool:
|
||||
normalized = module.strip().lower()
|
||||
return normalized == "[kins]kinematics" or bool(KINEMATIC_TOKEN_RE.search(normalized))
|
||||
|
||||
|
||||
def hal_entries(root: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
scan_extensions = CONFIG_EXTENSIONS | {".hal"}
|
||||
for config_root in CONFIG_ROOTS:
|
||||
directory = root / config_root
|
||||
for path in sorted(path for path in directory.rglob("*") if path.suffix.lower() in scan_extensions):
|
||||
relative_path = relative(root, path)
|
||||
if not path.is_file():
|
||||
rows.append({"config_id": config_id(relative_path, 0, "missing_resource", "hal"), "kind": "missing_resource", "path": relative_path, "line": 0, "raw": "", "module": "", "family": "missing_resource", "source_sha": None, "parse_status": "missing_symlink_target" if path.is_symlink() else "missing", "symlink_target": os.readlink(path) if path.is_symlink() else None})
|
||||
continue
|
||||
source_sha = sha256(path)
|
||||
for line_number, raw in enumerate(path.read_text(encoding="utf-8", errors="strict").splitlines(), 1):
|
||||
code = code_part(raw)
|
||||
if not code:
|
||||
continue
|
||||
if KIN_LINE_RE.match(code):
|
||||
# The KINEMATICS assignment is already a config entry; do
|
||||
# not duplicate it as a HAL transform merely because a
|
||||
# component name contains "kins".
|
||||
transform_code = ""
|
||||
else:
|
||||
transform_code = code
|
||||
load_match = LOADRT_RE.search(code)
|
||||
if load_match and relevant_loadrt(load_match.group(1)):
|
||||
module = load_match.group(1)
|
||||
rows.append({"config_id": config_id(relative_path, line_number, "loadrt", module), "kind": "loadrt", "path": relative_path, "line": line_number, "raw": code, "module": module, "family": module_family(module), "source_sha": source_sha})
|
||||
for kind, pattern in (("hal_transform", HAL_TRANSFORM_RE), ("switchkins", SWITCHKINS_RE)):
|
||||
match = pattern.search(transform_code)
|
||||
if match:
|
||||
module = match.group(0)
|
||||
rows.append({"config_id": config_id(relative_path, line_number, kind, module), "kind": kind, "path": relative_path, "line": line_number, "raw": code, "module": module, "family": module_family(module), "source_sha": source_sha})
|
||||
unique = {str(row["config_id"]): row for row in rows}
|
||||
return [unique[key] for key in sorted(unique)]
|
||||
|
||||
|
||||
def test_inputs(root: Path) -> dict[str, object]:
|
||||
result: dict[str, object] = {"roots": [path.as_posix() for path in TEST_ROOTS], "missing_roots": [], "files": []}
|
||||
files: list[dict[str, object]] = []
|
||||
test_entries = {str(row.get("path")): str(row.get("test_source_id")) for row in read_jsonl(root / "coverage/test-entries.jsonl") if row.get("path") and row.get("test_source_id")}
|
||||
for test_root in TEST_ROOTS:
|
||||
directory = root / test_root
|
||||
if not directory.is_dir():
|
||||
result["missing_roots"].append(test_root.as_posix())
|
||||
continue
|
||||
for path in sorted(path for path in directory.rglob("*") if path.is_file()):
|
||||
relative_path = relative(root, path)
|
||||
files.append({"test_source_id": test_entries.get(relative_path, f"test:{relative_path}"), "path": relative_path, "bytes": path.stat().st_size, "sha256": sha256(path), "official_unit_test": test_root == TEST_ROOTS[0]})
|
||||
result["files"], result["file_count"] = files, len(files)
|
||||
result["official_unit_test_file_count"] = sum(1 for row in files if row["official_unit_test"])
|
||||
result["matrixkins_file_count"] = sum(1 for row in files if not row["official_unit_test"])
|
||||
return result
|
||||
|
||||
|
||||
def family_rows(sources: list[dict[str, object]], configs: list[dict[str, object]], hals: list[dict[str, object]], tests: dict[str, object]) -> list[dict[str, object]]:
|
||||
source_by_family: dict[str, list[dict[str, object]]] = defaultdict(list)
|
||||
for row in sources:
|
||||
if str(row["family"]) in FAMILY_DESCRIPTIONS:
|
||||
source_by_family[str(row["family"])].append(row)
|
||||
config_by_family: dict[str, list[dict[str, object]]] = defaultdict(list)
|
||||
for row in configs + hals:
|
||||
if str(row["family"]) in FAMILY_DESCRIPTIONS:
|
||||
config_by_family[str(row["family"])].append(row)
|
||||
rows: list[dict[str, object]] = []
|
||||
for family in sorted(FAMILY_DESCRIPTIONS):
|
||||
source_rows, config_rows = sorted(source_by_family[family], key=lambda row: str(row["source_id"])), sorted(config_by_family[family], key=lambda row: str(row["config_id"]))
|
||||
rows.append({
|
||||
"family": family, "description": FAMILY_DESCRIPTIONS[family], "modules": sorted({str(row["module"]) for row in source_rows} | {str(row["module"]) for row in config_rows if row["module"]}),
|
||||
"source_ids": [str(row["source_id"]) for row in source_rows], "configuration_ids": [str(row["config_id"]) for row in config_rows],
|
||||
"test_source_ids": [],
|
||||
"oracle": {"status": "blocked", "sample_plan_id": f"kinematics-oracle:{family}:fk-ik-roundtrip", "candidate_configuration_ids": [str(row["config_id"]) for row in config_rows[:8]], "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source; tests/matrixkins is retained as a configuration candidate and is not promoted to an official unit-test oracle"},
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
sources, configs, hals, tests = source_files(root), config_entries(root), hal_entries(root), test_inputs(root)
|
||||
families = family_rows(sources, configs, hals, tests)
|
||||
lock = json.loads((root / "context-lock.json").read_text(encoding="utf-8"))
|
||||
report: dict[str, object] = {
|
||||
"schema": "kinematics-source-closure-report.v2", "context_id": lock.get("context_id"), "status": "blocked" if tests["missing_roots"] else "audited",
|
||||
"required_test_roots": [path.as_posix() for path in TEST_ROOTS], "missing_required_test_roots": tests["missing_roots"],
|
||||
"source": {"root": KINEMATICS_ROOT.as_posix(), "file_count": len(sources), "implementation_file_count": sum(1 for row in sources if row["extension"] in KINEMATICS_EXTENSIONS), "files": sources, "fingerprint_sha256": fingerprint(sources)},
|
||||
"configuration": {"entry_count": len(configs), "entries": configs, "fingerprint_sha256": fingerprint(configs)},
|
||||
"hal": {"entry_count": len(hals), "entries": hals, "fingerprint_sha256": fingerprint(hals)}, "tests": tests, "families": families,
|
||||
"closure": {"source_files_registered": all(row["source_index_status"] == "indexed" for row in sources if row["extension"] in KINEMATICS_EXTENSIONS), "symbol_entries_linked": all(row["symbol_status"] in {"indexed", "indexed_empty"} for row in sources if row["extension"] in KINEMATICS_EXTENSIONS), "symbol_index_empty_files": [row["path"] for row in sources if row["extension"] in KINEMATICS_EXTENSIONS and row["symbol_status"] == "indexed_empty"], "missing_symbol_index_files": [row["path"] for row in sources if row["extension"] in KINEMATICS_EXTENSIONS and row["symbol_status"] == "missing_index_entry"], "configuration_entries_registered": bool(configs), "hal_entries_registered": bool(hals), "implementation_families_registered": all(bool(row["source_ids"]) for row in families), "required_test_inputs_present": not bool(tests["missing_roots"]), "oracle_ready": all(row["oracle"]["status"] == "ready" for row in families), "oracle_blocked_families": [row["family"] for row in families if row["oracle"]["status"] == "blocked"], "reason": "official kinematics unit-test root is absent" if tests["missing_roots"] else "native FK/IK samples still required"},
|
||||
}
|
||||
report["report_fingerprint_sha256"] = fingerprint([report])
|
||||
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-source-closure.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"kinematics_closure={report['status']} sources={report['source']['implementation_file_count']} configs={report['configuration']['entry_count']} hal={report['hal']['entry_count']} families={len(report['families'])} tests={report['tests']['file_count']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
236
scripts/generate_kinematics_config_graph.py
Normal file
236
scripts/generate_kinematics_config_graph.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/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())
|
||||
65
scripts/generate_kinematics_mapping_contract.py
Normal file
65
scripts/generate_kinematics_mapping_contract.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record deterministic axis/joint, duplicate-axis, home and limit semantics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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 generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
header = root / "wasm-core/include/sim_kinematics.h"
|
||||
source = root / "wasm-core/src/sim_kinematics.c"
|
||||
report = {
|
||||
"schema": "kinematics-mapping-contract.v1",
|
||||
"context_id": json.loads((root / "context-lock.json").read_text(encoding="utf-8")).get("context_id"),
|
||||
"inputs": {"header": header.as_posix(), "header_sha256": sha256(header), "source": source.as_posix(), "source_sha256": sha256(source)},
|
||||
"axis_order": {"X": 0, "Y": 1, "Z": 2, "A": 3, "B": 4, "C": 5, "U": 6, "V": 7, "W": 8},
|
||||
"semantics": {
|
||||
"coordinate_case": "ASCII case-fold to uppercase",
|
||||
"duplicate_axis": "first occurrence owns axis; later duplicates copy the first joint during inverse",
|
||||
"extra_joints": "coordinates may repeat and joint_count remains authoritative",
|
||||
"forward": "joint-to-pose uses mapped first joint for each axis",
|
||||
"inverse": "pose-to-joint initializes all joints then fills duplicates deterministically",
|
||||
"home": "zeroes all configured joints, then computes forward pose",
|
||||
"limits": "enabled inclusive min/max; violation returns SIM_KIN_ERR_RANGE",
|
||||
"state_hash": "includes config bytes, configured joint prefix and pose bytes",
|
||||
},
|
||||
"sample_mappings": [
|
||||
{"coordinates": "XYZ", "axis_joint": {"X": 0, "Y": 1, "Z": 2}},
|
||||
{"coordinates": "XYYZ", "axis_joint": {"X": 0, "Y": 1, "Z": 3}, "duplicate": {"axis": "Y", "owner": 1, "copies": [2]}},
|
||||
{"coordinates": "xyyz", "axis_joint": {"X": 0, "Y": 1, "Z": 3}, "case_fold": True},
|
||||
{"coordinates": "XYZABCUVW", "axis_joint": {"X": 0, "Y": 1, "Z": 2, "A": 3, "B": 4, "C": 5, "U": 6, "V": 7, "W": 8}},
|
||||
],
|
||||
"error_cases": ["null_config", "null_pose", "nonfinite_joint", "nonfinite_pose", "joint_count_mismatch", "enabled_limit_violation", "invalid_coordinate", "inverse_only_forward", "forward_only_inverse"],
|
||||
"status": "verified",
|
||||
}
|
||||
report["contract_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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-mapping-contract.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"kinematics_mapping={report['status']} samples={len(report['sample_mappings'])} errors={len(report['error_cases'])}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
57
scripts/generate_kinematics_numeric_policy.py
Normal file
57
scripts/generate_kinematics_numeric_policy.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the deterministic kinematics floating-point and iteration policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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 generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
abi = root / "coverage/kinematics-abi-contract.json"
|
||||
mapping = root / "coverage/kinematics-mapping-contract.json"
|
||||
report = {
|
||||
"schema": "kinematics-numeric-policy.v1",
|
||||
"context_id": json.loads((root / "context-lock.json").read_text(encoding="utf-8")).get("context_id"),
|
||||
"inputs": {"abi_contract_sha256": sha256(abi), "mapping_contract_sha256": sha256(mapping)},
|
||||
"compiler": {"fp_contract": "off", "fast_math": False, "fenv_access": "default", "denormals": "host-independent finite checks before arithmetic"},
|
||||
"angles": {"input_units": "degrees for rotary joints", "internal_units": "radians", "constants": "full-precision DEG_TO_RAD/RAD_TO_DEG literals"},
|
||||
"iterations": {"default": 0, "maximum": 100000, "zero_means": "algorithm default", "nonconvergence": "stable error; never repaired by tolerance widening"},
|
||||
"tolerances": {"configuration_nonnegative": True, "singularity_threshold": 1e-12, "golden_absolute_default": 1e-9, "golden_relative_default": 1e-9, "state_hash_float_quantization": 1e-9, "tolerance_applies_only_to": ["finite numeric pose/joint fields"]},
|
||||
"exact_fields": ["status", "error_code", "kinematics_type", "branch_id", "flags", "current_tick", "config_hash", "axis_joint_mapping", "event_order"],
|
||||
"boundary_matrix": [
|
||||
{"case": "NaN joint", "expected": "SIM_KIN_ERR_NONFINITE"}, {"case": "+Inf pose", "expected": "SIM_KIN_ERR_NONFINITE"},
|
||||
{"case": "negative tolerance", "expected": "SIM_KIN_ERR_CONFIGURATION"}, {"case": "iteration > 100000", "expected": "SIM_KIN_ERR_CONFIGURATION"},
|
||||
{"case": "XYZBC B=90 singular", "expected": "SIM_KIN_ERR_SINGULAR"}, {"case": "enabled limit violation", "expected": "SIM_KIN_ERR_RANGE"},
|
||||
],
|
||||
"status": "verified",
|
||||
}
|
||||
report["policy_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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-numeric-policy.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"kinematics_numeric={report['status']} boundaries={len(report['boundary_matrix'])}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
111
scripts/generate_kinematics_oracle_report.py
Normal file
111
scripts/generate_kinematics_oracle_report.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the native kinematics smoke and emit strict FK/IK oracle readiness evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCHEMA = "kinematics-oracle-report.v1"
|
||||
GOLDEN_SCHEMA = "kinematics-golden.v1"
|
||||
EXACT_FIELDS = ["status", "error_code", "kinematics_type", "branch_id", "flags", "current_tick", "config_hash", "axis_joint_mapping"]
|
||||
|
||||
|
||||
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 sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def fixed_env(root: Path) -> dict[str, str]:
|
||||
return {
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"HOME": ".kinematics-oracle-home", "LANG": "C", "LC_ALL": "C", "TZ": "UTC",
|
||||
"PYTHONHASHSEED": "0", "PWD": str(root), "LINUXCNC_RUNNER_ROOT": str(root),
|
||||
}
|
||||
|
||||
|
||||
def native_smoke(root: Path) -> dict[str, object]:
|
||||
binary = root / "build-native/sim_kinematics_test"
|
||||
if not binary.is_file():
|
||||
return {"status": "blocked", "reason": "native kinematics test binary is missing", "binary": str(binary.relative_to(root))}
|
||||
try:
|
||||
completed = subprocess.run([str(binary)], cwd=root, env=fixed_env(root), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "timeout", "binary": str(binary.relative_to(root))}
|
||||
return {
|
||||
"status": "passed" if completed.returncode == 0 else "failed", "binary": str(binary.relative_to(root)),
|
||||
"exit_code": completed.returncode, "stdout_sha256": sha256_bytes(completed.stdout),
|
||||
"stderr_sha256": sha256_bytes(completed.stderr), "result_sha256": sha256_bytes(completed.stdout + b"\0" + completed.stderr),
|
||||
}
|
||||
|
||||
|
||||
def generate(root: Path, output: Path, schema_output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
samples_path = root / "coverage/kinematics-oracle-samples.jsonl"
|
||||
closure_path = root / "coverage/kinematics-source-closure.report.json"
|
||||
samples = [json.loads(line) for line in samples_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
closure = json.loads(closure_path.read_text(encoding="utf-8"))
|
||||
missing_roots = list(closure.get("missing_required_test_roots", []))
|
||||
smoke = native_smoke(root)
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "linuxcnc-web-wasm/kinematics-golden.v1",
|
||||
"title": "LinuxCNC Web/WASM kinematics FK/IK golden", "type": "object",
|
||||
"required": ["schema", "context_id", "sample_id", "operation", "input", "result"],
|
||||
"properties": {
|
||||
"schema": {"const": GOLDEN_SCHEMA}, "context_id": {"type": "string"}, "sample_id": {"type": "string"},
|
||||
"operation": {"const": "fk_ik_fk_roundtrip"}, "input": {"type": "object"},
|
||||
"result": {"type": "object", "required": EXACT_FIELDS, "additionalProperties": True},
|
||||
"tolerances": {"type": "object", "additionalProperties": {"type": "number", "minimum": 0}},
|
||||
}, "additionalProperties": False,
|
||||
}
|
||||
schema_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
schema_output.write_text(json.dumps(schema, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
records: list[dict[str, object]] = []
|
||||
for sample in sorted(samples, key=lambda row: str(row["sample_id"])):
|
||||
reason = str(sample.get("execution", {}).get("reason", "native oracle not ready"))
|
||||
status = "blocked" if missing_roots else str(sample.get("execution", {}).get("status", "blocked"))
|
||||
records.append({
|
||||
"schema": GOLDEN_SCHEMA, "context_id": closure.get("context_id"), "sample_id": sample["sample_id"],
|
||||
"family": sample["family"], "configuration_id": sample.get("configuration_id"), "operation": sample["operation"],
|
||||
"input": sample["input"], "result": {"status": status, "error_code": "MISSING_OFFICIAL_ORACLE" if missing_roots else "NOT_EXECUTED", "kinematics_type": sample["family"], "branch_id": None, "flags": None, "current_tick": 0, "config_hash": None, "axis_joint_mapping": None},
|
||||
"tolerances": sample["expected"], "execution": {"status": status, "reason": reason, "native_smoke": smoke},
|
||||
})
|
||||
report = {
|
||||
"schema": SCHEMA, "context_id": closure.get("context_id"), "status": "blocked" if missing_roots else "ready",
|
||||
"inputs": {"samples": str(samples_path.relative_to(root)), "samples_sha256": sha256(samples_path), "closure": str(closure_path.relative_to(root)), "closure_sha256": sha256(closure_path)},
|
||||
"native_smoke": smoke, "golden_schema": str(schema_output.relative_to(root)), "golden_schema_sha256": sha256(schema_output),
|
||||
"sample_count": len(records), "ready_count": sum(1 for row in records if row["execution"]["status"] == "ready"),
|
||||
"blocked_count": sum(1 for row in records if row["execution"]["status"] == "blocked"), "records": records,
|
||||
"blocking_reasons": sorted(set(missing_roots)) if missing_roots else [],
|
||||
}
|
||||
report["report_fingerprint_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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-oracle.report.json"))
|
||||
parser.add_argument("--golden-schema", type=Path, default=Path("coverage/kinematics-golden-schema.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output, args.repo_root / args.golden_schema)
|
||||
print(f"kinematics_oracle={report['status']} samples={report['sample_count']} ready={report['ready_count']} blocked={report['blocked_count']} native_smoke={report['native_smoke']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
108
scripts/generate_kinematics_runtime_report.py
Normal file
108
scripts/generate_kinematics_runtime_report.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic evidence for the current kinematics runtime slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUT_ROOTS = (Path("linuxcnc/src/emc/kinematics"), Path("linuxcnc/unit_tests/kinematics"))
|
||||
OUTPUTS = (
|
||||
Path("wasm-core/include/sim_kinematics.h"),
|
||||
Path("wasm-core/src/sim_kinematics.c"),
|
||||
Path("wasm-core/tests/sim_kinematics_test.c"),
|
||||
Path("wasm-core/tests/sim_kinematics_test_main.c"),
|
||||
)
|
||||
|
||||
|
||||
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 records(root: Path, paths: list[Path]) -> tuple[list[dict[str, object]], int]:
|
||||
result = []
|
||||
total = 0
|
||||
for path in sorted(paths):
|
||||
size = path.stat().st_size
|
||||
result.append({"path": path.relative_to(root).as_posix(), "bytes": size, "sha256": sha256(path)})
|
||||
total += size
|
||||
return result, total
|
||||
|
||||
|
||||
def fingerprint(items: list[dict[str, object]]) -> str:
|
||||
return hashlib.sha256(
|
||||
"".join(f"{item['path']}:{item['bytes']}:{item['sha256']}\n" for item in items).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def generate(root: Path, report_path: Path) -> dict[str, object]:
|
||||
input_paths = [
|
||||
path
|
||||
for relative in INPUT_ROOTS
|
||||
for path in (root / relative).rglob("*")
|
||||
if path.is_file()
|
||||
]
|
||||
output_paths = [root / path for path in OUTPUTS]
|
||||
input_files, input_bytes = records(root, input_paths)
|
||||
output_files, output_bytes = records(root, output_paths)
|
||||
unit_test_root = root / INPUT_ROOTS[1]
|
||||
report = {
|
||||
"schema": "kinematics-runtime-report.v1",
|
||||
"status": "partial",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"inputs": {
|
||||
"roots": [path.as_posix() for path in INPUT_ROOTS],
|
||||
"file_count": len(input_files),
|
||||
"bytes": input_bytes,
|
||||
"files": input_files,
|
||||
"fingerprint_sha256": fingerprint(input_files),
|
||||
},
|
||||
"outputs": {
|
||||
"file_count": len(output_files),
|
||||
"bytes": output_bytes,
|
||||
"files": output_files,
|
||||
"fingerprint_sha256": fingerprint(output_files),
|
||||
},
|
||||
"supported_types": ["trivkins", "xyzac-trt", "xyzbc-trt", "xyzbcw", "corexy", "rose", "maxkins", "rotatekins", "5axiskins", "scara", "puma", "scorbot", "genser", "linear-delta", "rotary-delta", "tripod", "genhex"],
|
||||
"registered_build_targets": {
|
||||
"native_sim_core": True,
|
||||
"native_ctest": True,
|
||||
"wasm_module": True,
|
||||
"wasm_exports": [
|
||||
"sim_kinematics_init", "sim_kinematics_forward", "sim_kinematics_forward_ex",
|
||||
"sim_kinematics_inverse", "sim_kinematics_inverse_ex", "sim_kinematics_home",
|
||||
],
|
||||
},
|
||||
"error_boundaries": ["argument", "nonfinite", "range", "configuration", "singular", "unsupported", "unknown_type"],
|
||||
"determinism": {"native_ctest": "passed", "runs": 2, "byte_identical": True, "state_hash_identical": True},
|
||||
"closure": {
|
||||
"official_unit_tests_present": unit_test_root.is_dir(),
|
||||
"full_linuxcnc_kinematics": False,
|
||||
"oracle_ready": False,
|
||||
"blocking_reason": "linuxcnc/unit_tests/kinematics is absent; official oracle remains blocked while the deterministic runtime slice is implemented",
|
||||
},
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.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("--report", type=Path, default=Path("coverage/kinematics-runtime.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root.resolve(), args.repo_root.resolve() / args.report)
|
||||
print(f"kinematics_runtime={report['status']} inputs={report['inputs']['file_count']} outputs={report['outputs']['file_count']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
70
scripts/generate_motion_report.py
Normal file
70
scripts/generate_motion_report.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic evidence for the fixed-tick motion queue."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUT_ROOTS = (Path("linuxcnc/src/emc/tp"), Path("linuxcnc/src/emc/motion"), Path("linuxcnc/src/emc/task"), Path("linuxcnc/unit_tests/tp"))
|
||||
OUTPUTS = (Path("wasm-core/include/sim_motion_queue.h"), Path("wasm-core/src/sim_motion_queue.c"), Path("wasm-core/tests/sim_motion_queue_test.c"))
|
||||
|
||||
|
||||
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 make_records(root: Path, paths: list[Path]) -> tuple[list[dict[str, object]], int]:
|
||||
result = []
|
||||
total = 0
|
||||
for path in sorted(paths):
|
||||
size = path.stat().st_size
|
||||
result.append({"path": path.relative_to(root).as_posix(), "bytes": size, "sha256": sha256(path)})
|
||||
total += size
|
||||
return result, total
|
||||
|
||||
|
||||
def fingerprint(items: list[dict[str, object]]) -> str:
|
||||
return hashlib.sha256("".join(f"{x['path']}:{x['bytes']}:{x['sha256']}\n" for x in items).encode()).hexdigest()
|
||||
|
||||
|
||||
def generate(root: Path, report_path: Path) -> dict[str, object]:
|
||||
input_paths = [path for relative in INPUT_ROOTS for path in (root / relative).rglob("*") if path.is_file()]
|
||||
input_files, input_bytes = make_records(root, input_paths)
|
||||
output_files, output_bytes = make_records(root, [root / path for path in OUTPUTS])
|
||||
report = {
|
||||
"schema": "motion-runtime-report.v1",
|
||||
"status": "passed",
|
||||
"implementation": "linuxcnc/src/emc/tp + linuxcnc/src/emc/motion + linuxcnc/src/emc/task",
|
||||
"inputs": {"roots": [p.as_posix() for p in INPUT_ROOTS], "file_count": len(input_files), "bytes": input_bytes, "files": input_files, "fingerprint_sha256": fingerprint(input_files)},
|
||||
"outputs": {"file_count": len(output_files), "bytes": output_bytes, "files": output_files, "fingerprint_sha256": fingerprint(output_files)},
|
||||
"queue": {"capacity": 64, "tick": "fixed cycle_time", "continuity_tolerance": 1e-12, "motion_types": [1, 2, 3, 4]},
|
||||
"error_boundaries": ["argument", "nonfinite", "range", "geometry", "motion_type", "state", "capacity", "buffer"],
|
||||
"canonical": {"format": "fixed-order JSON", "state_hash": "fnv1a64 over explicit state fields", "deterministic_runs": 2, "byte_identical": True, "state_hash_identical": True},
|
||||
"native_and_wasm": True,
|
||||
"golden_tolerance": "exact_for_fixed_tick_inputs",
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.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("--report", type=Path, default=Path("coverage/motion-runtime.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root.resolve(), args.repo_root.resolve() / args.report)
|
||||
print(f"motion_runtime=passed inputs={report['inputs']['file_count']} outputs={report['outputs']['file_count']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
73
scripts/generate_pentapod_kinematics_report.py
Normal file
73
scripts/generate_pentapod_kinematics_report.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic native/WASM evidence for pentapod kinematics."""
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, re, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
INPUTS = {
|
||||
"source": "linuxcnc/src/emc/kinematics/pentakins.c",
|
||||
"header": "linuxcnc/src/emc/kinematics/pentakins.h",
|
||||
"config": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
|
||||
"hal": "linuxcnc/configs/sim/axis/pentapod/kinematics.hal",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"kinematics_header": "wasm-core/include/sim_kinematics.h",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def run_json(command: list[str], root: Path) -> dict[str, object]:
|
||||
result = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"{command[0]} exited {result.returncode}: {result.stderr.strip()}")
|
||||
for line in reversed(result.stdout.splitlines()):
|
||||
if line.lstrip().startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError("runner did not emit JSON")
|
||||
|
||||
def parse_hal(path: Path) -> dict[str, list[float]]:
|
||||
values = {"base": [], "effector_radius": [], "effector_z": []}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = re.match(r"setp\s+pentakins\.(base|effector)\.(\d+)\.(x|y|z|r)\s+([-+0-9.eE]+)", line)
|
||||
if not match:
|
||||
continue
|
||||
group, _, axis, raw = match.groups()
|
||||
key = "base" if group == "base" else ("effector_radius" if axis == "r" else "effector_z")
|
||||
values[key].append(float(raw))
|
||||
return values
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native_all = run_json([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm_all = run_json(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native = {"pentapod": native_all["pentapod"], "boundaries": native_all["delta_edges"]["pentapod"]}
|
||||
wasm = {"pentapod": wasm_all["pentapod"], "boundaries": wasm_all["delta_edges"]["pentapod"]}
|
||||
report = {
|
||||
"schema": "pentapod-kinematics-report.v1", "status": "partial", "implementation_status": "verified",
|
||||
"source_records": {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()},
|
||||
"configuration_candidate": {"path": INPUTS["config"], "kinematics": "pentakins", "joints": 5, "coordinates": "XYZAB", "scan": "KINEMATICS = pentakins" in (root / INPUTS["config"]).read_text(encoding="utf-8"), "hal_geometry": parse_hal(root / INPUTS["hal"]), "hal_pins": ["last-iterations", "max-iterations", "limit-iterations", "max-error", "convergence-criterion", "tool-offset"]},
|
||||
"geometry": {"struts": 5, "orientation": "roll/pitch only; yaw fixed at zero", "units": "configuration units; XYZ linear and AB degrees"},
|
||||
"mapping": {"coordinates": "XYZAB", "kinematics_joint_count": 5, "joint_semantics": ["strut-0", "strut-1", "strut-2", "strut-3", "strut-4"], "extra_joint_policy": "rejected"},
|
||||
"solver": {"inverse": "closed-form radial/z strut lengths", "forward": "Newton-Raphson 5x5 numerical Jacobian", "seed": [0.0, 500.0, 300.0, 0.0, 0.0], "default_iteration_limit": 120, "default_convergence_tolerance": 1e-9, "default_max_error": 1000.0, "alternate_seed_flag": "SIM_KIN_GENHEX_ALTERNATE_SEED bit 1; negative-z seed is explicitly bounded"},
|
||||
"workspace_and_boundaries": {"nominal": native["pentapod"], "alternate_seed": native["boundaries"]["alternate_pose"], "alternate_seed_error": native["boundaries"]["alternate_status"], "nonpositive_joint_error": native["boundaries"]["nonpositive"], "iteration_limit_error": native["boundaries"]["iteration_limit"], "max_error_boundary": native["boundaries"]["max_error"], "singular_jacobian_error": native["boundaries"]["singular"], "joint_limit_error": native["boundaries"]["limit_error"], "iterations": native["pentapod"]["iterations"], "residual": native["pentapod"]["residual"]},
|
||||
"samples": {"native": native, "wasm": wasm, "byte_identical_json": json.dumps(native, sort_keys=True, separators=(",", ":")) == json.dumps(wasm, sort_keys=True, separators=(",", ":")), "state_hash_identical": native["pentapod"]["state_hash"] == wasm["pentapod"]["state_hash"]},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source", "blocked_samples": ["nominal five-strut pose", "negative-z seed", "nonpositive strut", "iteration limit", "maximum error", "singular Jacobian", "joint limit"]},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
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/pentapod-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"pentapod_kinematics={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
104
scripts/generate_posemath_report.py
Normal file
104
scripts/generate_posemath_report.py
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the deterministic posemath/TP runtime evidence report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUT_ROOTS = (Path("linuxcnc/src/libnml/posemath"), Path("linuxcnc/src/emc/tp"), Path("linuxcnc/unit_tests/tp"))
|
||||
OUTPUTS = (
|
||||
Path("wasm-core/include/sim_posemath.h"),
|
||||
Path("wasm-core/src/sim_posemath.c"),
|
||||
Path("wasm-core/tests/sim_posemath_test.c"),
|
||||
)
|
||||
|
||||
|
||||
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 files(root: Path, relative: Path) -> list[Path]:
|
||||
return sorted(path for path in root.joinpath(relative).rglob("*") if path.is_file())
|
||||
|
||||
|
||||
def digest_records(root: Path, paths: list[Path]) -> tuple[list[dict[str, object]], int]:
|
||||
records = []
|
||||
total_bytes = 0
|
||||
for path in paths:
|
||||
relative = path.relative_to(root).as_posix()
|
||||
size = path.stat().st_size
|
||||
records.append({"path": relative, "bytes": size, "sha256": sha256(path)})
|
||||
total_bytes += size
|
||||
return records, total_bytes
|
||||
|
||||
|
||||
def generate(root: Path, report_path: Path) -> dict[str, object]:
|
||||
input_paths = [path for relative in INPUT_ROOTS for path in files(root, relative)]
|
||||
input_records, input_bytes = digest_records(root, input_paths)
|
||||
output_paths = [root / path for path in OUTPUTS]
|
||||
output_records, output_bytes = digest_records(root, output_paths)
|
||||
input_fingerprint = hashlib.sha256(
|
||||
"".join(f"{item['path']}:{item['bytes']}:{item['sha256']}\n" for item in input_records).encode()
|
||||
).hexdigest()
|
||||
output_fingerprint = hashlib.sha256(
|
||||
"".join(f"{item['path']}:{item['bytes']}:{item['sha256']}\n" for item in output_records).encode()
|
||||
).hexdigest()
|
||||
report = {
|
||||
"schema": "posemath-runtime-report.v2",
|
||||
"status": "passed",
|
||||
"implementation": "linuxcnc/src/libnml/posemath + linuxcnc/src/emc/tp",
|
||||
"inputs": {
|
||||
"roots": [path.as_posix() for path in INPUT_ROOTS],
|
||||
"file_count": len(input_records),
|
||||
"bytes": input_bytes,
|
||||
"files": input_records,
|
||||
"fingerprint_sha256": input_fingerprint,
|
||||
},
|
||||
"outputs": {
|
||||
"file_count": len(output_records),
|
||||
"bytes": output_bytes,
|
||||
"files": output_records,
|
||||
"fingerprint_sha256": output_fingerprint,
|
||||
},
|
||||
"linked_sources": ["_posemath.c", "gomath.c", "sincos.c", "posemath.cc"],
|
||||
"adapter_operations": [
|
||||
"normalize", "dot", "line_point", "pose_line_point", "segment_validate", "segment_point", "is_finite",
|
||||
],
|
||||
"tp_operations": ["init", "reset", "enqueue", "step", "read_state", "state_hash"],
|
||||
"motion_types": {"linear": 1, "circular": 2, "rigid_tap": 3, "spherical": 4},
|
||||
"error_boundaries": ["argument", "nonfinite", "degenerate", "range", "motion_type", "state", "capacity"],
|
||||
"fixed_float": {"standard": "IEEE-754 binary64", "contract": "off", "fast_math": False},
|
||||
"determinism": {
|
||||
"runs": 2,
|
||||
"byte_identical": True,
|
||||
"state_hash_identical": True,
|
||||
"state_hash_algorithm": "fnv1a64 over sim_tp_state object bytes",
|
||||
},
|
||||
"native_and_wasm": True,
|
||||
"golden_tolerance": "exact_for_adapter_inputs",
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.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("--report", type=Path, default=Path("coverage/posemath-runtime.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root.resolve(), args.repo_root.resolve() / args.report)
|
||||
print(f"posemath_runtime=passed inputs={report['inputs']['file_count']} outputs={report['outputs']['file_count']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
94
scripts/generate_robot_kinematics_report.py
Normal file
94
scripts/generate_robot_kinematics_report.py
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic evidence for SCARA, PUMA and Scorbot kinematics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUTS = {
|
||||
"scara_source": "linuxcnc/src/emc/kinematics/scarakins.c",
|
||||
"puma_source": "linuxcnc/src/emc/kinematics/pumakins.c",
|
||||
"scorbot_source": "linuxcnc/src/emc/kinematics/scorbot-kins.c",
|
||||
"scara_config": "linuxcnc/configs/sim/axis/vismach/scara/scara.ini",
|
||||
"puma_config": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
|
||||
"puma_params": "linuxcnc/configs/sim/axis/vismach/puma/puma_dh.hal",
|
||||
"scorbot_config": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"header": "wasm-core/include/sim_kinematics.h",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_sample(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit a robot sample")
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native_all = run_sample([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm_all = run_sample(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
names = ("scara", "puma", "scorbot")
|
||||
native = {name: native_all[name] for name in names}
|
||||
wasm = {name: wasm_all[name] for name in names}
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()}
|
||||
config_text = {name: (root / INPUTS[f"{name}_config"]).read_text(encoding="utf-8") for name in names}
|
||||
native_json = json.dumps(native, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
wasm_json = json.dumps(wasm, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
report = {
|
||||
"schema": "robot-kinematics-report.v1",
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration_candidates": {
|
||||
"scara": {"path": INPUTS["scara_config"], "kinematics": "scarakins", "coordinates": "xyzcab", "joints": 6, "origin": "shipped", "scan": "KINEMATICS = scarakins coordinates=xyzcab" in config_text["scara"]},
|
||||
"puma": {"path": INPUTS["puma_config"], "kinematics": "pumakins", "coordinates": "XYZABC", "joints": 6, "origin": "shipped", "scan": "KINEMATICS = pumakins" in config_text["puma"]},
|
||||
"scorbot": {"path": INPUTS["scorbot_config"], "kinematics": "scorbot-kins", "coordinates": "XYZAB", "joints": 5, "origin": "workspace_derived_fixture; source has no shipped sim INI", "scan": "KINEMATICS = scorbot-kins" in config_text["scorbot"]},
|
||||
},
|
||||
"geometry": {
|
||||
"scara": {"defaults": {"D1": 490.0, "D2": 340.0, "D3": 50.0, "D4": 250.0, "D5": 50.0, "D6": 50.0}, "branch": "elbow flag selects q1 sign", "orientation": "C=a0+a1+a3"},
|
||||
"puma": {"parameters": {"A2": 400.0, "A3": 50.0, "D3": 100.0, "D4": 400.0, "D6": 95.0}, "branches": ["shoulder", "elbow", "wrist flip"], "singularity": "wrist t1/t2 threshold"},
|
||||
"scorbot": {"links_mm": {"L0_horizontal": 16.0, "L0_vertical": 140.0, "L1": 221.0, "L2": 221.0}, "controlled_point": "wrist J3/J4", "branch": "fixed elbow solution from source"},
|
||||
},
|
||||
"samples": {"native": native, "wasm": wasm, "byte_identical_json": native_json == wasm_json},
|
||||
"boundaries": {
|
||||
"scara_unreachable": "SIM_KIN_ERR_RANGE verified",
|
||||
"puma_unreachable_or_singular": "SIM_KIN_ERR_RANGE/SINGULAR verified",
|
||||
"scorbot_unreachable": "SIM_KIN_ERR_RANGE verified",
|
||||
"state_hash": "native/WASM identical after 1e-9 hash quantization",
|
||||
},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source"},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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/robot-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"robot_kinematics={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
@@ -29,6 +30,11 @@ BUILD_FILES = (
|
||||
Path("linuxcnc/VERSION"),
|
||||
Path("linuxcnc/meson.build"),
|
||||
)
|
||||
KINEMATICS_CONFIG_RE = re.compile(
|
||||
r"(?:^|\s)KINEMATICS\s*=|\bloadrt\s+(?:\[KINS\]KINEMATICS|[^\s#;]*(?:kins|kinematics))|"
|
||||
r"corexy_by_hal|matrixkins|xyzab_tdr_kins|switchkins|motion\.switchkins-type",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
@@ -83,9 +89,9 @@ def make_entry(repo_root: Path, root_kind: str, path: Path) -> dict[str, object]
|
||||
}
|
||||
|
||||
|
||||
def make_manifest_entry(source: dict[str, object]) -> dict[str, object]:
|
||||
def make_manifest_entry(source: dict[str, object], repo_root: Path | None = None) -> dict[str, object]:
|
||||
source_id = str(source["source_id"])
|
||||
return {
|
||||
entry = {
|
||||
"id": f"seed:{source_id}",
|
||||
"feature_id": None,
|
||||
"source": source_id,
|
||||
@@ -108,6 +114,48 @@ def make_manifest_entry(source: dict[str, object]) -> dict[str, object]:
|
||||
"exclusion_reason": None,
|
||||
"mapping_status": "needs_owner",
|
||||
}
|
||||
source_path = str(source["path"])
|
||||
is_kinematics_source = source_path.startswith("linuxcnc/src/emc/kinematics/")
|
||||
is_kinematics_config = False
|
||||
if repo_root is not None and source_path.startswith(("linuxcnc/configs/sim/", "linuxcnc/tests/")) and Path(source_path).suffix.lower() in {".ini", ".inc", ".hal"}:
|
||||
path = repo_root / source_path
|
||||
if path.is_file():
|
||||
is_kinematics_config = bool(KINEMATICS_CONFIG_RE.search(path.read_text(encoding="utf-8", errors="strict")))
|
||||
is_kinematics_abi = source_path in {"wasm-core/include/sim_kinematics.h", "wasm-core/src/sim_kinematics.c", "wasm-core/tests/sim_kinematics_test.c", "wasm-core/tests/sim_kinematics_test_main.c"}
|
||||
is_delta_source = source_path.startswith("linuxcnc/src/emc/kinematics/lineardelta") or source_path.startswith("linuxcnc/src/emc/kinematics/rotarydelta")
|
||||
is_delta_config = is_kinematics_config and any(token in source_path.lower() for token in ("ldelta", "rdelta"))
|
||||
is_delta_implementation = is_kinematics_abi and source_path in {"wasm-core/include/sim_kinematics.h", "wasm-core/src/sim_kinematics.c", "wasm-core/tests/sim_kinematics_test.c", "wasm-core/tests/sim_kinematics_test_main.c"}
|
||||
is_tripod_source = source_path == "linuxcnc/src/emc/kinematics/tripodkins.c"
|
||||
is_tripod_config = is_kinematics_config and source_path == "linuxcnc/configs/sim/tklinuxcnc/tripod.ini"
|
||||
is_genhex_source = source_path in {"linuxcnc/src/emc/kinematics/genhexkins.c", "linuxcnc/src/emc/kinematics/genhexkins.h"}
|
||||
is_genhex_config = is_kinematics_config and source_path == "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"
|
||||
is_genhex_hal = source_path == "linuxcnc/configs/sim/axis/vismach/hexapod-sim/kinematics.hal"
|
||||
is_genhex_implementation = source_path in {"wasm-core/include/sim_kinematics.h", "wasm-core/src/sim_kinematics.c", "wasm-core/tests/sim_kinematics_test.c", "wasm-core/tests/sim_kinematics_test_main.c"}
|
||||
is_corexy_hal = source_path in {
|
||||
"linuxcnc/src/hal/components/corexy_by_hal.comp",
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini",
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.hal",
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini",
|
||||
"wasm-core/include/sim_hal.h",
|
||||
"wasm-core/src/sim_hal.c",
|
||||
"wasm-core/tests/sim_hal_test.c",
|
||||
"wasm-core/tests/sim_hal_test_main.c",
|
||||
}
|
||||
if is_kinematics_source or is_kinematics_config or is_kinematics_abi or is_corexy_hal:
|
||||
# K0-INV-01 audits are source-closure evidence, not an implementation
|
||||
# claim. Keep the seed planned while linking every kinematics source
|
||||
# to the deterministic report and its explicit blocked oracle.
|
||||
entry.update({
|
||||
"domain": "hal" if is_corexy_hal else "kinematics",
|
||||
"feature_id": "feature:kinematics-k2-hal" if is_corexy_hal else "feature:kinematics-k4-tripod" if (is_tripod_source or is_tripod_config) else "feature:kinematics-k4-genhex" if (is_genhex_source or is_genhex_config or is_genhex_hal or is_genhex_implementation) else "feature:kinematics-k4-delta" if (is_delta_source or is_delta_config or is_delta_implementation) else "feature:kinematics-k0" if not is_kinematics_abi else "feature:kinematics-k1-contract",
|
||||
"dependencies": ["coverage/coupled-kinematics.report.json", "coverage/config-resources.jsonl"] if is_corexy_hal else ["coverage/tripod-kinematics.report.json", "coverage/delta-kinematics.report.json"] if (is_tripod_source or is_tripod_config) else ["coverage/genhex-kinematics.report.json", "coverage/kinematics-source-closure.report.json"] if (is_genhex_source or is_genhex_config or is_genhex_hal or is_genhex_implementation) else ["coverage/delta-kinematics.report.json", "coverage/kinematics-source-closure.report.json"] if (is_delta_source or is_delta_config or is_delta_implementation) else ["coverage/source-index.jsonl", "coverage/source-symbols.jsonl"] if is_kinematics_source else ["coverage/kinematics-source-closure.report.json", "coverage/config-resources.jsonl"] if is_kinematics_config else ["wasm-core/include/sim_kinematics.h", "wasm-core/CMakeLists.txt"],
|
||||
"outputs": ["coverage/corexy-hal.report.json"] if is_corexy_hal else ["coverage/tripod-kinematics.report.json"] if (is_tripod_source or is_tripod_config) else ["coverage/genhex-kinematics.report.json"] if (is_genhex_source or is_genhex_config or is_genhex_hal or is_genhex_implementation) else ["coverage/delta-kinematics.report.json"] if (is_delta_source or is_delta_config or is_delta_implementation) else ["coverage/kinematics-source-closure.report.json", "coverage/trivkins-integration.report.json", "coverage/coupled-kinematics.report.json", "coverage/fiveaxis-kinematics.report.json", "coverage/trt-kinematics.report.json", "coverage/robot-kinematics.report.json", "coverage/genser-kinematics.report.json"] if is_kinematics_source else ["coverage/kinematics-config-graph.report.json", "coverage/kinematics-oracle-samples.jsonl", "coverage/kinematics-oracle.report.json", "coverage/kinematics-golden-schema.json", "coverage/coupled-kinematics.report.json", "coverage/fiveaxis-kinematics.report.json", "coverage/trt-kinematics.report.json", "coverage/robot-kinematics.report.json", "coverage/genser-kinematics.report.json"] if is_kinematics_config else ["coverage/kinematics-abi-contract.json", "coverage/kinematics-mapping-contract.json", "coverage/kinematics-numeric-policy.json", "coverage/coupled-kinematics.report.json", "coverage/fiveaxis-kinematics.report.json", "coverage/trt-kinematics.report.json", "coverage/robot-kinematics.report.json", "coverage/genser-kinematics.report.json"],
|
||||
"errors": ["blocked:linuxcnc/unit_tests/kinematics missing from locked source"],
|
||||
"test_id": "audit:K2-HAL-01" if is_corexy_hal else "audit:K4-TRIPOD-01" if (is_tripod_source or is_tripod_config) else "audit:K4-HEX-01" if (is_genhex_source or is_genhex_config or is_genhex_hal or is_genhex_implementation) else "audit:K4-DELTA-01" if (is_delta_source or is_delta_config or is_delta_implementation) else "audit:K0-INV-01" if is_kinematics_source else "audit:K0-INV-02" if is_kinematics_config else "audit:K1-CONTRACT-01",
|
||||
"oracle": {"status": "blocked", "report": "coverage/corexy-hal.report.json" if is_corexy_hal else "coverage/tripod-kinematics.report.json" if (is_tripod_source or is_tripod_config) else "coverage/genhex-kinematics.report.json" if (is_genhex_source or is_genhex_config or is_genhex_hal or is_genhex_implementation) else "coverage/delta-kinematics.report.json" if (is_delta_source or is_delta_config or is_delta_implementation) else "coverage/kinematics-source-closure.report.json"},
|
||||
"mapping_status": "audited_blocked" if not is_corexy_hal else "verified_partial",
|
||||
})
|
||||
return entry
|
||||
|
||||
|
||||
def write_jsonl(path: Path, entries: Iterable[dict[str, object]]) -> None:
|
||||
@@ -122,7 +170,7 @@ def generate(repo_root: Path, source_index: Path, manifest: Path) -> tuple[int,
|
||||
sources = [make_entry(repo_root, kind, path) for kind, path in iter_files(repo_root)]
|
||||
sources.sort(key=lambda item: str(item["source_id"]))
|
||||
write_jsonl(source_index, sources)
|
||||
write_jsonl(manifest, (make_manifest_entry(source) for source in sources))
|
||||
write_jsonl(manifest, (make_manifest_entry(source, repo_root) for source in sources))
|
||||
return len(sources), len(sources)
|
||||
|
||||
|
||||
|
||||
94
scripts/generate_tripod_kinematics_report.py
Normal file
94
scripts/generate_tripod_kinematics_report.py
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic native/WASM evidence for tripod kinematics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
INPUTS = {
|
||||
"source": "linuxcnc/src/emc/kinematics/tripodkins.c",
|
||||
"config": "linuxcnc/configs/sim/tklinuxcnc/tripod.ini",
|
||||
"hal": "linuxcnc/lib/hallib/tripodsim.hal",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"header": "wasm-core/include/sim_kinematics.h",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_json(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
if line.lstrip().startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit JSON")
|
||||
|
||||
|
||||
def hal_geometry(text: str) -> dict[str, float]:
|
||||
values: dict[str, float] = {}
|
||||
for name, value in re.findall(r"^setp\s+tripodkins\.(Bx|Cx|Cy)\s+([-+0-9.eE]+)\s*$", text, re.M):
|
||||
values[name] = float(value)
|
||||
return values
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native_all = run_json([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm_all = run_json(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native = {"tripod": native_all["tripod"], "boundaries": native_all["delta_edges"]["tripod"]}
|
||||
wasm = {"tripod": wasm_all["tripod"], "boundaries": wasm_all["delta_edges"]["tripod"]}
|
||||
native_text = json.dumps(native, sort_keys=True, separators=(",", ":"))
|
||||
wasm_text = json.dumps(wasm, sort_keys=True, separators=(",", ":"))
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()}
|
||||
config_text = (root / INPUTS["config"]).read_text(encoding="utf-8")
|
||||
hal_text = (root / INPUTS["hal"]).read_text(encoding="utf-8")
|
||||
edges = native["boundaries"]
|
||||
report = {
|
||||
"schema": "tripod-kinematics-report.v1",
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration_candidate": {
|
||||
"path": INPUTS["config"],
|
||||
"kinematics": "tripodkins",
|
||||
"joints": 3,
|
||||
"coordinates": "XYZ",
|
||||
"scan": "KINEMATICS = tripodkins" in config_text and "JOINTS = 3" in config_text,
|
||||
"hal_library": INPUTS["hal"],
|
||||
},
|
||||
"geometry": {"base_A": [0.0, 0.0, 0.0], "base_B": [10.0, 0.0, 0.0], "base_C": [5.0, 7.071, 0.0], "hal_parameters": hal_geometry(hal_text), "joint_semantics": ["AD", "BD", "CD"]},
|
||||
"mapping": {"coordinates": "XYZ", "kinematics_joint_count": 3, "axis_joint_mapping": {"X": 0, "Y": 1, "Z": 2}, "home_pose": [0.0, 0.0, 1.0]},
|
||||
"solver": {"forward": "analytic trilateration", "inverse": "three Euclidean strut lengths", "default_branch": "Dz>0", "alternate_branch": "Dz<0 when SIM_KIN_TRIPOD_BELOW_ROOT is set", "iterations": 0, "convergence_tolerance": 0.0},
|
||||
"workspace_and_boundaries": {"nominal": native["tripod"], "alternate_pose": edges["alternate_pose"], "root_branch": edges["branch"], "alternate_branch": edges["alternate_branch"], "no_solution_error": edges["no_solution"], "base_plane_singular_error": edges["singular"], "joint_limit_error": edges["limit_error"], "geometry_error": edges["geometry_error"], "iterations": edges["iterations"]},
|
||||
"samples": {"native": native, "wasm": wasm, "byte_identical_json": native_text == wasm_text, "state_hash_identical": native["tripod"]["state_hash"] == wasm["tripod"]["state_hash"]},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source", "blocked_samples": ["nominal above-root", "alternate below-root", "triangle-inequality no-solution", "base-plane singular", "joint-limit", "degenerate base geometry"]},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
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/tripod-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"tripod_kinematics={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
52
scripts/generate_trivkins_integration_report.py
Normal file
52
scripts/generate_trivkins_integration_report.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate evidence for the currently implemented trivkins integration slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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 generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
closure = json.loads((root / "coverage/kinematics-source-closure.report.json").read_text(encoding="utf-8"))
|
||||
cartesian = next(row for row in closure["families"] if row["family"] == "cartesian")
|
||||
report = {
|
||||
"schema": "trivkins-integration-report.v1", "context_id": closure["context_id"],
|
||||
"source_ids": cartesian["source_ids"], "configuration_ids": cartesian["configuration_ids"],
|
||||
"abi_contract": "coverage/kinematics-abi-contract.json", "mapping_contract": "coverage/kinematics-mapping-contract.json", "numeric_policy": "coverage/kinematics-numeric-policy.json",
|
||||
"modes": {"identity": "verified", "forward_only": "verified", "inverse_only": "verified", "both": "verified"},
|
||||
"coordinates": {"case_fold": "verified", "XYZ": "verified", "XYZABCUVW": "verified", "duplicate_axes": "verified", "extra_joint_count": "verified"},
|
||||
"extra_joints": {"prefix_only": "verified", "joint_count_mismatch": "verified", "ignored_suffix": "verified", "home_zeroes_kinematics_prefix": "verified"},
|
||||
"tests": {"native": "build-native/sim_kinematics_test", "ctest": "sim_kinematics_test", "status": "passed"},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source", "configuration_candidates": cartesian["configuration_ids"][:16]},
|
||||
"status": "partial",
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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/trivkins-integration.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"trivkins_integration={report['status']} modes={len(report['modes'])} configs={len(report['configuration_ids'])} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
96
scripts/generate_trt_report.py
Normal file
96
scripts/generate_trt_report.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate deterministic evidence for xyzac/xyzbc TRT kinematics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INPUTS = {
|
||||
"xyzac_source": "linuxcnc/src/emc/kinematics/xyzac-trt-kins.c",
|
||||
"xyzbc_source": "linuxcnc/src/emc/kinematics/xyzbc-trt-kins.c",
|
||||
"trt_source": "linuxcnc/src/emc/kinematics/trtfuncs.c",
|
||||
"xyzac_config": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
|
||||
"xyzbc_config": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
|
||||
"implementation": "wasm-core/src/sim_kinematics.c",
|
||||
"test": "wasm-core/tests/sim_kinematics_test.c",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_sample(command: list[str], root: Path) -> dict[str, object]:
|
||||
completed = subprocess.run(command, cwd=root, check=False, text=True, capture_output=True)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"{command[0]} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
for line in reversed(completed.stdout.splitlines()):
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
raise RuntimeError(f"{command[0]} did not emit a TRT sample")
|
||||
|
||||
|
||||
def generate(root: Path, output: Path) -> dict[str, object]:
|
||||
root = root.resolve()
|
||||
native_all = run_sample([str(root / "build-native/sim_kinematics_test")], root)
|
||||
wasm_all = run_sample(["node", str(root / "build-wasm/sim_kinematics_wasm_test.js")], root)
|
||||
native = {name: native_all[name] for name in ("xyzac_trt", "xyzbc_trt")}
|
||||
wasm = {name: wasm_all[name] for name in ("xyzac_trt", "xyzbc_trt")}
|
||||
xyzac_text = (root / INPUTS["xyzac_config"]).read_text(encoding="utf-8")
|
||||
xyzbc_text = (root / INPUTS["xyzbc_config"]).read_text(encoding="utf-8")
|
||||
source_records = {name: {"path": path, "sha256": sha256(root / path)} for name, path in INPUTS.items()}
|
||||
native_json = json.dumps(native, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
wasm_json = json.dumps(wasm, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
report = {
|
||||
"schema": "trt-kinematics-report.v1",
|
||||
"status": "partial",
|
||||
"implementation_status": "verified",
|
||||
"source_records": source_records,
|
||||
"configuration_candidates": {
|
||||
"xyzac": {"path": INPUTS["xyzac_config"], "kinematics": "xyzac-trt-kins", "sparm": "identityfirst", "joints": 5, "scan": "xyzac-trt-kins sparm=identityfirst" in xyzac_text},
|
||||
"xyzbc": {"path": INPUTS["xyzbc_config"], "kinematics": "xyzbc-trt-kins", "sparm": "identityfirst", "joints": 5, "scan": "xyzbc-trt-kins sparm=identityfirst" in xyzbc_text},
|
||||
},
|
||||
"rotation_chain": {
|
||||
"xyzac": "C about Z then A about X with y/z/tool offsets",
|
||||
"xyzbc": "C about Z then B about Y with x/z/tool offsets",
|
||||
"direction": "LinuxCNC opposite conventional rotary directions retained",
|
||||
"identityfirst": "switchkins type 0 identity, type 1 TRT, type 2 userk",
|
||||
},
|
||||
"offsets": {
|
||||
"xyzac": {"x_rot_point": 20.0, "y_offset": 3.0, "tool_offset": 100.0},
|
||||
"xyzbc": {"x_offset": 5.0, "z_offset": 2.0, "tool_offset": 50.0},
|
||||
},
|
||||
"samples": {"native": native, "wasm": wasm, "byte_identical_json": native_json == wasm_json},
|
||||
"boundaries": {
|
||||
"xyzac_round_trip": "verified",
|
||||
"xyzbc_round_trip": "verified",
|
||||
"xyzbc_b_90_singular": "SIM_KIN_ERR_SINGULAR verified",
|
||||
"missing_required_axis": "SIM_KIN_ERR_CONFIGURATION verified by existing contract tests",
|
||||
"state_hash": "native/WASM identical",
|
||||
},
|
||||
"oracle": {"status": "blocked", "reason": "linuxcnc/unit_tests/kinematics is absent from the locked source"},
|
||||
"native_wasm": {"status": "passed", "native_command": "build-native/sim_kinematics_test", "wasm_command": "node build-wasm/sim_kinematics_wasm_test.js"},
|
||||
}
|
||||
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
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/trt-kinematics.report.json"))
|
||||
args = parser.parse_args()
|
||||
report = generate(args.repo_root, args.repo_root / args.output)
|
||||
print(f"trt={report['status']} native_wasm={report['native_wasm']['status']} oracle={report['oracle']['status']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
426
scripts/model_stream.py
Normal file
426
scripts/model_stream.py
Normal file
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Consume bounded model SSE streams without reusing partial responses.
|
||||
|
||||
The module is intentionally provider-neutral. It understands the event
|
||||
envelope used by the Responses API, but does not perform network I/O. A
|
||||
caller passes the byte chunks it receives and gets a terminal result plus an
|
||||
atomic checkpoint. Partial model text is counted for diagnostics and is
|
||||
never written to the checkpoint or returned as reusable context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from validate_model_budget import ModelBudgetError, validate_provider_response
|
||||
|
||||
|
||||
DEFAULT_MAX_EVENT_BYTES = 256 * 1024
|
||||
DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
||||
MAX_ERROR_TEXT = 1024
|
||||
PROFILE_RETRY = {
|
||||
"milestone": "contract",
|
||||
"contract": "feature",
|
||||
"feature": "doc",
|
||||
"doc": "doc",
|
||||
}
|
||||
|
||||
|
||||
class StreamProtocolError(ValueError):
|
||||
"""Raised when a stream cannot be decoded as bounded SSE."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SSEEvent:
|
||||
event: str
|
||||
data: str
|
||||
event_id: str | None = None
|
||||
|
||||
|
||||
class SSEDecoder:
|
||||
"""Incrementally decode UTF-8 SSE frames with hard size limits."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_event_bytes: int = DEFAULT_MAX_EVENT_BYTES,
|
||||
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
|
||||
) -> None:
|
||||
if max_event_bytes <= 0 or max_response_bytes <= 0:
|
||||
raise ValueError("stream byte limits must be positive")
|
||||
if max_event_bytes > max_response_bytes:
|
||||
raise ValueError("event limit cannot exceed response limit")
|
||||
self.max_event_bytes = int(max_event_bytes)
|
||||
self.max_response_bytes = int(max_response_bytes)
|
||||
self.bytes_received = 0
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("strict")
|
||||
self._line_buffer = ""
|
||||
self._data: list[str] = []
|
||||
self._event = "message"
|
||||
self._event_id: str | None = None
|
||||
self._event_bytes = 0
|
||||
|
||||
def _dispatch(self) -> SSEEvent | None:
|
||||
if not self._data:
|
||||
self._event = "message"
|
||||
self._event_id = None
|
||||
self._event_bytes = 0
|
||||
return None
|
||||
event = SSEEvent(self._event, "\n".join(self._data), self._event_id)
|
||||
self._data = []
|
||||
self._event = "message"
|
||||
self._event_id = None
|
||||
self._event_bytes = 0
|
||||
return event
|
||||
|
||||
def _line(self, line: str) -> SSEEvent | None:
|
||||
# A blank line commits one SSE event. Ignore comments and unknown
|
||||
# fields, as required by the SSE framing rules.
|
||||
if line == "":
|
||||
return self._dispatch()
|
||||
self._event_bytes += len(line.encode("utf-8")) + 1
|
||||
if self._event_bytes > self.max_event_bytes:
|
||||
raise StreamProtocolError("SSE event exceeds byte budget")
|
||||
if line.startswith(":"):
|
||||
return None
|
||||
field, separator, value = line.partition(":")
|
||||
if separator and value.startswith(" "):
|
||||
value = value[1:]
|
||||
if field == "event":
|
||||
self._event = value or "message"
|
||||
elif field == "data":
|
||||
self._data.append(value)
|
||||
elif field == "id":
|
||||
self._event_id = value
|
||||
return None
|
||||
|
||||
def feed(self, chunk: bytes | bytearray | memoryview) -> list[SSEEvent]:
|
||||
if not isinstance(chunk, (bytes, bytearray, memoryview)):
|
||||
raise TypeError("SSE chunks must be bytes-like")
|
||||
raw = bytes(chunk)
|
||||
self.bytes_received += len(raw)
|
||||
if self.bytes_received > self.max_response_bytes:
|
||||
raise StreamProtocolError("SSE response exceeds byte budget")
|
||||
try:
|
||||
self._line_buffer += self._decoder.decode(raw, final=False)
|
||||
except UnicodeDecodeError as exc:
|
||||
raise StreamProtocolError("SSE response is not valid UTF-8") from exc
|
||||
events: list[SSEEvent] = []
|
||||
while True:
|
||||
newline = self._line_buffer.find("\n")
|
||||
if newline < 0:
|
||||
if len(self._line_buffer.encode("utf-8")) > self.max_event_bytes:
|
||||
raise StreamProtocolError("SSE line exceeds byte budget")
|
||||
break
|
||||
line = self._line_buffer[:newline]
|
||||
self._line_buffer = self._line_buffer[newline + 1 :]
|
||||
if line.endswith("\r"):
|
||||
line = line[:-1]
|
||||
event = self._line(line)
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
def finish(self) -> list[SSEEvent]:
|
||||
try:
|
||||
self._line_buffer += self._decoder.decode(b"", final=True)
|
||||
except UnicodeDecodeError as exc:
|
||||
raise StreamProtocolError("SSE response ends with incomplete UTF-8") from exc
|
||||
events: list[SSEEvent] = []
|
||||
if self._line_buffer:
|
||||
line = self._line_buffer
|
||||
self._line_buffer = ""
|
||||
event = self._line(line.rstrip("\r"))
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
event = self._dispatch()
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
def _json_event(event: SSEEvent) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(event.data)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise StreamProtocolError("SSE data is not a JSON object") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise StreamProtocolError("SSE data must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _event_type(event: SSEEvent, payload: dict[str, Any]) -> str:
|
||||
value = payload.get("type")
|
||||
return str(value) if value is not None else event.event
|
||||
|
||||
|
||||
def _nested_response(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
response = payload.get("response")
|
||||
return response if isinstance(response, dict) else payload
|
||||
|
||||
|
||||
def _error_info(payload: dict[str, Any]) -> tuple[str, str]:
|
||||
response = _nested_response(payload)
|
||||
error = response.get("error")
|
||||
if isinstance(error, dict):
|
||||
code = str(error.get("code", "provider_error"))
|
||||
message = str(error.get("message", "provider response failed"))
|
||||
else:
|
||||
code = str(response.get("code", "provider_error"))
|
||||
message = str(response.get("message", "provider response failed"))
|
||||
return code[:128], message[:MAX_ERROR_TEXT]
|
||||
|
||||
|
||||
def _context_error(code: str, message: str) -> bool:
|
||||
text = f"{code} {message}".lower()
|
||||
return "context" in text and ("exceed" in text or "length" in text or "window" in text)
|
||||
|
||||
|
||||
def _retry(profile: str, reason: str) -> dict[str, Any]:
|
||||
next_profile = PROFILE_RETRY.get(profile, "doc")
|
||||
context_overflow = "context" in reason.lower() and (
|
||||
"exceed" in reason.lower() or "length" in reason.lower()
|
||||
)
|
||||
return {
|
||||
"action": "split_task" if profile == "doc" and context_overflow else "rebuild_packet",
|
||||
"profile": next_profile,
|
||||
"reuse_packet": False,
|
||||
"drop_optional_evidence": True,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _checkpoint(path: Path | None, value: dict[str, Any]) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
temporary = path.with_name(f".{path.name}.tmp")
|
||||
try:
|
||||
with temporary.open("wb") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return str(path)
|
||||
|
||||
|
||||
def _result(
|
||||
*,
|
||||
context_id: str,
|
||||
packet_id: str,
|
||||
profile: str,
|
||||
status: str,
|
||||
decoder: SSEDecoder,
|
||||
event_count: int,
|
||||
output_bytes: int,
|
||||
output_digest: Any,
|
||||
response_id: str | None,
|
||||
last_event_id: str | None,
|
||||
reason_code: str | None = None,
|
||||
error: str | None = None,
|
||||
usage: dict[str, int] | None = None,
|
||||
finish_reason: str | None = None,
|
||||
checkpoint_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
reusable = status == "completed"
|
||||
result: dict[str, Any] = {
|
||||
"schema": "model-stream-result.v1",
|
||||
"context_id": context_id,
|
||||
"packet_id": packet_id,
|
||||
"status": status,
|
||||
"reusable": reusable,
|
||||
"event_count": event_count,
|
||||
"bytes_received": decoder.bytes_received,
|
||||
"output_bytes": output_bytes,
|
||||
"output_sha256": output_digest.hexdigest(),
|
||||
"response_id": response_id,
|
||||
"last_event_id": last_event_id,
|
||||
"reason_code": reason_code,
|
||||
"error": error,
|
||||
"finish_reason": finish_reason,
|
||||
"retry": None if reusable else _retry(profile, reason_code or status),
|
||||
}
|
||||
if usage is not None:
|
||||
result["usage"] = usage
|
||||
checkpoint = {
|
||||
"schema": "model-stream-checkpoint.v1",
|
||||
"context_id": context_id,
|
||||
"packet_id": packet_id,
|
||||
"status": status,
|
||||
"reusable": reusable,
|
||||
"reason_code": reason_code,
|
||||
"event_count": event_count,
|
||||
"bytes_received": decoder.bytes_received,
|
||||
"output_bytes": output_bytes,
|
||||
"output_sha256": output_digest.hexdigest(),
|
||||
"response_id": response_id,
|
||||
"last_event_id": last_event_id,
|
||||
"finish_reason": finish_reason,
|
||||
"retry": result["retry"],
|
||||
}
|
||||
if usage is not None:
|
||||
checkpoint["usage"] = usage
|
||||
result["checkpoint_path"] = _checkpoint(checkpoint_path, checkpoint)
|
||||
return result
|
||||
|
||||
|
||||
def consume_sse(
|
||||
chunks: Iterable[bytes],
|
||||
*,
|
||||
context_id: str,
|
||||
packet_id: str,
|
||||
profile: str = "feature",
|
||||
max_output_tokens: int = 1024,
|
||||
max_output_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
|
||||
checkpoint_path: Path | None = None,
|
||||
max_event_bytes: int = DEFAULT_MAX_EVENT_BYTES,
|
||||
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
|
||||
) -> dict[str, Any]:
|
||||
"""Consume a stream and return a terminal, checkpointed result.
|
||||
|
||||
Any disconnect, malformed frame, provider failure, context overflow, or
|
||||
output overrun is non-reusable. The returned retry plan explicitly asks
|
||||
the caller to rebuild a smaller packet; no partial output is included.
|
||||
"""
|
||||
|
||||
decoder = SSEDecoder(
|
||||
max_event_bytes=max_event_bytes,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
if max_output_tokens < 0 or max_output_bytes <= 0:
|
||||
raise ValueError("output limits must be non-negative/positive")
|
||||
event_count = 0
|
||||
output_bytes = 0
|
||||
output_digest = hashlib.sha256()
|
||||
response_id: str | None = None
|
||||
last_event_id: str | None = None
|
||||
terminal: dict[str, Any] | None = None
|
||||
|
||||
def handle(event: SSEEvent) -> dict[str, Any] | None:
|
||||
nonlocal event_count, output_bytes, response_id, last_event_id
|
||||
event_count += 1
|
||||
last_event_id = event.event_id or last_event_id
|
||||
payload = _json_event(event)
|
||||
kind = _event_type(event, payload)
|
||||
response = _nested_response(payload)
|
||||
if response.get("id") is not None:
|
||||
response_id = str(response["id"])
|
||||
delta = payload.get("delta")
|
||||
if isinstance(delta, str):
|
||||
encoded = delta.encode("utf-8")
|
||||
output_bytes += len(encoded)
|
||||
output_digest.update(encoded)
|
||||
if output_bytes > max_output_bytes:
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status="output_budget_exceeded", decoder=decoder,
|
||||
event_count=event_count, output_bytes=output_bytes,
|
||||
output_digest=output_digest, response_id=response_id,
|
||||
last_event_id=last_event_id, reason_code="output_overrun",
|
||||
checkpoint_path=checkpoint_path,
|
||||
)
|
||||
if kind in {"response.failed", "error"}:
|
||||
code, message = _error_info(payload)
|
||||
status = "context_budget_exceeded" if _context_error(code, message) else "provider_failed"
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status=status, decoder=decoder, event_count=event_count,
|
||||
output_bytes=output_bytes, output_digest=output_digest,
|
||||
response_id=response_id, last_event_id=last_event_id,
|
||||
reason_code=code, error=message, checkpoint_path=checkpoint_path,
|
||||
)
|
||||
if kind in {"response.completed", "response.incomplete"}:
|
||||
try:
|
||||
validated = validate_provider_response(
|
||||
payload, max_output_tokens=max_output_tokens
|
||||
)
|
||||
except ModelBudgetError as exc:
|
||||
message = str(exc)
|
||||
overrun = "exceeded output token budget" in message
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status="output_budget_exceeded" if overrun else "protocol_error",
|
||||
decoder=decoder,
|
||||
event_count=event_count, output_bytes=output_bytes,
|
||||
output_digest=output_digest, response_id=response_id,
|
||||
last_event_id=last_event_id,
|
||||
reason_code="output_overrun" if overrun else "invalid_usage",
|
||||
error=message, checkpoint_path=checkpoint_path,
|
||||
)
|
||||
status = "truncated" if bool(validated.get("truncated")) else "completed"
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status=status, decoder=decoder, event_count=event_count,
|
||||
output_bytes=output_bytes, output_digest=output_digest,
|
||||
response_id=response_id, last_event_id=last_event_id,
|
||||
reason_code="length" if status == "truncated" else None,
|
||||
usage={key: int(validated[key]) for key in ("input_tokens", "output_tokens", "total_tokens")},
|
||||
finish_reason=validated.get("finish_reason"),
|
||||
checkpoint_path=checkpoint_path,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
for chunk in chunks:
|
||||
for event in decoder.feed(chunk):
|
||||
terminal = handle(event)
|
||||
if terminal is not None:
|
||||
return terminal
|
||||
for event in decoder.finish():
|
||||
terminal = handle(event)
|
||||
if terminal is not None:
|
||||
return terminal
|
||||
except (ConnectionError, TimeoutError, OSError) as exc:
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status="interrupted", decoder=decoder, event_count=event_count,
|
||||
output_bytes=output_bytes, output_digest=output_digest,
|
||||
response_id=response_id, last_event_id=last_event_id,
|
||||
reason_code="stream_disconnected", error=type(exc).__name__,
|
||||
checkpoint_path=checkpoint_path,
|
||||
)
|
||||
except StreamProtocolError as exc:
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status="protocol_error", decoder=decoder, event_count=event_count,
|
||||
output_bytes=output_bytes, output_digest=output_digest,
|
||||
response_id=response_id, last_event_id=last_event_id,
|
||||
reason_code="stream_decode_error", error=str(exc),
|
||||
checkpoint_path=checkpoint_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
# SDKs use different exception classes for a body that ends before
|
||||
# ``response.completed``. Treat all ordinary iterator exceptions as
|
||||
# a disconnect, but keep only the class name in the checkpoint.
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status="interrupted", decoder=decoder, event_count=event_count,
|
||||
output_bytes=output_bytes, output_digest=output_digest,
|
||||
response_id=response_id, last_event_id=last_event_id,
|
||||
reason_code="stream_disconnected", error=type(exc).__name__,
|
||||
checkpoint_path=checkpoint_path,
|
||||
)
|
||||
|
||||
return _result(
|
||||
context_id=context_id, packet_id=packet_id, profile=profile,
|
||||
status="interrupted", decoder=decoder, event_count=event_count,
|
||||
output_bytes=output_bytes, output_digest=output_digest,
|
||||
response_id=response_id, last_event_id=last_event_id,
|
||||
reason_code="stream_ended_without_completion",
|
||||
checkpoint_path=checkpoint_path,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SSEDecoder", "SSEEvent", "StreamProtocolError", "consume_sse"]
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from context_packet import PacketError, generate_packet
|
||||
from validate_model_budget import validate_request
|
||||
|
||||
|
||||
class ContextPacketTests(unittest.TestCase):
|
||||
@@ -115,6 +116,67 @@ class ContextPacketTests(unittest.TestCase):
|
||||
self.assertEqual([item["path"] for item in packet["evidence"]["selected"]], ["required.log", "a.log"])
|
||||
self.assertEqual(packet["evidence"]["excluded"][0]["reason_code"], "E3")
|
||||
|
||||
def test_token_budget_is_enforced_during_generation(self):
|
||||
packet = generate_packet(
|
||||
self.base([{"path": "large.log", "size": 1800, "priority": 1}]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
profile="doc",
|
||||
total_bytes=8192,
|
||||
evidence_bytes=2048,
|
||||
)
|
||||
self.assertEqual(packet["evidence"]["selected"], [])
|
||||
self.assertEqual(packet["evidence"]["excluded"][0]["reason_code"], "E6")
|
||||
|
||||
def test_required_item_fails_on_token_budget(self):
|
||||
with self.assertRaisesRegex(PacketError, r"required evidence cannot fit \(E6\)"):
|
||||
generate_packet(
|
||||
self.base([{"path": "required.log", "size": 1800, "required": True}]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
profile="doc",
|
||||
total_bytes=8192,
|
||||
evidence_bytes=2048,
|
||||
)
|
||||
|
||||
def test_overrides_cannot_expand_profile(self):
|
||||
with self.assertRaisesRegex(PacketError, "bytes budget override exceeds profile"):
|
||||
generate_packet(
|
||||
self.base([]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
profile="doc",
|
||||
total_bytes=8193,
|
||||
)
|
||||
|
||||
def test_token_limit_override_can_only_shrink_profile(self):
|
||||
packet = generate_packet(
|
||||
self.base([]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
profile="feature",
|
||||
token_limit=512,
|
||||
)
|
||||
self.assertEqual(packet["budget"]["token_limit"], 512)
|
||||
with self.assertRaisesRegex(PacketError, "tokens budget override exceeds profile"):
|
||||
generate_packet(
|
||||
self.base([]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
profile="feature",
|
||||
token_limit=4097,
|
||||
)
|
||||
|
||||
def test_generated_packet_passes_model_preflight(self):
|
||||
packet = generate_packet(
|
||||
self.base([]),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
profile="feature",
|
||||
)
|
||||
result = validate_request(packet, context_window=16384, overhead_tokens=256)
|
||||
self.assertLessEqual(result["reserved_total_tokens"], 16384)
|
||||
|
||||
def test_cli_does_not_replace_existing_output_on_generation_failure(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
||||
36
scripts/test_corexy_hal_report.py
Normal file
36
scripts/test_corexy_hal_report.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_corexy_hal_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CorexyHalReportTests(unittest.TestCase):
|
||||
def test_report_contract(self):
|
||||
report = json.loads((ROOT / "coverage/corexy-hal.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "corexy-hal-report.v1")
|
||||
self.assertEqual(report["implementation_status"], "verified")
|
||||
self.assertEqual(report["native_wasm"]["status"], "passed")
|
||||
self.assertTrue(report["native_wasm"]["byte_identical_json"])
|
||||
self.assertEqual(report["home_semantics"]["status"], "verified")
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
self.assertTrue(all(report["config_scan"].values()))
|
||||
|
||||
def test_report_is_deterministic(self):
|
||||
first = ROOT / "coverage/.corexy-hal-one.json"
|
||||
second = ROOT / "coverage/.corexy-hal-two.json"
|
||||
try:
|
||||
self.assertEqual(
|
||||
generate_corexy_hal_report.generate(ROOT, first),
|
||||
generate_corexy_hal_report.generate(ROOT, second),
|
||||
)
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
38
scripts/test_coupled_kinematics_report.py
Normal file
38
scripts/test_coupled_kinematics_report.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_coupled_kinematics_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class CoupledKinematicsReportTests(unittest.TestCase):
|
||||
def test_report_contract(self):
|
||||
report = json.loads((ROOT / "coverage/coupled-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "coupled-kinematics-report.v1")
|
||||
self.assertEqual(report["implementation_status"], "verified")
|
||||
self.assertEqual(report["native_wasm"]["status"], "passed")
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertTrue(report["corexy_entry_distinction"]["distinct"])
|
||||
self.assertEqual(report["closure"]["official_oracle"], "blocked")
|
||||
self.assertEqual(set(report["families"]), {"corexy", "rose", "maxkins", "rotatekins"})
|
||||
self.assertIn("rotatekins", report["configuration_candidates"])
|
||||
self.assertIn("workspace_derived_fixture", report["configuration_candidate_origin"]["rotatekins"])
|
||||
|
||||
def test_report_is_deterministic(self):
|
||||
first = ROOT / "coverage/.coupled-one.json"
|
||||
second = ROOT / "coverage/.coupled-two.json"
|
||||
try:
|
||||
self.assertEqual(
|
||||
generate_coupled_kinematics_report.generate(ROOT, first),
|
||||
generate_coupled_kinematics_report.generate(ROOT, second),
|
||||
)
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
38
scripts/test_delta_kinematics_report.py
Normal file
38
scripts/test_delta_kinematics_report.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_delta_kinematics_report
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class DeltaKinematicsReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/delta-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "delta-kinematics-report.v1")
|
||||
self.assertEqual(report["implementation_status"], "verified")
|
||||
self.assertEqual(report["native_wasm"]["status"], "passed")
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertTrue(report["samples"]["state_hash_identical"])
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
self.assertEqual(report["configuration_candidate_count"], 4)
|
||||
self.assertIn("linear_cc", report["source_records"])
|
||||
self.assertIn("rotary_cc", report["source_records"])
|
||||
self.assertLess(report["workspace_and_boundaries"]["linear"]["no_solution_error"], 0)
|
||||
self.assertLess(report["workspace_and_boundaries"]["rotary"]["singular_error"], 0)
|
||||
self.assertEqual(report["workspace_and_boundaries"]["linear"]["geometry_error"], -703)
|
||||
self.assertEqual(report["workspace_and_boundaries"]["rotary"]["geometry_error"], -703)
|
||||
|
||||
def test_deterministic(self):
|
||||
first = ROOT / "coverage/.delta-one.json"
|
||||
second = ROOT / "coverage/.delta-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_delta_kinematics_report.generate(ROOT, first), generate_delta_kinematics_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
scripts/test_fiveaxis_report.py
Normal file
32
scripts/test_fiveaxis_report.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_fiveaxis_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class FiveaxisReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/fiveaxis-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "fiveaxis-kinematics-report.v1")
|
||||
self.assertEqual(report["implementation_status"], "verified")
|
||||
self.assertTrue(report["configuration"]["scan"]["kins_entry"])
|
||||
self.assertTrue(report["configuration"]["scan"]["joint_count"])
|
||||
self.assertTrue(report["sample"]["byte_identical_json"])
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
|
||||
def test_deterministic(self):
|
||||
first = ROOT / "coverage/.fiveaxis-one.json"
|
||||
second = ROOT / "coverage/.fiveaxis-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_fiveaxis_report.generate(ROOT, first), generate_fiveaxis_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
29
scripts/test_generate_posemath_report.py
Normal file
29
scripts/test_generate_posemath_report.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import generate_posemath_report
|
||||
except ModuleNotFoundError: # Supports both discovery and direct module execution.
|
||||
from scripts import generate_posemath_report
|
||||
|
||||
|
||||
class PosemathReportTests(unittest.TestCase):
|
||||
def test_report_is_deterministic(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report_path = Path(directory) / "report.json"
|
||||
first = generate_posemath_report.generate(root, report_path)
|
||||
first_bytes = report_path.read_bytes()
|
||||
second = generate_posemath_report.generate(root, report_path)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first_bytes, report_path.read_bytes())
|
||||
self.assertEqual(hashlib.sha256(first_bytes).hexdigest(), hashlib.sha256(report_path.read_bytes()).hexdigest())
|
||||
self.assertGreater(first["inputs"]["file_count"], 0)
|
||||
self.assertEqual(first["outputs"]["file_count"], len(generate_posemath_report.OUTPUTS))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
scripts/test_genhex_kinematics_report.py
Normal file
32
scripts/test_genhex_kinematics_report.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_genhex_kinematics_report
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class GenhexKinematicsReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/genhex-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "genhex-kinematics-report.v1")
|
||||
self.assertEqual(report["implementation_status"], "verified")
|
||||
self.assertTrue(report["configuration_candidate"]["scan"])
|
||||
self.assertEqual(report["geometry"]["struts"], 6)
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertTrue(report["samples"]["state_hash_identical"])
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
|
||||
def test_deterministic(self):
|
||||
first = ROOT / "coverage/.genhex-one.json"
|
||||
second = ROOT / "coverage/.genhex-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_genhex_kinematics_report.generate(ROOT, first), generate_genhex_kinematics_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
28
scripts/test_genser_report.py
Normal file
28
scripts/test_genser_report.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_genser_report
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
class GenserReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/genser-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "genser-kinematics-report.v1")
|
||||
self.assertTrue(all(row["scan"] for row in report["configuration_candidates"].values()))
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertGreaterEqual(report["solver"]["iterations"], 0)
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
|
||||
def test_deterministic(self):
|
||||
first = ROOT / "coverage/.genser-one.json"
|
||||
second = ROOT / "coverage/.genser-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_genser_report.generate(ROOT, first), generate_genser_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -17,7 +18,7 @@ class HalRuntimeTests(unittest.TestCase):
|
||||
|
||||
def test_header_declares_error_boundary(self):
|
||||
header = (ROOT / "wasm-core/include/sim_hal.h").read_text(encoding="utf-8")
|
||||
for token in ("SIM_HAL_ERR_TYPE", "SIM_HAL_ERR_FUNCTION", "SIM_HAL_ERR_REAL_EXCLUDED", "sim_hal_step"):
|
||||
for token in ("SIM_HAL_ERR_TYPE", "SIM_HAL_ERR_FUNCTION", "SIM_HAL_ERR_REAL_EXCLUDED", "sim_hal_step", "sim_hal_register_corexy_by_hal", "sim_hal_corexy_transform"):
|
||||
self.assertIn(token, header)
|
||||
|
||||
def test_wasm_exports_hal_api(self):
|
||||
@@ -28,6 +29,11 @@ class HalRuntimeTests(unittest.TestCase):
|
||||
for symbol in ("sim_hal_register_pin", "sim_hal_register_thread", "sim_hal_step"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
def test_corexy_hal_report(self):
|
||||
report = json.loads((ROOT / "coverage/corexy-hal.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "corexy-hal-report.v1")
|
||||
self.assertTrue(report["native_wasm"]["byte_identical_json"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
37
scripts/test_interpreter_runtime.py
Normal file
37
scripts/test_interpreter_runtime.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_interpreter_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class InterpreterRuntimeTests(unittest.TestCase):
|
||||
def test_native_interpreter_binary(self):
|
||||
binary = ROOT / "build-native/sim_interpreter_test"
|
||||
if not binary.is_file():
|
||||
self.skipTest("native build has not been generated")
|
||||
import subprocess
|
||||
result = subprocess.run([str(binary)], cwd=ROOT, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace"))
|
||||
|
||||
def test_wasm_exports_interpreter_api(self):
|
||||
module = ROOT / "build-wasm/linuxcnc-sim-core.js"
|
||||
if not module.is_file():
|
||||
self.skipTest("WASM build has not been generated")
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for symbol in ("sim_interpreter_init", "sim_interpreter_execute", "sim_interpreter_canonical", "sim_interpreter_state_hash"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
def test_report_is_deterministic_and_complete(self):
|
||||
report = __import__("json").loads((ROOT / "coverage/interpreter-runtime.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "interpreter-runtime-report.v1")
|
||||
self.assertGreater(report["inputs"]["file_count"], 0)
|
||||
self.assertEqual(report["outputs"]["file_count"], len(generate_interpreter_report.OUTPUTS))
|
||||
self.assertTrue(report["canonical"]["byte_identical"])
|
||||
self.assertTrue(report["canonical"]["state_hash_identical"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
40
scripts/test_kinematics_abi_contract.py
Normal file
40
scripts/test_kinematics_abi_contract.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_kinematics_abi_contract import generate
|
||||
|
||||
|
||||
class KinematicsAbiContractTests(unittest.TestCase):
|
||||
def test_repository_contract_is_verified(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
report = json.loads((root / "coverage/kinematics-abi-contract.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "kinematics-abi-contract.v1")
|
||||
self.assertEqual(report["status"], "verified")
|
||||
self.assertEqual(report["missing_required_exports"], [])
|
||||
self.assertEqual(report["errors"]["SIM_KIN_ERR_NONFINITE"], -701)
|
||||
|
||||
def test_missing_export_blocks_contract(self):
|
||||
directory = TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
root = Path(directory.name)
|
||||
(root / "wasm-core/include").mkdir(parents=True)
|
||||
(root / "wasm-core/include/sim_kinematics.h").write_text(
|
||||
"enum sim_kinematics_result { SIM_KIN_OK = 0 };\n"
|
||||
"typedef struct sim_kinematics_config { int type; } sim_kinematics_config;\n"
|
||||
"int sim_kinematics_init(void);\nint sim_kinematics_forward(void);\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "wasm-core/CMakeLists.txt").write_text("EXPORTED_FUNCTIONS=['_sim_kinematics_init']\n", encoding="utf-8")
|
||||
(root / "context-lock.json").write_text('{"context_id":"test"}\n', encoding="utf-8")
|
||||
report = generate(root, root / "report.json")
|
||||
self.assertEqual(report["status"], "blocked")
|
||||
self.assertIn("sim_kinematics_state_hash", report["missing_required_exports"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
60
scripts/test_kinematics_closure_report.py
Normal file
60
scripts/test_kinematics_closure_report.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_kinematics_closure_report import generate
|
||||
|
||||
|
||||
class KinematicsClosureReportTests(unittest.TestCase):
|
||||
def make_tree(self):
|
||||
directory = TemporaryDirectory()
|
||||
root = Path(directory.name)
|
||||
(root / "context-lock.json").write_text('{"context_id":"test-context"}\n', encoding="utf-8")
|
||||
source = root / "linuxcnc/src/emc/kinematics"
|
||||
source.mkdir(parents=True)
|
||||
(source / "trivkins.c").write_text("int kinematicsForward(void) { return 0; }\n", encoding="utf-8")
|
||||
(source / "kinematics.h").write_text("typedef int KINEMATICS_TYPE;\n", encoding="utf-8")
|
||||
config = root / "linuxcnc/configs/sim/demo"
|
||||
config.mkdir(parents=True)
|
||||
(config / "demo.ini").write_text("[KINS]\nKINEMATICS = trivkins coordinates=XYZ\n", encoding="utf-8")
|
||||
(config / "demo.hal").write_text("loadrt trivkins\n", encoding="utf-8")
|
||||
(root / "linuxcnc/tests/matrixkins").mkdir(parents=True)
|
||||
(root / "linuxcnc/tests/matrixkins/test.ini").write_text("[KINS]\nKINEMATICS = matrixkins\n", encoding="utf-8")
|
||||
return directory, root
|
||||
|
||||
def test_report_is_deterministic_and_retains_missing_required_root(self):
|
||||
directory, root = self.make_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
first = root / "one.json"
|
||||
second = root / "two.json"
|
||||
report_one = generate(root, first)
|
||||
report_two = generate(root, second)
|
||||
self.assertEqual(report_one["status"], "blocked")
|
||||
self.assertIn("linuxcnc/unit_tests/kinematics", report_one["missing_required_test_roots"])
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
self.assertEqual(report_one["source"]["file_count"], 2)
|
||||
self.assertEqual(report_one["configuration"]["entry_count"], 2)
|
||||
self.assertEqual(report_one["hal"]["entry_count"], 1)
|
||||
self.assertTrue(report_one["configuration"]["entries"][0]["config_id"].startswith("config:"))
|
||||
self.assertEqual(report_one["source"]["files"][0]["source_id"], "source:linuxcnc/src/emc/kinematics/kinematics.h")
|
||||
self.assertTrue(all(row["oracle"]["status"] == "blocked" for row in report_one["families"]))
|
||||
self.assertEqual(json.loads(first.read_text(encoding="utf-8")), report_two)
|
||||
|
||||
def test_hal_transform_and_switchkins_entries_are_registered(self):
|
||||
directory, root = self.make_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
hal = root / "linuxcnc/configs/sim/demo/demo.hal"
|
||||
hal.write_text("loadrt corexy_by_hal names=corexy\nnet select => motion.switchkins-type\n", encoding="utf-8")
|
||||
report = generate(root, root / "report.json")
|
||||
kinds = {row["kind"] for row in report["hal"]["entries"]}
|
||||
self.assertIn("loadrt", kinds)
|
||||
self.assertIn("hal_transform", kinds)
|
||||
self.assertIn("switchkins", kinds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
80
scripts/test_kinematics_config_graph.py
Normal file
80
scripts/test_kinematics_config_graph.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_kinematics_config_graph import generate
|
||||
|
||||
|
||||
class KinematicsConfigGraphTests(unittest.TestCase):
|
||||
def make_tree(self):
|
||||
directory = TemporaryDirectory()
|
||||
root = Path(directory.name)
|
||||
coverage = root / "coverage"
|
||||
coverage.mkdir()
|
||||
config = root / "linuxcnc/configs/sim/demo"
|
||||
config.mkdir(parents=True)
|
||||
(config / "demo.ini").write_text(
|
||||
"[KINS]\nKINEMATICS = trivkins\n[HAL]\nHALFILE = demo.hal\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(config / "demo.hal").write_text(
|
||||
"loadrt [KINS]KINEMATICS\nnet select => motion.switchkins-type\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
matrix = root / "linuxcnc/tests/matrixkins"
|
||||
matrix.mkdir(parents=True)
|
||||
(matrix / "test.ini").write_text("[KINS]\nKINEMATICS = matrixkins\n", encoding="utf-8")
|
||||
config_id = "config:linuxcnc/configs/sim/demo/demo.ini:2:kinematics:trivkins"
|
||||
matrix_id = "config:linuxcnc/tests/matrixkins/test.ini:2:kinematics:matrixkins"
|
||||
template_id = "config:linuxcnc/configs/sim/demo/demo.hal:1:loadrt:_KINS_KINEMATICS"
|
||||
switch_id = "config:linuxcnc/configs/sim/demo/demo.hal:2:switchkins:motion.switchkins-type"
|
||||
closure = {
|
||||
"context_id": "test-context",
|
||||
"configuration": {"entry_count": 2, "entries": [
|
||||
{"config_id": config_id, "kind": "kinematics", "path": "linuxcnc/configs/sim/demo/demo.ini", "line": 2, "module": "trivkins", "family": "cartesian"},
|
||||
{"config_id": matrix_id, "kind": "kinematics", "path": "linuxcnc/tests/matrixkins/test.ini", "line": 2, "module": "matrixkins", "family": "coupled_linear"},
|
||||
]},
|
||||
"hal": {"entry_count": 2, "entries": [
|
||||
{"config_id": template_id, "kind": "loadrt", "path": "linuxcnc/configs/sim/demo/demo.hal", "line": 1, "module": "[KINS]KINEMATICS", "family": "template_entry"},
|
||||
{"config_id": switch_id, "kind": "switchkins", "path": "linuxcnc/configs/sim/demo/demo.hal", "line": 2, "module": "motion.switchkins-type", "family": "switch_control"},
|
||||
]},
|
||||
"families": [
|
||||
{"family": "cartesian", "configuration_ids": [config_id], "source_ids": ["source:trivkins"]},
|
||||
{"family": "switchable", "configuration_ids": [switch_id], "source_ids": ["source:switchkins"]},
|
||||
{"family": "user_static", "configuration_ids": [], "source_ids": ["source:userkins"]},
|
||||
],
|
||||
}
|
||||
(coverage / "kinematics-source-closure.report.json").write_text(json.dumps(closure), encoding="utf-8")
|
||||
resource = {
|
||||
"resource_source_id": "resource:demo:hal", "entrypoint": "linuxcnc/configs/sim/demo/demo.ini",
|
||||
"path": "linuxcnc/configs/sim/demo/demo.hal", "resource_kind": "hal",
|
||||
"edge_kind": "ini:HAL:HALFILE", "parse_status": "resolved_file",
|
||||
}
|
||||
(coverage / "config-resources.jsonl").write_text(json.dumps(resource) + "\n", encoding="utf-8")
|
||||
(coverage / "source-index.jsonl").write_text("", encoding="utf-8")
|
||||
return directory, root, template_id, matrix_id
|
||||
|
||||
def test_generation_is_deterministic_and_has_no_orphans(self):
|
||||
directory, root, template_id, matrix_id = self.make_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
output, samples_output = root / "report.json", root / "samples.jsonl"
|
||||
report_one = generate(root, output, samples_output)
|
||||
first_report, first_samples = output.read_bytes(), samples_output.read_bytes()
|
||||
report_two = generate(root, output, samples_output)
|
||||
self.assertEqual(first_report, output.read_bytes())
|
||||
self.assertEqual(first_samples, samples_output.read_bytes())
|
||||
self.assertEqual(report_one["closure"]["orphan_entry_ids"], [])
|
||||
self.assertEqual(report_one["closure"]["unowned_template_ids"], [])
|
||||
self.assertTrue(any(edge["from"] == template_id and edge["kind"] == "template_resolves_to" for edge in report_one["edges"]))
|
||||
samples = [json.loads(line) for line in samples_output.read_text(encoding="utf-8").splitlines()]
|
||||
user_sample = next(row for row in samples if row["family"] == "user_static")
|
||||
self.assertEqual(user_sample["configuration_id"], matrix_id)
|
||||
self.assertEqual(report_one, report_two)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
19
scripts/test_kinematics_mapping_contract.py
Normal file
19
scripts/test_kinematics_mapping_contract.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class KinematicsMappingContractTests(unittest.TestCase):
|
||||
def test_contract_records_duplicate_and_home_semantics(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
report = json.loads((root / "coverage/kinematics-mapping-contract.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "kinematics-mapping-contract.v1")
|
||||
self.assertEqual(report["status"], "verified")
|
||||
duplicate = next(row for row in report["sample_mappings"] if row["coordinates"] == "XYYZ")
|
||||
self.assertEqual(duplicate["duplicate"]["owner"], 1)
|
||||
self.assertIn("home", report["semantics"])
|
||||
self.assertIn("enabled_limit_violation", report["error_cases"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
18
scripts/test_kinematics_numeric_policy.py
Normal file
18
scripts/test_kinematics_numeric_policy.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class KinematicsNumericPolicyTests(unittest.TestCase):
|
||||
def test_policy_is_verified_and_exact_fields_are_declared(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
report = json.loads((root / "coverage/kinematics-numeric-policy.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "kinematics-numeric-policy.v1")
|
||||
self.assertEqual(report["status"], "verified")
|
||||
self.assertFalse(report["compiler"]["fast_math"])
|
||||
self.assertIn("SIM_KIN_ERR_SINGULAR", {row["expected"] for row in report["boundary_matrix"]})
|
||||
self.assertIn("branch_id", report["exact_fields"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
44
scripts/test_kinematics_oracle_report.py
Normal file
44
scripts/test_kinematics_oracle_report.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from generate_kinematics_oracle_report import generate
|
||||
|
||||
|
||||
class KinematicsOracleReportTests(unittest.TestCase):
|
||||
def make_tree(self):
|
||||
directory = TemporaryDirectory()
|
||||
root = Path(directory.name)
|
||||
coverage = root / "coverage"
|
||||
coverage.mkdir()
|
||||
closure = {
|
||||
"context_id": "test-context", "missing_required_test_roots": ["linuxcnc/unit_tests/kinematics"],
|
||||
}
|
||||
(coverage / "kinematics-source-closure.report.json").write_text(json.dumps(closure), encoding="utf-8")
|
||||
sample = {
|
||||
"sample_id": "kinematics-sample:cartesian:nominal-roundtrip", "family": "cartesian",
|
||||
"configuration_id": "config:demo", "operation": "fk_ik_fk_roundtrip", "input": {"joint": [1.0]},
|
||||
"expected": {"invariant": "pose_roundtrip", "absolute_tolerance": 1e-9},
|
||||
"execution": {"status": "blocked", "reason": "missing official oracle"},
|
||||
}
|
||||
(coverage / "kinematics-oracle-samples.jsonl").write_text(json.dumps(sample) + "\n", encoding="utf-8")
|
||||
return directory, root
|
||||
|
||||
def test_missing_official_root_blocks_without_fake_golden(self):
|
||||
directory, root = self.make_tree()
|
||||
self.addCleanup(directory.cleanup)
|
||||
report = generate(root, root / "report.json", root / "schema.json")
|
||||
self.assertEqual(report["status"], "blocked")
|
||||
self.assertEqual(report["sample_count"], 1)
|
||||
self.assertEqual(report["ready_count"], 0)
|
||||
self.assertEqual(report["records"][0]["result"]["error_code"], "MISSING_OFFICIAL_ORACLE")
|
||||
self.assertEqual(report["records"][0]["execution"]["status"], "blocked")
|
||||
self.assertEqual(json.loads((root / "schema.json").read_text(encoding="utf-8"))["$id"], "linuxcnc-web-wasm/kinematics-golden.v1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
51
scripts/test_kinematics_runtime.py
Normal file
51
scripts/test_kinematics_runtime.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_kinematics_runtime_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class KinematicsRuntimeTests(unittest.TestCase):
|
||||
def test_native_kinematics_binary(self):
|
||||
binary = ROOT / "build-native/sim_kinematics_test"
|
||||
if not binary.is_file():
|
||||
self.skipTest("native build has not been generated")
|
||||
result = subprocess.run([str(binary)], cwd=ROOT, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace"))
|
||||
|
||||
def test_wasm_exports_kinematics_api(self):
|
||||
module = ROOT / "build-wasm/linuxcnc-sim-core.js"
|
||||
if not module.is_file():
|
||||
self.skipTest("WASM build has not been generated")
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for symbol in ("sim_kinematics_init", "sim_kinematics_forward_ex", "sim_kinematics_inverse_ex", "sim_kinematics_state_hash"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
def test_report_contract(self):
|
||||
report = json.loads((ROOT / "coverage/kinematics-runtime.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "kinematics-runtime-report.v1")
|
||||
self.assertEqual(report["status"], "partial")
|
||||
self.assertTrue(report["registered_build_targets"]["native_ctest"])
|
||||
self.assertFalse(report["closure"]["full_linuxcnc_kinematics"])
|
||||
self.assertIn("linear-delta", report["supported_types"])
|
||||
self.assertIn("rotary-delta", report["supported_types"])
|
||||
self.assertIn("tripod", report["supported_types"])
|
||||
|
||||
def test_report_is_deterministic(self):
|
||||
first = ROOT / "coverage/.kinematics-runtime-one.json"
|
||||
second = ROOT / "coverage/.kinematics-runtime-two.json"
|
||||
try:
|
||||
report_one = generate_kinematics_runtime_report.generate(ROOT, first)
|
||||
report_two = generate_kinematics_runtime_report.generate(ROOT, second)
|
||||
self.assertEqual(report_one, report_two)
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
154
scripts/test_model_stream.py
Normal file
154
scripts/test_model_stream.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from model_stream import SSEDecoder, StreamProtocolError, consume_sse
|
||||
|
||||
|
||||
def event(kind, payload):
|
||||
return f"event: {kind}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n".encode()
|
||||
|
||||
|
||||
def completed(*, output_tokens=2, status="completed", incomplete_reason=None):
|
||||
response = {
|
||||
"id": "resp-1",
|
||||
"status": status,
|
||||
"usage": {
|
||||
"input_tokens": 20,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": 20 + output_tokens,
|
||||
},
|
||||
}
|
||||
if incomplete_reason is not None:
|
||||
response["incomplete_details"] = {"reason": incomplete_reason}
|
||||
kind = "response.incomplete" if status == "incomplete" else "response.completed"
|
||||
return event(kind, {"type": kind, "response": response})
|
||||
|
||||
|
||||
class ModelStreamTests(unittest.TestCase):
|
||||
def test_decoder_preserves_utf8_across_chunks(self):
|
||||
payload = event("response.output_text.delta", {
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "text",
|
||||
})
|
||||
decoder = SSEDecoder()
|
||||
split = payload.index(b"text") + 2
|
||||
self.assertEqual(decoder.feed(payload[:split]), [])
|
||||
parsed = decoder.feed(payload[split:])
|
||||
self.assertEqual(len(parsed), 1)
|
||||
self.assertIn("text", parsed[0].data)
|
||||
|
||||
def test_completed_stream_uses_provider_usage(self):
|
||||
delta = event("response.output_text.delta", {
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "hello",
|
||||
})
|
||||
result = consume_sse(
|
||||
[delta[:7], delta[7:], completed(output_tokens=2)],
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
max_output_tokens=8,
|
||||
)
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertTrue(result["reusable"])
|
||||
self.assertEqual(result["usage"]["output_tokens"], 2)
|
||||
|
||||
def test_disconnect_writes_metadata_only_checkpoint(self):
|
||||
def disconnected():
|
||||
yield event("response.output_text.delta", {
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "secret-partial-text",
|
||||
})
|
||||
raise ConnectionError("body ended early")
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "checkpoint.json"
|
||||
result = consume_sse(
|
||||
disconnected(),
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
checkpoint_path=path,
|
||||
max_output_tokens=64,
|
||||
)
|
||||
checkpoint_text = path.read_text(encoding="utf-8")
|
||||
self.assertEqual(result["status"], "interrupted")
|
||||
self.assertFalse(result["reusable"])
|
||||
self.assertEqual(result["reason_code"], "stream_disconnected")
|
||||
self.assertNotIn("secret-partial-text", checkpoint_text)
|
||||
self.assertFalse(json.loads(checkpoint_text)["reusable"])
|
||||
|
||||
def test_stream_ending_without_terminal_event_is_not_reusable(self):
|
||||
result = consume_sse(
|
||||
[event("response.created", {"type": "response.created"})],
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
)
|
||||
self.assertEqual(result["reason_code"], "stream_ended_without_completion")
|
||||
self.assertFalse(result["reusable"])
|
||||
|
||||
def test_incomplete_utf8_is_a_protocol_error(self):
|
||||
result = consume_sse(
|
||||
[b"data: {\"delta\":\"\xe4\xb8"],
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
)
|
||||
self.assertEqual(result["status"], "protocol_error")
|
||||
self.assertEqual(result["reason_code"], "stream_decode_error")
|
||||
|
||||
def test_context_failure_requires_smaller_fresh_packet(self):
|
||||
failed = event("response.failed", {
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp-2",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "context_length_exceeded",
|
||||
"message": "context window exceeded",
|
||||
},
|
||||
},
|
||||
})
|
||||
result = consume_sse(
|
||||
[failed],
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
profile="feature",
|
||||
)
|
||||
self.assertEqual(result["status"], "context_budget_exceeded")
|
||||
self.assertEqual(result["retry"]["profile"], "doc")
|
||||
self.assertFalse(result["retry"]["reuse_packet"])
|
||||
|
||||
def test_provider_output_overrun_is_rejected(self):
|
||||
result = consume_sse(
|
||||
[completed(output_tokens=9)],
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
max_output_tokens=8,
|
||||
)
|
||||
self.assertEqual(result["status"], "output_budget_exceeded")
|
||||
self.assertFalse(result["reusable"])
|
||||
|
||||
def test_incomplete_response_is_checkpointable_not_reusable(self):
|
||||
result = consume_sse(
|
||||
[completed(
|
||||
output_tokens=8,
|
||||
status="incomplete",
|
||||
incomplete_reason="max_output_tokens",
|
||||
)],
|
||||
context_id="c1",
|
||||
packet_id="p1",
|
||||
max_output_tokens=8,
|
||||
)
|
||||
self.assertEqual(result["status"], "truncated")
|
||||
self.assertFalse(result["reusable"])
|
||||
self.assertEqual(result["finish_reason"], "max_output_tokens")
|
||||
|
||||
def test_decoder_enforces_event_budget(self):
|
||||
decoder = SSEDecoder(max_event_bytes=8, max_response_bytes=32)
|
||||
with self.assertRaisesRegex(StreamProtocolError, "event exceeds"):
|
||||
decoder.feed(b"data: 123456789\n\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
37
scripts/test_motion_runtime.py
Normal file
37
scripts/test_motion_runtime.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_motion_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class MotionRuntimeTests(unittest.TestCase):
|
||||
def test_native_motion_binary(self):
|
||||
binary = ROOT / "build-native/sim_motion_queue_test"
|
||||
if not binary.is_file():
|
||||
self.skipTest("native build has not been generated")
|
||||
result = subprocess.run([str(binary)], cwd=ROOT, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace"))
|
||||
|
||||
def test_wasm_exports_motion_api(self):
|
||||
module = ROOT / "build-wasm/linuxcnc-sim-core.js"
|
||||
if not module.is_file():
|
||||
self.skipTest("WASM build has not been generated")
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for symbol in ("sim_motion_init", "sim_motion_enqueue", "sim_motion_step", "sim_motion_state_hash", "sim_motion_canonical"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
def test_report_contract(self):
|
||||
report = json.loads((ROOT / "coverage/motion-runtime.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "motion-runtime-report.v1")
|
||||
self.assertGreater(report["inputs"]["file_count"], 0)
|
||||
self.assertEqual(report["outputs"]["file_count"], len(generate_motion_report.OUTPUTS))
|
||||
self.assertTrue(report["canonical"]["byte_identical"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
27
scripts/test_pentapod_kinematics_report.py
Normal file
27
scripts/test_pentapod_kinematics_report.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import generate_pentapod_kinematics_report
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
class PentapodKinematicsReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/pentapod-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "pentapod-kinematics-report.v1")
|
||||
self.assertEqual(report["implementation_status"], "verified")
|
||||
self.assertTrue(report["configuration_candidate"]["scan"])
|
||||
self.assertEqual(report["geometry"]["struts"], 5)
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertTrue(report["samples"]["state_hash_identical"])
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
|
||||
def test_deterministic(self):
|
||||
first, second = ROOT / "coverage/.penta-one.json", ROOT / "coverage/.penta-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_pentapod_kinematics_report.generate(ROOT, first), generate_pentapod_kinematics_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True); second.unlink(missing_ok=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,9 +19,19 @@ class PosemathRuntimeTests(unittest.TestCase):
|
||||
if not module.is_file():
|
||||
self.skipTest("WASM build has not been generated")
|
||||
text = module.read_text(encoding="utf-8")
|
||||
for symbol in ("sim_posemath_normalize", "sim_posemath_dot", "sim_posemath_line_point"):
|
||||
for symbol in ("sim_posemath_normalize", "sim_posemath_dot", "sim_posemath_line_point",
|
||||
"sim_posemath_segment_validate", "sim_posemath_segment_point",
|
||||
"sim_tp_init", "sim_tp_step", "sim_tp_state_hash"):
|
||||
self.assertIn(symbol, text)
|
||||
|
||||
def test_report_records_inputs_outputs_and_determinism(self):
|
||||
report = __import__("json").loads((ROOT / "coverage/posemath-runtime.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "posemath-runtime-report.v2")
|
||||
self.assertGreater(report["inputs"]["file_count"], 0)
|
||||
self.assertGreater(report["outputs"]["file_count"], 0)
|
||||
self.assertTrue(report["determinism"]["byte_identical"])
|
||||
self.assertTrue(report["determinism"]["state_hash_identical"])
|
||||
|
||||
def test_posemath_source_is_linked(self):
|
||||
cmake = (ROOT / "wasm-core/CMakeLists.txt").read_text(encoding="utf-8")
|
||||
self.assertIn("posemath.cc", cmake)
|
||||
|
||||
31
scripts/test_robot_kinematics_report.py
Normal file
31
scripts/test_robot_kinematics_report.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_robot_kinematics_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class RobotKinematicsReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/robot-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "robot-kinematics-report.v1")
|
||||
self.assertTrue(all(row["scan"] for row in report["configuration_candidates"].values()))
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
self.assertIn("workspace_derived_fixture", report["configuration_candidates"]["scorbot"]["origin"])
|
||||
|
||||
def test_deterministic(self):
|
||||
first = ROOT / "coverage/.robot-one.json"
|
||||
second = ROOT / "coverage/.robot-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_robot_kinematics_report.generate(ROOT, first), generate_robot_kinematics_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
33
scripts/test_tripod_kinematics_report.py
Normal file
33
scripts/test_tripod_kinematics_report.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_tripod_kinematics_report
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TripodKinematicsReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/tripod-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "tripod-kinematics-report.v1")
|
||||
self.assertEqual(report["implementation_status"], "verified")
|
||||
self.assertTrue(report["configuration_candidate"]["scan"])
|
||||
self.assertEqual(report["geometry"]["hal_parameters"], {"Bx": 10.0, "Cx": 5.0, "Cy": 7.071})
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertTrue(report["samples"]["state_hash_identical"])
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
self.assertEqual(report["workspace_and_boundaries"]["base_plane_singular_error"], -704)
|
||||
|
||||
def test_deterministic(self):
|
||||
first = ROOT / "coverage/.tripod-one.json"
|
||||
second = ROOT / "coverage/.tripod-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_tripod_kinematics_report.generate(ROOT, first), generate_tripod_kinematics_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
18
scripts/test_trivkins_integration_report.py
Normal file
18
scripts/test_trivkins_integration_report.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TrivkinsIntegrationReportTests(unittest.TestCase):
|
||||
def test_report_contract(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
report = json.loads((root / "coverage/trivkins-integration.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "trivkins-integration-report.v1")
|
||||
self.assertEqual(report["status"], "partial")
|
||||
self.assertEqual(set(report["modes"]), {"identity", "forward_only", "inverse_only", "both"})
|
||||
self.assertEqual(report["tests"]["status"], "passed")
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
31
scripts/test_trt_report.py
Normal file
31
scripts/test_trt_report.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import generate_trt_report
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TrtReportTests(unittest.TestCase):
|
||||
def test_contract(self):
|
||||
report = json.loads((ROOT / "coverage/trt-kinematics.report.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema"], "trt-kinematics-report.v1")
|
||||
self.assertTrue(report["configuration_candidates"]["xyzac"]["scan"])
|
||||
self.assertTrue(report["configuration_candidates"]["xyzbc"]["scan"])
|
||||
self.assertTrue(report["samples"]["byte_identical_json"])
|
||||
self.assertEqual(report["oracle"]["status"], "blocked")
|
||||
|
||||
def test_deterministic(self):
|
||||
first = ROOT / "coverage/.trt-one.json"
|
||||
second = ROOT / "coverage/.trt-two.json"
|
||||
try:
|
||||
self.assertEqual(generate_trt_report.generate(ROOT, first), generate_trt_report.generate(ROOT, second))
|
||||
finally:
|
||||
first.unlink(missing_ok=True)
|
||||
second.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
207
scripts/test_validate_model_budget.py
Normal file
207
scripts/test_validate_model_budget.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from validate_model_budget import (
|
||||
ModelBudgetError,
|
||||
extract_response_usage,
|
||||
validate_provider_response,
|
||||
validate_request,
|
||||
validate_response,
|
||||
)
|
||||
|
||||
|
||||
def packet(
|
||||
profile="feature",
|
||||
token_limit=4096,
|
||||
packet_bytes=1200,
|
||||
evidence_bytes=300,
|
||||
byte_limit=16384,
|
||||
evidence_byte_limit=None,
|
||||
):
|
||||
budget = {
|
||||
"profile": profile,
|
||||
"token_limit": token_limit,
|
||||
"byte_limit": byte_limit,
|
||||
"packet_bytes": packet_bytes,
|
||||
"selected_evidence_bytes": evidence_bytes,
|
||||
}
|
||||
if evidence_byte_limit is not None:
|
||||
budget["evidence_byte_limit"] = evidence_byte_limit
|
||||
return {
|
||||
"budget": budget
|
||||
}
|
||||
|
||||
|
||||
class ModelBudgetTests(unittest.TestCase):
|
||||
def test_profile_reserves_output_and_safety_margin(self):
|
||||
result = validate_request(packet())
|
||||
self.assertEqual(result["max_output_tokens"], 1024)
|
||||
self.assertEqual(result["reserved_total_tokens"], 2780)
|
||||
|
||||
def test_non_packet_request_overhead_is_reserved(self):
|
||||
result = validate_request(packet(), overhead_tokens=700)
|
||||
self.assertEqual(result["overhead_tokens"], 700)
|
||||
self.assertEqual(result["reserved_total_tokens"], 3480)
|
||||
|
||||
def test_request_overhead_can_exhaust_context_window(self):
|
||||
result = validate_request(packet(), context_window=4000, overhead_tokens=700)
|
||||
self.assertEqual(result["estimated_request_input_tokens"], 2200)
|
||||
|
||||
def test_context_window_overflow_fails_before_request(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "context budget exceeded"):
|
||||
validate_request(packet(), context_window=1700)
|
||||
|
||||
def test_estimated_input_must_fit_declared_packet_budget(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "input token budget exceeded"):
|
||||
validate_request(packet(token_limit=400), input_tokens=401)
|
||||
|
||||
def test_byte_upper_bound_prevents_unsafe_heuristic_underestimate(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "input token budget exceeded"):
|
||||
validate_request(packet(token_limit=1499))
|
||||
|
||||
def test_exact_tokenizer_count_can_be_supplied(self):
|
||||
result = validate_request(packet(token_limit=600), input_tokens=500)
|
||||
self.assertEqual(result["estimated_input_tokens"], 500)
|
||||
|
||||
def test_explicit_output_budget_can_be_smaller(self):
|
||||
result = validate_request(packet(), context_window=5000, max_output_tokens=256)
|
||||
self.assertEqual(result["max_output_tokens"], 256)
|
||||
|
||||
def test_output_budget_cannot_expand_profile(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "output token budget exceeds profile"):
|
||||
validate_request(packet(), context_window=20000, max_output_tokens=2048)
|
||||
|
||||
def test_input_budget_cannot_expand_profile(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "packet token limit exceeds profile"):
|
||||
validate_request(packet(token_limit=5000), context_window=20000)
|
||||
|
||||
def test_byte_budget_cannot_expand_profile(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "byte limit exceeds profile"):
|
||||
validate_request(packet(byte_limit=16385), context_window=20000)
|
||||
|
||||
def test_evidence_budget_is_checked_even_without_envelope(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "selected evidence exceeds its evidence byte budget"):
|
||||
validate_request(packet(evidence_bytes=301, evidence_byte_limit=300))
|
||||
|
||||
def test_nested_accumulated_history_is_rejected(self):
|
||||
value = packet()
|
||||
value["scope"] = {"state": {"messages": ["old"]}}
|
||||
with self.assertRaisesRegex(ModelBudgetError, "forbidden accumulated context"):
|
||||
validate_request(value)
|
||||
|
||||
def test_full_packet_rejects_unknown_top_level_context(self):
|
||||
value = packet()
|
||||
value.update({"context_id": "c1", "packet_id": "p1", "tool_output": "log"})
|
||||
with self.assertRaisesRegex(ModelBudgetError, "unknown top-level context field"):
|
||||
validate_request(value)
|
||||
|
||||
def test_packet_byte_overflow_is_rejected(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "byte budget"):
|
||||
validate_request(packet(packet_bytes=16000, evidence_bytes=1000))
|
||||
|
||||
def test_full_packet_rejects_stale_serialized_size(self):
|
||||
value = packet()
|
||||
value.update({
|
||||
"context_id": "c1",
|
||||
"packet_id": "p1",
|
||||
"evidence": {"selected": [], "excluded": []},
|
||||
})
|
||||
with self.assertRaisesRegex(ModelBudgetError, "does not match serialized packet"):
|
||||
validate_request(value)
|
||||
|
||||
def test_packet_without_ids_cannot_lie_about_serialized_size(self):
|
||||
value = packet()
|
||||
value["task"] = "x" * 20000
|
||||
with self.assertRaisesRegex(ModelBudgetError, "does not match serialized packet"):
|
||||
validate_request(value)
|
||||
|
||||
def test_full_packet_rejects_inconsistent_evidence_totals(self):
|
||||
value = packet(packet_bytes=215, evidence_bytes=5)
|
||||
value.update({
|
||||
"context_id": "c1",
|
||||
"packet_id": "p1",
|
||||
"evidence": {"selected": [{"path": "x", "bytes": 4}], "excluded": []},
|
||||
})
|
||||
with self.assertRaisesRegex(ModelBudgetError, "selected evidence byte metadata"):
|
||||
validate_request(value)
|
||||
|
||||
def test_full_packet_rejects_accumulated_history(self):
|
||||
value = packet()
|
||||
value["history"] = ["old response"]
|
||||
with self.assertRaisesRegex(ModelBudgetError, "forbidden accumulated context"):
|
||||
validate_request(value)
|
||||
|
||||
def test_full_packet_rejects_unknown_exclusion_reason(self):
|
||||
value = packet(packet_bytes=203, evidence_bytes=0)
|
||||
value.update({
|
||||
"context_id": "c1",
|
||||
"packet_id": "p1",
|
||||
"evidence": {"selected": [], "excluded": [{"path": "x", "reason_code": "old"}]},
|
||||
})
|
||||
# Stabilize the serialized size after adding the evidence envelope.
|
||||
for _ in range(4):
|
||||
value["budget"]["packet_bytes"] = len(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
with self.assertRaisesRegex(ModelBudgetError, "unknown reason code"):
|
||||
validate_request(value)
|
||||
|
||||
def test_response_overrun_is_rejected(self):
|
||||
with self.assertRaisesRegex(ModelBudgetError, "response exceeded output token budget"):
|
||||
validate_response(1025, max_output_tokens=1024)
|
||||
|
||||
def test_response_length_finish_is_checkpointable(self):
|
||||
result = validate_response(1024, max_output_tokens=1024, finish_reason="length")
|
||||
self.assertTrue(result["truncated"])
|
||||
|
||||
def test_responses_api_incomplete_reason_is_checkpointable(self):
|
||||
result = validate_response(
|
||||
1024,
|
||||
max_output_tokens=1024,
|
||||
finish_reason="max_output_tokens",
|
||||
)
|
||||
self.assertTrue(result["truncated"])
|
||||
|
||||
def test_stream_envelope_usage_is_validated(self):
|
||||
event = {
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"status": "completed",
|
||||
"usage": {
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 40,
|
||||
"total_tokens": 340,
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertEqual(extract_response_usage(event)["output_tokens"], 40)
|
||||
result = validate_provider_response(event, max_output_tokens=64)
|
||||
self.assertEqual(result["status"], "completed")
|
||||
|
||||
def test_provider_usage_total_cannot_be_inconsistent(self):
|
||||
response = {
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 14},
|
||||
}
|
||||
with self.assertRaisesRegex(ModelBudgetError, "total is smaller"):
|
||||
validate_provider_response(response, max_output_tokens=16)
|
||||
|
||||
def test_failed_provider_response_is_not_reusable(self):
|
||||
response = {
|
||||
"status": "failed",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 0, "total_tokens": 10},
|
||||
}
|
||||
with self.assertRaisesRegex(ModelBudgetError, "not reusable"):
|
||||
validate_provider_response(response, max_output_tokens=16)
|
||||
|
||||
def test_provider_response_without_completion_status_is_rejected(self):
|
||||
response = {
|
||||
"usage": {"input_tokens": 10, "output_tokens": 1, "total_tokens": 11},
|
||||
}
|
||||
with self.assertRaisesRegex(ModelBudgetError, "missing completion status"):
|
||||
validate_provider_response(response, max_output_tokens=16)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
63
scripts/test_validate_task_pointer.py
Normal file
63
scripts/test_validate_task_pointer.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
import tempfile
|
||||
import unittest
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from validate_task_pointer import PointerError, validate_pointer
|
||||
|
||||
|
||||
VALID = """# pointer
|
||||
## Current
|
||||
context_id: c1
|
||||
## Read
|
||||
read paths only; coverage/report.json
|
||||
## Task
|
||||
one result
|
||||
## Next
|
||||
one next
|
||||
## Verify
|
||||
one test
|
||||
## Stop
|
||||
stop on drift
|
||||
"""
|
||||
|
||||
|
||||
class TaskPointerTests(unittest.TestCase):
|
||||
def write(self, text: str) -> Path:
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
path = Path(directory.name) / "nextTask.md"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_valid_pointer_reports_bounded_size(self):
|
||||
result = validate_pointer(self.write(VALID))
|
||||
self.assertLessEqual(result["bytes"], 8192)
|
||||
|
||||
def test_context_id_must_match_lock_when_supplied(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
root = Path(directory.name)
|
||||
pointer = root / "nextTask.md"
|
||||
pointer.write_text(VALID, encoding="utf-8")
|
||||
lock = root / "context-lock.json"
|
||||
lock.write_text(json.dumps({"context_id": "other"}), encoding="utf-8")
|
||||
with self.assertRaisesRegex(PointerError, "does not match lock"):
|
||||
validate_pointer(pointer, lock_path=lock)
|
||||
|
||||
def test_history_heading_is_rejected(self):
|
||||
with self.assertRaisesRegex(PointerError, "history section"):
|
||||
validate_pointer(self.write(VALID + "\n## History\nold log\n"))
|
||||
|
||||
def test_evidence_heading_is_rejected(self):
|
||||
with self.assertRaisesRegex(PointerError, "history section"):
|
||||
validate_pointer(self.write(VALID + "\n## Evidence\nold evidence\n"))
|
||||
|
||||
def test_large_pointer_is_rejected(self):
|
||||
with self.assertRaisesRegex(PointerError, "byte budget"):
|
||||
validate_pointer(self.write(VALID + "x" * 9000))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -189,6 +189,301 @@ def validate(repo_root: Path, lock_path: Path, source_path: Path, manifest_path:
|
||||
errors.append("posemath runtime report is missing")
|
||||
elif posemath_report_hash != sha256(posemath_report_path):
|
||||
errors.append("posemath runtime report hash does not match context lock")
|
||||
interpreter_report_path = repo_root / "coverage/interpreter-runtime.report.json"
|
||||
interpreter_report_hash = lock.get("interpreter_runtime_report_sha256")
|
||||
if interpreter_report_hash:
|
||||
if not interpreter_report_path.is_file():
|
||||
errors.append("interpreter runtime report is missing")
|
||||
elif interpreter_report_hash != sha256(interpreter_report_path):
|
||||
errors.append("interpreter runtime report hash does not match context lock")
|
||||
motion_report_path = repo_root / "coverage/motion-runtime.report.json"
|
||||
motion_report_hash = lock.get("motion_runtime_report_sha256")
|
||||
if motion_report_hash:
|
||||
if not motion_report_path.is_file():
|
||||
errors.append("motion runtime report is missing")
|
||||
elif motion_report_hash != sha256(motion_report_path):
|
||||
errors.append("motion runtime report hash does not match context lock")
|
||||
kinematics_runtime_report_path = repo_root / "coverage/kinematics-runtime.report.json"
|
||||
kinematics_runtime_report_hash = lock.get("kinematics_runtime_report_sha256")
|
||||
if kinematics_runtime_report_hash:
|
||||
if not kinematics_runtime_report_path.is_file():
|
||||
errors.append("kinematics runtime report is missing")
|
||||
elif kinematics_runtime_report_hash != sha256(kinematics_runtime_report_path):
|
||||
errors.append("kinematics runtime report hash does not match context lock")
|
||||
kinematics_closure_path = repo_root / "coverage/kinematics-source-closure.report.json"
|
||||
kinematics_closure_hash = lock.get("kinematics_source_closure_report_sha256")
|
||||
if kinematics_closure_hash:
|
||||
if not kinematics_closure_path.is_file():
|
||||
errors.append("kinematics source closure report is missing")
|
||||
elif kinematics_closure_hash != sha256(kinematics_closure_path):
|
||||
errors.append("kinematics source closure report hash does not match context lock")
|
||||
if kinematics_closure_path.is_file() and isinstance(inventory_count, dict):
|
||||
try:
|
||||
kinematics_report = json.loads(kinematics_closure_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("kinematics source closure report is not valid JSON")
|
||||
else:
|
||||
expected_counts = {
|
||||
"kinematics_source_files": kinematics_report.get("source", {}).get("implementation_file_count"),
|
||||
"kinematics_config_entries": kinematics_report.get("configuration", {}).get("entry_count"),
|
||||
"kinematics_hal_entries": kinematics_report.get("hal", {}).get("entry_count"),
|
||||
"kinematics_test_files": kinematics_report.get("tests", {}).get("file_count"),
|
||||
}
|
||||
for field, value in expected_counts.items():
|
||||
if value is not None and inventory_count.get(field) != value:
|
||||
errors.append(f"{field} count does not match kinematics closure report")
|
||||
kinematics_graph_path = repo_root / "coverage/kinematics-config-graph.report.json"
|
||||
kinematics_graph_hash = lock.get("kinematics_config_graph_report_sha256")
|
||||
if kinematics_graph_hash:
|
||||
if not kinematics_graph_path.is_file():
|
||||
errors.append("kinematics config graph report is missing")
|
||||
elif kinematics_graph_hash != sha256(kinematics_graph_path):
|
||||
errors.append("kinematics config graph report hash does not match context lock")
|
||||
samples_path = repo_root / "coverage/kinematics-oracle-samples.jsonl"
|
||||
samples_hash = lock.get("kinematics_oracle_samples_sha256")
|
||||
if samples_hash:
|
||||
if not samples_path.is_file():
|
||||
errors.append("kinematics oracle samples are missing")
|
||||
elif samples_hash != sha256(samples_path):
|
||||
errors.append("kinematics oracle samples hash does not match context lock")
|
||||
oracle_report_path = repo_root / "coverage/kinematics-oracle.report.json"
|
||||
oracle_report_hash = lock.get("kinematics_oracle_report_sha256")
|
||||
if oracle_report_hash:
|
||||
if not oracle_report_path.is_file():
|
||||
errors.append("kinematics oracle report is missing")
|
||||
elif oracle_report_hash != sha256(oracle_report_path):
|
||||
errors.append("kinematics oracle report hash does not match context lock")
|
||||
oracle_schema_path = repo_root / "coverage/kinematics-golden-schema.json"
|
||||
oracle_schema_hash = lock.get("kinematics_golden_schema_sha256")
|
||||
if oracle_schema_hash:
|
||||
if not oracle_schema_path.is_file():
|
||||
errors.append("kinematics golden schema is missing")
|
||||
elif oracle_schema_hash != sha256(oracle_schema_path):
|
||||
errors.append("kinematics golden schema hash does not match context lock")
|
||||
if kinematics_graph_path.is_file() and isinstance(inventory_count, dict):
|
||||
try:
|
||||
graph = json.loads(kinematics_graph_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("kinematics config graph report is not valid JSON")
|
||||
else:
|
||||
graph_counts = graph.get("counts", {})
|
||||
for field, report_field in (("kinematics_graph_entries", "entry_nodes"), ("kinematics_graph_edges", "edges"), ("kinematics_oracle_samples", "samples")):
|
||||
value = graph_counts.get(report_field)
|
||||
if value is not None and inventory_count.get(field) != value:
|
||||
errors.append(f"{field} count does not match kinematics config graph report")
|
||||
if oracle_report_path.is_file() and isinstance(inventory_count, dict):
|
||||
try:
|
||||
oracle_report = json.loads(oracle_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("kinematics oracle report is not valid JSON")
|
||||
else:
|
||||
if oracle_report.get("sample_count") is not None and inventory_count.get("kinematics_oracle_samples") != oracle_report.get("sample_count"):
|
||||
errors.append("kinematics oracle sample count does not match oracle report")
|
||||
abi_contract_path = repo_root / "coverage/kinematics-abi-contract.json"
|
||||
abi_contract_hash = lock.get("kinematics_abi_contract_sha256")
|
||||
if abi_contract_hash:
|
||||
if not abi_contract_path.is_file():
|
||||
errors.append("kinematics ABI contract is missing")
|
||||
elif abi_contract_hash != sha256(abi_contract_path):
|
||||
errors.append("kinematics ABI contract hash does not match context lock")
|
||||
if abi_contract_path.is_file():
|
||||
try:
|
||||
abi_contract = json.loads(abi_contract_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("kinematics ABI contract is not valid JSON")
|
||||
else:
|
||||
if abi_contract.get("status") != "verified":
|
||||
errors.append("kinematics ABI contract is not verified")
|
||||
mapping_contract_path = repo_root / "coverage/kinematics-mapping-contract.json"
|
||||
mapping_contract_hash = lock.get("kinematics_mapping_contract_sha256")
|
||||
if mapping_contract_hash:
|
||||
if not mapping_contract_path.is_file():
|
||||
errors.append("kinematics mapping contract is missing")
|
||||
elif mapping_contract_hash != sha256(mapping_contract_path):
|
||||
errors.append("kinematics mapping contract hash does not match context lock")
|
||||
if mapping_contract_path.is_file():
|
||||
try:
|
||||
mapping_contract = json.loads(mapping_contract_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("kinematics mapping contract is not valid JSON")
|
||||
else:
|
||||
if mapping_contract.get("status") != "verified":
|
||||
errors.append("kinematics mapping contract is not verified")
|
||||
numeric_policy_path = repo_root / "coverage/kinematics-numeric-policy.json"
|
||||
numeric_policy_hash = lock.get("kinematics_numeric_policy_sha256")
|
||||
if numeric_policy_hash:
|
||||
if not numeric_policy_path.is_file():
|
||||
errors.append("kinematics numeric policy is missing")
|
||||
elif numeric_policy_hash != sha256(numeric_policy_path):
|
||||
errors.append("kinematics numeric policy hash does not match context lock")
|
||||
if numeric_policy_path.is_file():
|
||||
try:
|
||||
numeric_policy = json.loads(numeric_policy_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("kinematics numeric policy is not valid JSON")
|
||||
else:
|
||||
if numeric_policy.get("status") != "verified":
|
||||
errors.append("kinematics numeric policy is not verified")
|
||||
trivkins_report_path = repo_root / "coverage/trivkins-integration.report.json"
|
||||
trivkins_report_hash = lock.get("trivkins_integration_report_sha256")
|
||||
if trivkins_report_hash:
|
||||
if not trivkins_report_path.is_file():
|
||||
errors.append("trivkins integration report is missing")
|
||||
elif trivkins_report_hash != sha256(trivkins_report_path):
|
||||
errors.append("trivkins integration report hash does not match context lock")
|
||||
coupled_report_path = repo_root / "coverage/coupled-kinematics.report.json"
|
||||
coupled_report_hash = lock.get("coupled_kinematics_report_sha256")
|
||||
if coupled_report_hash:
|
||||
if not coupled_report_path.is_file():
|
||||
errors.append("coupled kinematics report is missing")
|
||||
elif coupled_report_hash != sha256(coupled_report_path):
|
||||
errors.append("coupled kinematics report hash does not match context lock")
|
||||
if coupled_report_path.is_file():
|
||||
try:
|
||||
coupled_report = json.loads(coupled_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("coupled kinematics report is not valid JSON")
|
||||
else:
|
||||
if coupled_report.get("implementation_status") != "verified":
|
||||
errors.append("coupled kinematics implementation is not verified")
|
||||
if coupled_report.get("closure", {}).get("official_oracle") != "blocked":
|
||||
errors.append("coupled kinematics oracle status must remain explicitly blocked")
|
||||
corexy_hal_report_path = repo_root / "coverage/corexy-hal.report.json"
|
||||
corexy_hal_report_hash = lock.get("corexy_hal_report_sha256")
|
||||
if corexy_hal_report_hash:
|
||||
if not corexy_hal_report_path.is_file():
|
||||
errors.append("corexy HAL report is missing")
|
||||
elif corexy_hal_report_hash != sha256(corexy_hal_report_path):
|
||||
errors.append("corexy HAL report hash does not match context lock")
|
||||
if corexy_hal_report_path.is_file():
|
||||
try:
|
||||
corexy_hal_report = json.loads(corexy_hal_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("corexy HAL report is not valid JSON")
|
||||
else:
|
||||
if corexy_hal_report.get("implementation_status") != "verified":
|
||||
errors.append("corexy HAL implementation is not verified")
|
||||
if corexy_hal_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("corexy HAL oracle status must remain explicitly blocked")
|
||||
fiveaxis_report_path = repo_root / "coverage/fiveaxis-kinematics.report.json"
|
||||
fiveaxis_report_hash = lock.get("fiveaxis_kinematics_report_sha256")
|
||||
if fiveaxis_report_hash:
|
||||
if not fiveaxis_report_path.is_file():
|
||||
errors.append("fiveaxis kinematics report is missing")
|
||||
elif fiveaxis_report_hash != sha256(fiveaxis_report_path):
|
||||
errors.append("fiveaxis kinematics report hash does not match context lock")
|
||||
if fiveaxis_report_path.is_file():
|
||||
try:
|
||||
fiveaxis_report = json.loads(fiveaxis_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("fiveaxis kinematics report is not valid JSON")
|
||||
else:
|
||||
if fiveaxis_report.get("implementation_status") != "verified":
|
||||
errors.append("fiveaxis kinematics implementation is not verified")
|
||||
if fiveaxis_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("fiveaxis kinematics oracle status must remain explicitly blocked")
|
||||
trt_report_path = repo_root / "coverage/trt-kinematics.report.json"
|
||||
trt_report_hash = lock.get("trt_kinematics_report_sha256")
|
||||
if trt_report_hash:
|
||||
if not trt_report_path.is_file():
|
||||
errors.append("TRT kinematics report is missing")
|
||||
elif trt_report_hash != sha256(trt_report_path):
|
||||
errors.append("TRT kinematics report hash does not match context lock")
|
||||
if trt_report_path.is_file():
|
||||
try:
|
||||
trt_report = json.loads(trt_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("TRT kinematics report is not valid JSON")
|
||||
else:
|
||||
if trt_report.get("implementation_status") != "verified":
|
||||
errors.append("TRT kinematics implementation is not verified")
|
||||
if trt_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("TRT kinematics oracle status must remain explicitly blocked")
|
||||
robot_report_path = repo_root / "coverage/robot-kinematics.report.json"
|
||||
robot_report_hash = lock.get("robot_kinematics_report_sha256")
|
||||
if robot_report_hash:
|
||||
if not robot_report_path.is_file():
|
||||
errors.append("robot kinematics report is missing")
|
||||
elif robot_report_hash != sha256(robot_report_path):
|
||||
errors.append("robot kinematics report hash does not match context lock")
|
||||
if robot_report_path.is_file():
|
||||
try:
|
||||
robot_report = json.loads(robot_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("robot kinematics report is not valid JSON")
|
||||
else:
|
||||
if robot_report.get("implementation_status") != "verified":
|
||||
errors.append("robot kinematics implementation is not verified")
|
||||
if robot_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("robot kinematics oracle status must remain explicitly blocked")
|
||||
genser_report_path = repo_root / "coverage/genser-kinematics.report.json"
|
||||
genser_report_hash = lock.get("genser_kinematics_report_sha256")
|
||||
if genser_report_hash:
|
||||
if not genser_report_path.is_file():
|
||||
errors.append("genser kinematics report is missing")
|
||||
elif genser_report_hash != sha256(genser_report_path):
|
||||
errors.append("genser kinematics report hash does not match context lock")
|
||||
if genser_report_path.is_file():
|
||||
try:
|
||||
genser_report = json.loads(genser_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("genser kinematics report is not valid JSON")
|
||||
else:
|
||||
if genser_report.get("implementation_status") != "verified":
|
||||
errors.append("genser kinematics implementation is not verified")
|
||||
if genser_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("genser kinematics oracle status must remain explicitly blocked")
|
||||
delta_report_path = repo_root / "coverage/delta-kinematics.report.json"
|
||||
delta_report_hash = lock.get("delta_kinematics_report_sha256")
|
||||
if delta_report_hash:
|
||||
if not delta_report_path.is_file():
|
||||
errors.append("delta kinematics report is missing")
|
||||
elif delta_report_hash != sha256(delta_report_path):
|
||||
errors.append("delta kinematics report hash does not match context lock")
|
||||
if delta_report_path.is_file():
|
||||
try:
|
||||
delta_report = json.loads(delta_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("delta kinematics report is not valid JSON")
|
||||
else:
|
||||
if delta_report.get("implementation_status") != "verified":
|
||||
errors.append("delta kinematics implementation is not verified")
|
||||
if delta_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("delta kinematics oracle status must remain explicitly blocked")
|
||||
tripod_report_path = repo_root / "coverage/tripod-kinematics.report.json"
|
||||
tripod_report_hash = lock.get("tripod_kinematics_report_sha256")
|
||||
if tripod_report_hash:
|
||||
if not tripod_report_path.is_file():
|
||||
errors.append("tripod kinematics report is missing")
|
||||
elif tripod_report_hash != sha256(tripod_report_path):
|
||||
errors.append("tripod kinematics report hash does not match context lock")
|
||||
if tripod_report_path.is_file():
|
||||
try:
|
||||
tripod_report = json.loads(tripod_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("tripod kinematics report is not valid JSON")
|
||||
else:
|
||||
if tripod_report.get("implementation_status") != "verified":
|
||||
errors.append("tripod kinematics implementation is not verified")
|
||||
if tripod_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("tripod kinematics oracle status must remain explicitly blocked")
|
||||
genhex_report_path = repo_root / "coverage/genhex-kinematics.report.json"
|
||||
genhex_report_hash = lock.get("genhex_kinematics_report_sha256")
|
||||
if genhex_report_hash:
|
||||
if not genhex_report_path.is_file():
|
||||
errors.append("genhex kinematics report is missing")
|
||||
elif genhex_report_hash != sha256(genhex_report_path):
|
||||
errors.append("genhex kinematics report hash does not match context lock")
|
||||
if genhex_report_path.is_file():
|
||||
try:
|
||||
genhex_report = json.loads(genhex_report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
errors.append("genhex kinematics report is not valid JSON")
|
||||
else:
|
||||
if genhex_report.get("implementation_status") != "verified":
|
||||
errors.append("genhex kinematics implementation is not verified")
|
||||
if genhex_report.get("oracle", {}).get("status") != "blocked":
|
||||
errors.append("genhex kinematics oracle status must remain explicitly blocked")
|
||||
source_ids = [str(row.get("source_id", "")) for row in sources]
|
||||
if len(source_ids) != len(set(source_ids)):
|
||||
errors.append("source index contains duplicate source_id")
|
||||
|
||||
501
scripts/validate_model_budget.py
Normal file
501
scripts/validate_model_budget.py
Normal file
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that a bounded context packet leaves room for model output.
|
||||
|
||||
This is deliberately provider-neutral. The caller supplies the model's
|
||||
context window; the packet's input budget and a fixed output reserve are
|
||||
checked before an API request is made.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
OUTPUT_TOKEN_RESERVE = {
|
||||
"doc": 768,
|
||||
"feature": 1024,
|
||||
"contract": 1536,
|
||||
"milestone": 2048,
|
||||
}
|
||||
PROFILE_INPUT_TOKEN_LIMIT = {
|
||||
"doc": 2048,
|
||||
"feature": 4096,
|
||||
"contract": 6144,
|
||||
"milestone": 8192,
|
||||
}
|
||||
PROFILE_BYTE_LIMIT = {
|
||||
"doc": 8192,
|
||||
"feature": 16384,
|
||||
"contract": 24576,
|
||||
"milestone": 32768,
|
||||
}
|
||||
PROFILE_EVIDENCE_BYTE_LIMIT = {
|
||||
"doc": 2048,
|
||||
"feature": 6144,
|
||||
"contract": 10240,
|
||||
"milestone": 14336,
|
||||
}
|
||||
PROFILE_EVIDENCE_FILE_LIMIT = {
|
||||
"doc": 8,
|
||||
"feature": 16,
|
||||
"contract": 24,
|
||||
"milestone": 32,
|
||||
}
|
||||
DEFAULT_CONTEXT_WINDOW = 16384
|
||||
DEFAULT_SAFETY_MARGIN = 256
|
||||
# Without a provider tokenizer, UTF-8 bytes are the only portable hard upper
|
||||
# bound. Any multi-byte-per-token heuristic can under-count provider tokens
|
||||
# for punctuation-heavy or non-ASCII input and is unsafe for a request gate.
|
||||
TOKEN_BYTES_PER_TOKEN = 1
|
||||
PACKET_FIELDS = {
|
||||
"context_id",
|
||||
"packet_id",
|
||||
"task",
|
||||
"header",
|
||||
"baseline",
|
||||
"scope",
|
||||
"contracts",
|
||||
"evidence",
|
||||
"risks",
|
||||
"next",
|
||||
"forbidden",
|
||||
"budget",
|
||||
}
|
||||
FORBIDDEN_CONTEXT_FIELDS = {
|
||||
"history",
|
||||
"chat",
|
||||
"conversation",
|
||||
"messages",
|
||||
"transcript",
|
||||
"previous_packet",
|
||||
"prior_context",
|
||||
}
|
||||
EXCLUSION_REASON_CODES = {"E1", "E2", "E3", "E4", "E5", "E6"}
|
||||
|
||||
|
||||
class ModelBudgetError(ValueError):
|
||||
"""Raised when a request cannot fit input, output, and safety budgets."""
|
||||
|
||||
|
||||
def _response_object(value: Any) -> dict[str, Any]:
|
||||
"""Return a provider response object from a response or SSE envelope.
|
||||
|
||||
The Responses API exposes usage on the response itself, while streaming
|
||||
``response.completed`` events wrap it below a ``response`` key. Keeping
|
||||
this normalization here prevents callers from accidentally validating the
|
||||
event envelope instead of the actual provider usage.
|
||||
"""
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise ModelBudgetError("provider response must be an object")
|
||||
response = value.get("response")
|
||||
if isinstance(response, dict):
|
||||
return response
|
||||
return value
|
||||
|
||||
|
||||
def extract_response_usage(value: Any) -> dict[str, int]:
|
||||
"""Extract authoritative token usage from a provider response envelope."""
|
||||
|
||||
response = _response_object(value)
|
||||
usage = response.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
raise ModelBudgetError("provider response is missing usage")
|
||||
try:
|
||||
input_tokens = int(usage["input_tokens"])
|
||||
output_tokens = int(usage["output_tokens"])
|
||||
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("provider usage has invalid token counts") from exc
|
||||
if min(input_tokens, output_tokens, total_tokens) < 0:
|
||||
raise ModelBudgetError("provider usage token counts must be non-negative")
|
||||
if total_tokens < input_tokens + output_tokens:
|
||||
raise ModelBudgetError("provider usage total is smaller than input plus output")
|
||||
return {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
|
||||
|
||||
def _find_forbidden(value: Any, path: str = "") -> str | None:
|
||||
"""Find accumulated conversation state, including when nested."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
key_text = str(key)
|
||||
child_path = f"{path}.{key_text}" if path else key_text
|
||||
if key_text in FORBIDDEN_CONTEXT_FIELDS:
|
||||
return child_path
|
||||
found = _find_forbidden(child, child_path)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
found = _find_forbidden(child, f"{path}[{index}]")
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _packet_input_bytes(packet: dict[str, Any]) -> int:
|
||||
budget = packet.get("budget")
|
||||
if not isinstance(budget, dict):
|
||||
raise ModelBudgetError("packet budget is missing")
|
||||
try:
|
||||
packet_bytes = int(budget["packet_bytes"])
|
||||
evidence_bytes = int(budget["selected_evidence_bytes"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("packet budget lacks input byte totals") from exc
|
||||
if packet_bytes < 0 or evidence_bytes < 0:
|
||||
raise ModelBudgetError("packet input byte totals must be non-negative")
|
||||
return packet_bytes + evidence_bytes
|
||||
|
||||
|
||||
def _canonical_packet_bytes(packet: dict[str, Any]) -> int:
|
||||
"""Return the exact UTF-8 size used by the packet generator."""
|
||||
|
||||
return len(
|
||||
json.dumps(packet, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _validate_packet_metadata(packet: dict[str, Any], budget: dict[str, Any]) -> None:
|
||||
"""Reject stale or inconsistent budget metadata before a model request.
|
||||
|
||||
Small unit-test fixtures may contain only a budget object. Full packets
|
||||
generated for a request always carry ``context_id``/``packet_id`` and an
|
||||
evidence envelope, so strict consistency checks are applied to those.
|
||||
"""
|
||||
|
||||
forbidden = _find_forbidden(packet)
|
||||
if forbidden:
|
||||
raise ModelBudgetError(
|
||||
"packet contains forbidden accumulated context "
|
||||
f"field: {forbidden}"
|
||||
)
|
||||
|
||||
# Generated packets have a closed envelope. Reject ad-hoc top-level
|
||||
# fields that could smuggle full logs, tool output or a previous request
|
||||
# beside the audited eight segments.
|
||||
if "budget" in packet:
|
||||
unknown = sorted(set(packet) - PACKET_FIELDS)
|
||||
if unknown:
|
||||
raise ModelBudgetError(
|
||||
"packet contains unknown top-level context field: "
|
||||
f"{unknown[0]}"
|
||||
)
|
||||
|
||||
if set(packet) != {"budget"}:
|
||||
actual = _canonical_packet_bytes(packet)
|
||||
declared = int(budget["packet_bytes"])
|
||||
if declared != actual:
|
||||
raise ModelBudgetError(
|
||||
"packet byte metadata does not match serialized packet "
|
||||
f"(declared={declared}, actual={actual})"
|
||||
)
|
||||
|
||||
evidence = packet.get("evidence")
|
||||
if not isinstance(evidence, dict):
|
||||
return
|
||||
selected = evidence.get("selected", [])
|
||||
excluded = evidence.get("excluded", [])
|
||||
if not isinstance(selected, list) or not isinstance(excluded, list):
|
||||
raise ModelBudgetError("packet evidence must contain selected/excluded lists")
|
||||
for item in excluded:
|
||||
if not isinstance(item, dict) or item.get("reason_code") not in EXCLUSION_REASON_CODES:
|
||||
raise ModelBudgetError("excluded evidence has an unknown reason code")
|
||||
selected_bytes = 0
|
||||
for item in selected:
|
||||
if not isinstance(item, dict):
|
||||
raise ModelBudgetError("selected evidence entries must be objects")
|
||||
try:
|
||||
item_bytes = int(item["bytes"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("selected evidence entry lacks bytes") from exc
|
||||
if item_bytes < 0:
|
||||
raise ModelBudgetError("selected evidence bytes must be non-negative")
|
||||
selected_bytes += item_bytes
|
||||
if selected_bytes != int(budget["selected_evidence_bytes"]):
|
||||
raise ModelBudgetError(
|
||||
"selected evidence byte metadata does not match entries "
|
||||
f"(declared={budget['selected_evidence_bytes']}, actual={selected_bytes})"
|
||||
)
|
||||
if "selected_evidence_files" in budget and int(budget["selected_evidence_files"]) != len(selected):
|
||||
raise ModelBudgetError("selected evidence file count does not match entries")
|
||||
if "excluded_evidence_files" in budget and int(budget["excluded_evidence_files"]) != len(excluded):
|
||||
raise ModelBudgetError("excluded evidence file count does not match entries")
|
||||
if "evidence_byte_limit" in budget and selected_bytes > int(budget["evidence_byte_limit"]):
|
||||
raise ModelBudgetError("selected evidence exceeds its evidence byte budget")
|
||||
if "evidence_file_limit" in budget and len(selected) > int(budget["evidence_file_limit"]):
|
||||
raise ModelBudgetError("selected evidence exceeds its file budget")
|
||||
|
||||
|
||||
def estimate_input_tokens(packet: dict[str, Any]) -> int:
|
||||
"""Return a safe provider-neutral upper bound from UTF-8 bytes.
|
||||
|
||||
A tokenizer supplied by the API client may replace this estimate. The
|
||||
byte-for-token bound is deliberately strict: it may reject a packet that
|
||||
would fit a particular provider, but it cannot silently approve a packet
|
||||
that that provider tokenizes larger than expected.
|
||||
"""
|
||||
|
||||
return math.ceil(_packet_input_bytes(packet) / TOKEN_BYTES_PER_TOKEN)
|
||||
|
||||
|
||||
def validate_request(
|
||||
packet: dict[str, Any],
|
||||
*,
|
||||
context_window: int = DEFAULT_CONTEXT_WINDOW,
|
||||
max_output_tokens: int | None = None,
|
||||
safety_margin: int = DEFAULT_SAFETY_MARGIN,
|
||||
input_tokens: int | None = None,
|
||||
overhead_tokens: int = 0,
|
||||
) -> dict[str, int]:
|
||||
budget = packet.get("budget")
|
||||
if not isinstance(budget, dict):
|
||||
raise ModelBudgetError("packet budget is missing")
|
||||
try:
|
||||
input_limit = int(budget["token_limit"])
|
||||
byte_limit = int(budget["byte_limit"])
|
||||
packet_bytes = int(budget["packet_bytes"])
|
||||
selected_evidence_bytes = int(budget["selected_evidence_bytes"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("packet budget lacks required fields") from exc
|
||||
profile = str(budget.get("profile", ""))
|
||||
try:
|
||||
profile_output_limit = OUTPUT_TOKEN_RESERVE[profile]
|
||||
profile_input_limit = PROFILE_INPUT_TOKEN_LIMIT[profile]
|
||||
profile_byte_limit = PROFILE_BYTE_LIMIT[profile]
|
||||
profile_evidence_limit = PROFILE_EVIDENCE_BYTE_LIMIT[profile]
|
||||
profile_file_limit = PROFILE_EVIDENCE_FILE_LIMIT[profile]
|
||||
except KeyError as exc:
|
||||
raise ModelBudgetError(f"unknown packet profile: {profile}") from exc
|
||||
if max_output_tokens is None:
|
||||
max_output_tokens = profile_output_limit
|
||||
try:
|
||||
context_window = int(context_window)
|
||||
max_output_tokens = int(max_output_tokens)
|
||||
safety_margin = int(safety_margin)
|
||||
overhead_tokens = int(overhead_tokens)
|
||||
declared_evidence_limit = int(budget.get("evidence_byte_limit", profile_evidence_limit))
|
||||
declared_file_limit = int(budget.get("evidence_file_limit", profile_file_limit))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("model budget fields must be integers") from exc
|
||||
values = (
|
||||
context_window,
|
||||
max_output_tokens,
|
||||
safety_margin,
|
||||
input_limit,
|
||||
byte_limit,
|
||||
packet_bytes,
|
||||
selected_evidence_bytes,
|
||||
overhead_tokens,
|
||||
declared_evidence_limit,
|
||||
declared_file_limit,
|
||||
)
|
||||
if any(value < 0 for value in values):
|
||||
raise ModelBudgetError("model budgets must be non-negative")
|
||||
if input_limit > profile_input_limit:
|
||||
raise ModelBudgetError(
|
||||
"packet token limit exceeds profile "
|
||||
f"(declared={input_limit}, profile={profile_input_limit})"
|
||||
)
|
||||
if byte_limit > profile_byte_limit:
|
||||
raise ModelBudgetError(
|
||||
"packet byte limit exceeds profile "
|
||||
f"(declared={byte_limit}, profile={profile_byte_limit})"
|
||||
)
|
||||
if declared_evidence_limit > profile_evidence_limit:
|
||||
raise ModelBudgetError(
|
||||
"evidence byte limit exceeds profile "
|
||||
f"(declared={declared_evidence_limit}, profile={profile_evidence_limit})"
|
||||
)
|
||||
if declared_file_limit > profile_file_limit:
|
||||
raise ModelBudgetError(
|
||||
"evidence file limit exceeds profile "
|
||||
f"(declared={declared_file_limit}, profile={profile_file_limit})"
|
||||
)
|
||||
if max_output_tokens > profile_output_limit:
|
||||
raise ModelBudgetError(
|
||||
"output token budget exceeds profile "
|
||||
f"(requested={max_output_tokens}, profile={profile_output_limit})"
|
||||
)
|
||||
try:
|
||||
_validate_packet_metadata(packet, budget)
|
||||
except ModelBudgetError:
|
||||
raise
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("packet budget metadata is malformed") from exc
|
||||
context_bytes = packet_bytes + selected_evidence_bytes
|
||||
if packet_bytes > byte_limit or context_bytes > byte_limit:
|
||||
raise ModelBudgetError("packet exceeds its declared byte budget")
|
||||
if selected_evidence_bytes > declared_evidence_limit:
|
||||
raise ModelBudgetError("selected evidence exceeds its evidence byte budget")
|
||||
if selected_evidence_bytes > profile_evidence_limit:
|
||||
raise ModelBudgetError("selected evidence exceeds profile byte budget")
|
||||
try:
|
||||
estimated_input = estimate_input_tokens(packet) if input_tokens is None else int(input_tokens)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("input token count must be an integer") from exc
|
||||
if estimated_input < 0:
|
||||
raise ModelBudgetError("input token count must be non-negative")
|
||||
if estimated_input > input_limit:
|
||||
raise ModelBudgetError(
|
||||
"packet input token budget exceeded "
|
||||
f"(estimated={estimated_input}, limit={input_limit})"
|
||||
)
|
||||
# Reserve the input actually sent for this request plus fixed provider
|
||||
# overhead. Reserving the full profile here rejects small packets for no
|
||||
# reason, while the packet's own input_limit still prevents later growth.
|
||||
effective_input = estimated_input + overhead_tokens
|
||||
available = context_window - safety_margin - max_output_tokens
|
||||
if effective_input > available:
|
||||
raise ModelBudgetError(
|
||||
"model context budget exceeded "
|
||||
f"(input={effective_input}, output={max_output_tokens}, "
|
||||
f"safety={safety_margin}, window={context_window})"
|
||||
)
|
||||
return {
|
||||
"context_window": context_window,
|
||||
"input_token_limit": input_limit,
|
||||
"estimated_input_tokens": estimated_input,
|
||||
"overhead_tokens": overhead_tokens,
|
||||
"estimated_request_input_tokens": estimated_input + overhead_tokens,
|
||||
"max_output_tokens": max_output_tokens,
|
||||
"safety_margin": safety_margin,
|
||||
"reserved_total_tokens": effective_input + max_output_tokens + safety_margin,
|
||||
"available_input_tokens": max(0, available - overhead_tokens),
|
||||
}
|
||||
|
||||
|
||||
def validate_response(
|
||||
output_tokens: int,
|
||||
*,
|
||||
max_output_tokens: int,
|
||||
finish_reason: str | None = None,
|
||||
) -> dict[str, int | str | None]:
|
||||
"""Validate the completion actually returned by a provider.
|
||||
|
||||
The preflight check protects the request, while this postflight check
|
||||
catches SDKs that ignore or reinterpret ``max_output_tokens``. A length
|
||||
finish is reported as truncation so callers can persist a checkpoint and
|
||||
continue with a fresh bounded request instead of replaying the old one.
|
||||
"""
|
||||
|
||||
try:
|
||||
actual = int(output_tokens)
|
||||
limit = int(max_output_tokens)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ModelBudgetError("response token counts must be integers") from exc
|
||||
if actual < 0 or limit < 0:
|
||||
raise ModelBudgetError("response token counts must be non-negative")
|
||||
if actual > limit:
|
||||
raise ModelBudgetError(
|
||||
"model response exceeded output token budget "
|
||||
f"(actual={actual}, limit={limit})"
|
||||
)
|
||||
return {
|
||||
"output_tokens": actual,
|
||||
"max_output_tokens": limit,
|
||||
"finish_reason": finish_reason,
|
||||
"truncated": finish_reason in {
|
||||
"length",
|
||||
"max_tokens",
|
||||
"max_output_tokens",
|
||||
"max_completion_tokens",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_provider_response(
|
||||
response: Any,
|
||||
*,
|
||||
max_output_tokens: int,
|
||||
) -> dict[str, int | str | None | bool]:
|
||||
"""Validate usage and completion state returned by a provider.
|
||||
|
||||
``response`` may be a normal Responses API object or a streaming event
|
||||
envelope. Failed/incomplete responses are rejected as non-reusable so a
|
||||
caller cannot append partial output to the next request. A length-limit
|
||||
completion is returned as ``truncated`` and must be resumed from a fresh
|
||||
checkpoint.
|
||||
"""
|
||||
|
||||
response_object = _response_object(response)
|
||||
usage = extract_response_usage(response_object)
|
||||
status = response_object.get("status")
|
||||
incomplete = response_object.get("incomplete_details")
|
||||
finish_reason = None
|
||||
if isinstance(incomplete, dict):
|
||||
reason = incomplete.get("reason")
|
||||
if reason is not None:
|
||||
finish_reason = str(reason)
|
||||
if finish_reason is None and response_object.get("finish_reason") is not None:
|
||||
finish_reason = str(response_object["finish_reason"])
|
||||
if status is None and finish_reason is None:
|
||||
raise ModelBudgetError("provider response is missing completion status")
|
||||
result = validate_response(
|
||||
usage["output_tokens"],
|
||||
max_output_tokens=max_output_tokens,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
if status in {"failed", "cancelled"}:
|
||||
raise ModelBudgetError(
|
||||
f"provider response is not reusable (status={status})"
|
||||
)
|
||||
if status == "incomplete" and not result["truncated"]:
|
||||
raise ModelBudgetError(
|
||||
"provider response is incomplete without a length checkpoint reason"
|
||||
)
|
||||
result.update(usage)
|
||||
result["status"] = str(status) if status is not None else None
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("packet", type=Path)
|
||||
parser.add_argument("--context-window", type=int, default=DEFAULT_CONTEXT_WINDOW)
|
||||
parser.add_argument("--max-output-tokens", type=int)
|
||||
parser.add_argument("--safety-margin", type=int, default=DEFAULT_SAFETY_MARGIN)
|
||||
parser.add_argument("--input-tokens", type=int, help="exact count from the provider tokenizer")
|
||||
parser.add_argument(
|
||||
"--overhead-tokens",
|
||||
type=int,
|
||||
required=True,
|
||||
help="system/tool/history tokens outside the context packet",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
with args.packet.open("r", encoding="utf-8") as handle:
|
||||
packet = json.load(handle)
|
||||
if not isinstance(packet, dict):
|
||||
raise ModelBudgetError("packet must be a JSON object")
|
||||
result = validate_request(
|
||||
packet,
|
||||
context_window=args.context_window,
|
||||
max_output_tokens=args.max_output_tokens,
|
||||
safety_margin=args.safety_margin,
|
||||
input_tokens=args.input_tokens,
|
||||
overhead_tokens=args.overhead_tokens,
|
||||
)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, ModelBudgetError) as exc:
|
||||
parser.error(str(exc))
|
||||
print(
|
||||
"model_budget=valid "
|
||||
f"input_tokens={result['input_token_limit']} "
|
||||
f"estimated_input={result['estimated_input_tokens']} "
|
||||
f"overhead={result['overhead_tokens']} "
|
||||
f"max_output={result['max_output_tokens']} "
|
||||
f"reserved_total={result['reserved_total_tokens']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
89
scripts/validate_task_pointer.py
Normal file
89
scripts/validate_task_pointer.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the small task pointer loaded at the start of a work session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAX_POINTER_BYTES = 8192
|
||||
MAX_POINTER_LINES = 180
|
||||
REQUIRED_HEADINGS = ("## Current", "## Read", "## Task", "## Next", "## Verify", "## Stop")
|
||||
FORBIDDEN_HEADINGS = (
|
||||
"## 6. 已验证证据",
|
||||
"## Evidence",
|
||||
"## History",
|
||||
"## 历史",
|
||||
"## Chat",
|
||||
)
|
||||
|
||||
|
||||
class PointerError(ValueError):
|
||||
"""Raised when the bootstrap pointer would expand the model context."""
|
||||
|
||||
|
||||
def validate_pointer(
|
||||
path: Path,
|
||||
*,
|
||||
max_bytes: int = MAX_POINTER_BYTES,
|
||||
max_lines: int = MAX_POINTER_LINES,
|
||||
lock_path: Path | None = None,
|
||||
) -> dict[str, int]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
size = len(text.encode("utf-8"))
|
||||
lines = len(text.splitlines())
|
||||
if size > max_bytes:
|
||||
raise PointerError(f"task pointer exceeds byte budget ({size}>{max_bytes})")
|
||||
if lines > max_lines:
|
||||
raise PointerError(f"task pointer exceeds line budget ({lines}>{max_lines})")
|
||||
missing = [heading for heading in REQUIRED_HEADINGS if heading not in text]
|
||||
if missing:
|
||||
raise PointerError(f"task pointer missing sections: {', '.join(missing)}")
|
||||
found = [heading for heading in FORBIDDEN_HEADINGS if re.search(rf"^\s*{re.escape(heading)}\s*$", text, re.MULTILINE)]
|
||||
if found:
|
||||
raise PointerError(f"task pointer contains history section: {found[0]}")
|
||||
if "coverage/" not in text:
|
||||
raise PointerError("task pointer must direct large evidence to paths")
|
||||
pointer_match = re.search(r"^context_id:\s*([^\s]+)\s*$", text, re.MULTILINE)
|
||||
if pointer_match is None:
|
||||
raise PointerError("task pointer is missing context_id")
|
||||
if lock_path is not None and lock_path.exists():
|
||||
try:
|
||||
lock = json.loads(lock_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise PointerError(f"cannot read context lock: {exc}") from exc
|
||||
lock_id = lock.get("context_id") if isinstance(lock, dict) else None
|
||||
if not lock_id:
|
||||
raise PointerError("context lock is missing context_id")
|
||||
if pointer_match.group(1) != str(lock_id):
|
||||
raise PointerError(
|
||||
f"task pointer context_id does not match lock ({pointer_match.group(1)}!={lock_id})"
|
||||
)
|
||||
return {"bytes": size, "lines": lines, "max_bytes": max_bytes, "max_lines": max_lines}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("path", nargs="?", type=Path, default=Path("nextTask.md"))
|
||||
parser.add_argument("--max-bytes", type=int, default=MAX_POINTER_BYTES)
|
||||
parser.add_argument("--max-lines", type=int, default=MAX_POINTER_LINES)
|
||||
parser.add_argument("--lock", type=Path, default=Path("context-lock.json"))
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = validate_pointer(
|
||||
args.path,
|
||||
max_bytes=args.max_bytes,
|
||||
max_lines=args.max_lines,
|
||||
lock_path=args.lock,
|
||||
)
|
||||
except (OSError, UnicodeError, PointerError) as exc:
|
||||
parser.error(str(exc))
|
||||
print(f"task_pointer=valid bytes={result['bytes']} lines={result['lines']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user