Add fail-closed JCZ profile import seam

This commit is contained in:
Codex 2026-10-19 19:21:16 +02:00
parent 131ca4b737
commit 47d83684ee
9 changed files with 241 additions and 15 deletions

View file

@ -16,10 +16,19 @@ The imported `DeviceProfile` also has no representation of the observed
`QPulseWidth` fields. The import is therefore explicitly classified as unsafe
for JCZ use rather than receiving guessed defaults.
The external addon now supplies the narrow profile seam missing from Rayforge
core. It recognizes only the explicit public synthetic profile identity,
requires `JCZFiber`, positive work-area dimensions and
`RequireFrameBeforeStart: true`, and associates the result only with
`PirateRayJCZNoDeviceDriver`. Frequency and `QPulseWidth` remain raw
observations with unverified serialized-unit conversion. Encoding remains
blocked because the physical profile, lens/correction match, project
serialization and machine-specific limits are not yet verified.
The checked result is
`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the
same SHA-256 digest,
`3c26f43be6b06e9b17a5decbe4ba7bb633eb1b8ac7bb0361449d7498a3b9279c`.
`94f0d17837471399334a82c84558985d35da0f1a8eee6a176eb02d57ae3aeb18`.
## Evidence boundary
@ -29,10 +38,9 @@ ranges, not a copy of a private export and not certified machine limits or
material settings. It deliberately omits private GUIDs, source paths and
unpublished connection serialization.
This spike characterizes the current importer; it does not implement a driver
mapping. A future `JCZFiber` mapping must target the separately tested
no-transport addon driver and require an exact compatible profile. It must not
silently map `JCZFiber` to a G-code or Ruida driver.
This spike characterizes the current importer and proves the smallest mapping
at the external-addon boundary. It does not change Rayforge core or silently
map `JCZFiber` to a G-code or Ruida driver.
## Reproduce
@ -44,10 +52,8 @@ python scripts/spike_rayforge_lightburn_device_import.py C:\path\to\rayforge
## Next seam
The smallest evidence-backed extension is to recognize `JCZFiber` as a
distinct, fail-closed profile kind, preserve its raw settings with explicit
units/provenance, and associate it only with the external no-device JCZ driver.
Cut-list recipes and project-layer `QPulseWidth` mapping remain gated until a
known-working `.lbrn2` fixture establishes project serialization.
The next extension is offline controller-list buffering and explicit safe
state ordering. Cut-list recipes and project-layer `QPulseWidth` mapping remain
gated until a known-working `.lbrn2` fixture establishes project serialization.
No USB enumeration, controller command, motion, framing or emission occurred.

View file

@ -282,6 +282,12 @@ Rayforge preserves the common name and work-area fields, but maps no
`JCZFiber` driver and drops the JCZ-specific safety and MOPA fields from its
profile model. The fixture and regression test remain transport-free.
Item 3 now has a minimal external-addon seam: it recognizes only the synthetic
`JCZFiber` fixture with explicit provenance and frame-before-start, binds it to
the no-device JCZ driver, preserves process values as raw observations with
unit uncertainty, and keeps encoding disabled until the remaining profile and
project evidence exists.
## Decision rules
- A simulator test precedes every equivalent hardware behavior.

View file

@ -26,7 +26,7 @@ def _commit_of(source: Path) -> str:
return result.stdout.strip()
def run(source: Path, fixture: Path) -> dict[str, object]:
def run(source: Path, fixture: Path, addon: Path) -> dict[str, object]:
commit = _commit_of(source)
if commit != RAYFORGE_COMMIT:
raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}")
@ -39,6 +39,11 @@ def run(source: Path, fixture: Path) -> dict[str, object]:
raw = parse_lbdev(fixture)
profile, summary = convert_to_profile(fixture)
document = json.loads(fixture.read_text(encoding="utf-8"))
sys.path.insert(0, str(addon))
from pirate_ray_jcz.profile_import import import_lightburn_jcz_profile
seam = import_lightburn_jcz_profile(document)
settings = raw["Settings"]
cut_entry = raw["CutList"][0]
@ -72,6 +77,7 @@ def run(source: Path, fixture: Path) -> dict[str, object]:
"work_area_mm": list(profile.machine_config.axis_extents),
"home_on_start": profile.machine_config.home_on_start,
},
"addon_profile_seam": seam,
"gap_matrix": {
"jczfiber_driver_mapped": profile.machine_config.driver is not None,
"require_frame_before_start_mapped": False,
@ -92,6 +98,11 @@ def main() -> None:
root = Path(__file__).parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path, help="pinned Rayforge checkout")
parser.add_argument(
"--addon",
type=Path,
default=root / "spikes" / "rayforge-jcz-no-device-addon",
)
parser.add_argument(
"--fixture",
type=Path,
@ -99,7 +110,9 @@ def main() -> None:
)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
result = run(args.source.resolve(), args.fixture.resolve())
result = run(
args.source.resolve(), args.fixture.resolve(), args.addon.resolve()
)
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.write_text(rendered, encoding="utf-8", newline="\n")

View file

@ -0,0 +1,110 @@
"""Fail-closed LightBurn JCZ profile seam with no hardware capability."""
from __future__ import annotations
from typing import Any
DRIVER_NAME = "PirateRayJCZNoDeviceDriver"
SYNTHETIC_PROFILE_ID = "synthetic-jpt-mopa-110-v1"
class JCZProfileImportError(ValueError):
"""Raised when a LightBurn profile cannot be recognized safely."""
def _is_positive_number(value: Any) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and value > 0
)
def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
"""Recognize the public synthetic profile without enabling encoding.
The returned process values remain serialized raw observations. This
function does not infer LightBurn project fields, units, machine limits,
lens correction, controller transport, or hardware compatibility.
"""
if document.get("FixtureProvenance") != (
"synthetic public data; not a machine export"
):
raise JCZProfileImportError("profile provenance is absent or unsupported")
if document.get("SyntheticProfileId") != SYNTHETIC_PROFILE_ID:
raise JCZProfileImportError("synthetic profile identity mismatch")
devices = document.get("DeviceList")
if not isinstance(devices, list) or len(devices) != 1:
raise JCZProfileImportError("exactly one LightBurn device is required")
device = devices[0]
if not isinstance(device, dict) or device.get("Type") != "JCZFiber":
raise JCZProfileImportError("LightBurn device is not JCZFiber")
width = device.get("Width")
height = device.get("Height")
if not all(_is_positive_number(value) for value in (width, height)):
raise JCZProfileImportError("positive work-area dimensions are required")
settings = device.get("Settings")
if not isinstance(settings, dict) or settings.get(
"RequireFrameBeforeStart"
) is not True:
raise JCZProfileImportError("RequireFrameBeforeStart must be explicitly true")
cut_list = device.get("CutList")
if not isinstance(cut_list, list) or not cut_list:
raise JCZProfileImportError("at least one observed cut-list entry is required")
preserved_entries = []
for entry in cut_list:
if not isinstance(entry, dict):
raise JCZProfileImportError("cut-list entry must be an object")
frequency = entry.get("frequency")
pulse_width = entry.get("QPulseWidth")
if not all(
_is_positive_number(value) for value in (frequency, pulse_width)
):
raise JCZProfileImportError(
"frequency and QPulseWidth must be positive raw values"
)
preserved_entries.append(
{
"name": entry.get("Name", ""),
"frequency": {
"raw_value": frequency,
"unit_status": "serialized-unit-conversion-unverified",
},
"q_pulse_width": {
"raw_value": pulse_width,
"ui_unit_contract": "nanoseconds",
"serialized_unit_conversion": "unverified",
},
}
)
return {
"profile_id": SYNTHETIC_PROFILE_ID,
"driver": DRIVER_NAME,
"transport": None,
"source": {
"format": "LightBurn synthetic .lbdev",
"provenance": document["FixtureProvenance"],
"backend": "JCZFiber",
},
"work_area_mm": [float(width), float(height)],
"safety": {"require_frame_before_start": True},
"process_metadata": {
"status": "preserved-raw-not-mapped",
"cut_list": preserved_entries,
},
"encoding_allowed": False,
"blockers": [
"synthetic profile is not physical machine identity",
"lens and correction profile are unverified",
"LightBurn project QPulseWidth serialization is unverified",
"machine-specific parameter limits are unverified",
],
}

View file

@ -17,6 +17,7 @@
],
"packaged_files": [
"pirate_ray_jcz/__init__.py",
"pirate_ray_jcz/profile_import.py",
"pirate_ray_jcz/worker.py",
"rayforge-addon.yaml"
],

View file

@ -1,4 +1,45 @@
{
"addon_profile_seam": {
"blockers": [
"synthetic profile is not physical machine identity",
"lens and correction profile are unverified",
"LightBurn project QPulseWidth serialization is unverified",
"machine-specific parameter limits are unverified"
],
"driver": "PirateRayJCZNoDeviceDriver",
"encoding_allowed": false,
"process_metadata": {
"cut_list": [
{
"frequency": {
"raw_value": 40000,
"unit_status": "serialized-unit-conversion-unverified"
},
"name": "Synthetic MOPA entry",
"q_pulse_width": {
"raw_value": 100,
"serialized_unit_conversion": "unverified",
"ui_unit_contract": "nanoseconds"
}
}
],
"status": "preserved-raw-not-mapped"
},
"profile_id": "synthetic-jpt-mopa-110-v1",
"safety": {
"require_frame_before_start": true
},
"source": {
"backend": "JCZFiber",
"format": "LightBurn synthetic .lbdev",
"provenance": "synthetic public data; not a machine export"
},
"transport": null,
"work_area_mm": [
110.0,
110.0
]
},
"conclusion": "name and work area survive, but JCZFiber has no driver mapping; RequireFrameBeforeStart, CutList, frequency and QPulseWidth are not represented in the imported Rayforge profile",
"fixture": "synthetic-jcz-device.lbdev",
"fixture_provenance": "synthetic public data",

View file

@ -1,5 +1,6 @@
{
"FixtureProvenance": "synthetic public data; not a machine export",
"SyntheticProfileId": "synthetic-jpt-mopa-110-v1",
"DeviceList": [
{
"DisplayName": "Synthetic JCZ MOPA",

View file

@ -27,10 +27,11 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
self.assertEqual(23, result["rayforge_api_version"])
def test_addon_contains_no_hardware_transport_import(self):
worker = (ADDON / "pirate_ray_jcz" / "worker.py").read_text(
encoding="utf-8"
source = "\n".join(
path.read_text(encoding="utf-8")
for path in sorted((ADDON / "pirate_ray_jcz").glob("*.py"))
)
compact = worker.lower()
compact = source.lower()
for forbidden in ("import usb", "import serial", "import socket"):
self.assertNotIn(forbidden, compact)
self.assertIn("discovery = none", compact)

View file

@ -1,11 +1,20 @@
import json
import unittest
from copy import deepcopy
from pathlib import Path
import sys
ROOT = Path(__file__).parents[1]
RESULT = ROOT / "tests" / "fixtures" / "rayforge-lightburn-device-import.json"
SOURCE = ROOT / "tests" / "fixtures" / "synthetic-jcz-device.lbdev"
ADDON = ROOT / "spikes" / "rayforge-jcz-no-device-addon"
sys.path.insert(0, str(ADDON))
from pirate_ray_jcz.profile_import import ( # noqa: E402
JCZProfileImportError,
import_lightburn_jcz_profile,
)
class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
@ -39,6 +48,44 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
self.assertFalse(gaps["q_pulse_width_mapped"])
self.assertFalse(gaps["safe_for_jcz_use"])
def test_addon_seam_preserves_raw_values_but_blocks_encoding(self):
result = json.loads(RESULT.read_text(encoding="utf-8"))
seam = result["addon_profile_seam"]
self.assertEqual("PirateRayJCZNoDeviceDriver", seam["driver"])
self.assertIsNone(seam["transport"])
self.assertFalse(seam["encoding_allowed"])
self.assertEqual("preserved-raw-not-mapped", seam["process_metadata"]["status"])
entry = seam["process_metadata"]["cut_list"][0]
self.assertEqual(40000, entry["frequency"]["raw_value"])
self.assertEqual(100, entry["q_pulse_width"]["raw_value"])
self.assertEqual("unverified", entry["q_pulse_width"]["serialized_unit_conversion"])
def test_addon_seam_rejects_wrong_backend_and_missing_frame_gate(self):
source = json.loads(SOURCE.read_text(encoding="utf-8"))
wrong_backend = deepcopy(source)
wrong_backend["DeviceList"][0]["Type"] = "Ruida"
with self.assertRaises(JCZProfileImportError):
import_lightburn_jcz_profile(wrong_backend)
no_frame_gate = deepcopy(source)
no_frame_gate["DeviceList"][0]["Settings"][
"RequireFrameBeforeStart"
] = False
with self.assertRaises(JCZProfileImportError):
import_lightburn_jcz_profile(no_frame_gate)
wrong_identity = deepcopy(source)
wrong_identity["SyntheticProfileId"] = "some-other-profile"
with self.assertRaises(JCZProfileImportError):
import_lightburn_jcz_profile(wrong_identity)
ambiguous_frequency = deepcopy(source)
ambiguous_frequency["DeviceList"][0]["CutList"][0][
"frequency"
] = True
with self.assertRaises(JCZProfileImportError):
import_lightburn_jcz_profile(ambiguous_frequency)
if __name__ == "__main__":
unittest.main()