From 47d83684ee5fc59f89bfd97ffc56448364ac046a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 19:21:16 +0200 Subject: [PATCH] Add fail-closed JCZ profile import seam --- .../rayforge-lightburn-device-import-spike.md | 26 +++-- docs/roadmap.md | 6 + .../spike_rayforge_lightburn_device_import.py | 17 ++- .../pirate_ray_jcz/profile_import.py | 110 ++++++++++++++++++ tests/fixtures/rayforge-addon-spike.json | 1 + .../rayforge-lightburn-device-import.json | 41 +++++++ tests/fixtures/synthetic-jcz-device.lbdev | 1 + tests/test_rayforge_addon_spike.py | 7 +- ..._rayforge_lightburn_device_import_spike.py | 47 ++++++++ 9 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py diff --git a/docs/research/rayforge-lightburn-device-import-spike.md b/docs/research/rayforge-lightburn-device-import-spike.md index b258401..f0b27f3 100644 --- a/docs/research/rayforge-lightburn-device-import-spike.md +++ b/docs/research/rayforge-lightburn-device-import-spike.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index c1b94af..175be16 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. diff --git a/scripts/spike_rayforge_lightburn_device_import.py b/scripts/spike_rayforge_lightburn_device_import.py index 5348606..43501d6 100644 --- a/scripts/spike_rayforge_lightburn_device_import.py +++ b/scripts/spike_rayforge_lightburn_device_import.py @@ -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") diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py new file mode 100644 index 0000000..29b994b --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py @@ -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", + ], + } diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json index a7bda93..f9af7ca 100644 --- a/tests/fixtures/rayforge-addon-spike.json +++ b/tests/fixtures/rayforge-addon-spike.json @@ -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" ], diff --git a/tests/fixtures/rayforge-lightburn-device-import.json b/tests/fixtures/rayforge-lightburn-device-import.json index e78087f..6e40f59 100644 --- a/tests/fixtures/rayforge-lightburn-device-import.json +++ b/tests/fixtures/rayforge-lightburn-device-import.json @@ -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", diff --git a/tests/fixtures/synthetic-jcz-device.lbdev b/tests/fixtures/synthetic-jcz-device.lbdev index 8439dd3..8b60228 100644 --- a/tests/fixtures/synthetic-jcz-device.lbdev +++ b/tests/fixtures/synthetic-jcz-device.lbdev @@ -1,5 +1,6 @@ { "FixtureProvenance": "synthetic public data; not a machine export", + "SyntheticProfileId": "synthetic-jpt-mopa-110-v1", "DeviceList": [ { "DisplayName": "Synthetic JCZ MOPA", diff --git a/tests/test_rayforge_addon_spike.py b/tests/test_rayforge_addon_spike.py index 7f9ab46..c7d2d30 100644 --- a/tests/test_rayforge_addon_spike.py +++ b/tests/test_rayforge_addon_spike.py @@ -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) diff --git a/tests/test_rayforge_lightburn_device_import_spike.py b/tests/test_rayforge_lightburn_device_import_spike.py index b1497c7..30b2a3e 100644 --- a/tests/test_rayforge_lightburn_device_import_spike.py +++ b/tests/test_rayforge_lightburn_device_import_spike.py @@ -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()