From bf5dd439ceebdf4d29cfc6859e13d9e1042351e8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 19:25:40 +0200 Subject: [PATCH] Add transport-free JCZ list buffering --- docs/research/rayforge-jcz-negative-cases.md | 6 +- docs/research/rayforge-jcz-packet-spike.md | 17 ++-- docs/roadmap.md | 5 ++ scripts/spike_rayforge_jcz_equivalence.py | 18 +++- scripts/spike_rayforge_jcz_negative_cases.py | 30 ++++++- .../pirate_ray_jcz/worker.py | 85 ++++++++++++++++++- tests/fixtures/rayforge-jcz-equivalence.json | 34 ++++++-- .../fixtures/rayforge-jcz-negative-cases.json | 10 +++ tests/test_rayforge_addon_spike.py | 36 +++++++- 9 files changed, 220 insertions(+), 21 deletions(-) diff --git a/docs/research/rayforge-jcz-negative-cases.md b/docs/research/rayforge-jcz-negative-cases.md index c886cd2..e266f18 100644 --- a/docs/research/rayforge-jcz-negative-cases.md +++ b/docs/research/rayforge-jcz-negative-cases.md @@ -6,7 +6,7 @@ ## Result -The transport-free JCZ semantic encoder rejected all six synthetic negative +The transport-free JCZ semantic encoder rejected all eight synthetic negative cases before producing output: | Case | Rejection | @@ -17,9 +17,11 @@ cases before producing output: | Zero frequency | Frequency was not positive | | Zero pulse width | Pulse width was not positive | | Unsupported command | A synthetic `CUSTOM` Ops command had no explicit mapping | +| Missing required state | A job omitted pulse width, rapid speed, frequency and mark speed | +| State after motion | A frequency change appeared after the first move | Two runs produced identical results with SHA-256 -`0adab8fb1060fde1e172c674da3d0d4ae5936b01490891b898f86cc4f7ca3c3f`. +`1372037e089185365bb7600762d49afa938063ef53f888604f87c68cad829b83`. The checked result is `tests/fixtures/rayforge-jcz-negative-cases.json`. ## Boundary diff --git a/docs/research/rayforge-jcz-packet-spike.md b/docs/research/rayforge-jcz-packet-spike.md index 006388c..98acfda 100644 --- a/docs/research/rayforge-jcz-packet-spike.md +++ b/docs/research/rayforge-jcz-packet-spike.md @@ -13,8 +13,16 @@ The opcode and value fields agree with the pinned BalorMK mock trace for power, mark and jump speed, frequency, MOPA pulse width, the square coordinates and distances, one millisecond dwell, and end-of-list. +The encoder now places those records into the pinned BalorMK list shape: +exactly 256 records and 3072 bytes per buffer, with unused final-buffer slots +filled by `listEndOfList`. Before packing, it requires one complete state block +ahead of all motion and emits the evidence-backed order `listReadyMark`, pulse +width, jump speed, frequency, power and mark speed, followed by geometry and +end-of-list. State changes after motion and duplicate or missing state fields +fail closed. + Two independent runs produced identical output with SHA-256 -`b3d34600daac7060c3d3b28edf1627813d3bc518b1a64be11367f8e2106a51c6`. +`6217c9f8fee0e612872de6c20f5b033a4df885a173a442baa54629465fc5db5b`. The checked records are in `tests/fixtures/rayforge-jcz-equivalence.json` and are asserted byte-for-byte by the standard-library test suite. @@ -29,10 +37,9 @@ operations continue to fail closed. ## Remaining gaps -- Records are emitted in Rayforge operation order. A production adapter must - define controller-safe state ordering from evidence. -- List framing, fixed-size buffers, padding, upload, status, cancellation and - recovery are absent. +- Upload, status, cancellation and recovery are absent. +- Port-state commands are deliberately omitted because their machine-specific + bit semantics can arm output and have not yet passed the hardware gate. - The 110 mm coordinate mapping is synthetic and has no lens correction. - Machine-specific bounds and the pulse-width unit contract still require authoritative evidence; they are not guessed here. diff --git a/docs/roadmap.md b/docs/roadmap.md index 175be16..64ee002 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -288,6 +288,11 @@ 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. +Item 4 now covers transport-free fixed-size list buffering and an explicit +state-before-motion order derived from pinned BalorMK. Port commands, upload, +execute, status and cancellation remain outside the encoder and cannot be +reached by this spike. + ## Decision rules - A simulator test precedes every equivalent hardware behavior. diff --git a/scripts/spike_rayforge_jcz_equivalence.py b/scripts/spike_rayforge_jcz_equivalence.py index 580c9fd..ba89248 100644 --- a/scripts/spike_rayforge_jcz_equivalence.py +++ b/scripts/spike_rayforge_jcz_equivalence.py @@ -110,10 +110,24 @@ def run(source: Path, addon: Path, balor_fixture: Path) -> dict[str, object]: "rayforge_ops_count": ops.len(), "semantic_trace": semantic, "command_records_hex": encoded.driver_data["command_records_hex"], + "controller_list_commands": [ + item["command"] + for item in encoded.driver_data["controller_list_trace"] + ], + "list_buffer_size": encoded.driver_data["list_buffer_size"], + "list_record_capacity": encoded.driver_data["list_record_capacity"], + "list_buffer_count": len(encoded.driver_data["list_buffers"]), + "list_buffer_sha256": encoded.driver_data["list_buffer_sha256"], + "list_meaningful_record_count": encoded.driver_data[ + "list_meaningful_record_count" + ], + "list_padding_record_count": encoded.driver_data[ + "list_padding_record_count" + ], "balormk_equivalence": checks, "limitations": [ - "12-byte command records only; no list buffering or transport", - "record ordering follows the Rayforge Ops stream", + "3072-byte list buffers only; no upload or transport", + "safe state ordering follows pinned BalorMK program/set_settings order while omitting port commands", "synthetic field mapping; no lens correction", "pulse width treated as nanoseconds from LaserStep UI contract", ], diff --git a/scripts/spike_rayforge_jcz_negative_cases.py b/scripts/spike_rayforge_jcz_negative_cases.py index bed7214..08449e6 100644 --- a/scripts/spike_rayforge_jcz_negative_cases.py +++ b/scripts/spike_rayforge_jcz_negative_cases.py @@ -95,12 +95,40 @@ def run(source: Path, addon: Path) -> dict[str, object]: "unsupported_ops_command", Encoder(), unsupported, "unsupported" ) + incomplete_state = Ops() + incomplete_state.job_start() + incomplete_state.set_power(0.5) + incomplete_state.job_end() + expect_rejection( + "missing_required_state", + Encoder(), + incomplete_state, + "missing required JCZ state", + ) + + late_state = Ops() + late_state.job_start() + late_state.set_power(0.5) + late_state.set_feed_rate(15_000) + late_state.set_rapid_rate(120_000) + late_state.set_frequency(40_000) + late_state.set_pulse_width(200) + late_state.move_to(55, 55, 0) + late_state.set_frequency(50_000) + late_state.job_end() + expect_rejection( + "state_change_after_motion", + Encoder(), + late_state, + "state change after motion", + ) + return { "rayforge_commit": commit, "profile": Encoder.SYNTHETIC_PROFILE_ID, "transport": None, "cases": cases, - "all_failed_closed": len(cases) == 6, + "all_failed_closed": len(cases) == 8, } diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py index 05e315a..1b1cfbe 100644 --- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py @@ -4,6 +4,7 @@ This is deliberately not a controller implementation. It has no discovery specification, transport, USB import, or machine I/O. """ +import hashlib import json import math import struct @@ -35,8 +36,20 @@ class OfflineJCZEncoder(OpsEncoder): "power": 0x8012, "mark_speed": 0x800C, "pulse_width": 0x8026, + "ready": 0x8051, } + LIST_RECORD_SIZE = 12 + LIST_BUFFER_SIZE = 0xC00 + LIST_RECORD_CAPACITY = LIST_BUFFER_SIZE // LIST_RECORD_SIZE + STATE_ORDER = ( + "pulse_width", + "jump_speed", + "frequency", + "power", + "mark_speed", + ) + def __init__( self, profile_id: str = SYNTHETIC_PROFILE_ID, @@ -66,7 +79,9 @@ class OfflineJCZEncoder(OpsEncoder): command = item["command"] if command == "job_start": continue - if command == "job_end": + if command == "ready": + records.append(self._record(self.OPCODES[command])) + elif command == "job_end": records.append(self._record(self.OPCODES["end_of_list"])) elif command == "pulse_width": records.append( @@ -100,6 +115,58 @@ class OfflineJCZEncoder(OpsEncoder): raise ValueError(f"cannot packetize semantic command: {command}") return records + def _controller_list_trace( + self, trace: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + if not trace or trace[0]["command"] != "job_start": + raise ValueError("JCZ list must start with job_start") + if trace[-1]["command"] != "job_end": + raise ValueError("JCZ list must end with job_end") + + states: dict[str, dict[str, Any]] = {} + body: list[dict[str, Any]] = [] + motion_seen = False + for item in trace[1:-1]: + command = item["command"] + if command in self.STATE_ORDER: + if motion_seen: + raise ValueError( + "JCZ state change after motion is not supported" + ) + if command in states: + raise ValueError(f"duplicate JCZ state command: {command}") + states[command] = item + else: + motion_seen = True + body.append(item) + + missing = [name for name in self.STATE_ORDER if name not in states] + if missing: + raise ValueError(f"missing required JCZ state: {', '.join(missing)}") + return ( + [{"command": "ready"}] + + [states[name] for name in self.STATE_ORDER] + + body + + [trace[-1]] + ) + + def _pack_list_buffers(self, records: list[bytes]) -> list[bytes]: + if not records: + return [] + end_record = self._record(self.OPCODES["end_of_list"]) + buffers = [] + for offset in range(0, len(records), self.LIST_RECORD_CAPACITY): + chunk = records[offset : offset + self.LIST_RECORD_CAPACITY] + if len(chunk) < self.LIST_RECORD_CAPACITY: + chunk = chunk + [end_record] * ( + self.LIST_RECORD_CAPACITY - len(chunk) + ) + packet = b"".join(chunk) + if len(packet) != self.LIST_BUFFER_SIZE: + raise AssertionError("JCZ list buffer is not exactly 0xC00 bytes") + buffers.append(packet) + return buffers + @classmethod def _coordinate(cls, value_mm: float) -> int: if not 0.0 <= value_mm <= cls.FIELD_MM: @@ -186,7 +253,9 @@ class OfflineJCZEncoder(OpsEncoder): raise ValueError(f"unsupported Ops command: {kind}") op_count = len(trace) - records = self._encode_records(trace) + controller_trace = [] if ops is None else self._controller_list_trace(trace) + records = self._encode_records(controller_trace) + list_buffers = self._pack_list_buffers(records) payload = b"".join(records) if ops is None: payload = json.dumps( @@ -202,6 +271,18 @@ class OfflineJCZEncoder(OpsEncoder): "binary": payload, "command_records_hex": [record.hex() for record in records], "semantic_trace": trace, + "controller_list_trace": controller_trace, + "list_buffers": list_buffers, + "list_buffer_sha256": [ + hashlib.sha256(packet).hexdigest() + for packet in list_buffers + ], + "list_record_capacity": self.LIST_RECORD_CAPACITY, + "list_buffer_size": self.LIST_BUFFER_SIZE, + "list_meaningful_record_count": len(records), + "list_padding_record_count": ( + len(list_buffers) * self.LIST_RECORD_CAPACITY - len(records) + ), "transport": None, }, ) diff --git a/tests/fixtures/rayforge-jcz-equivalence.json b/tests/fixtures/rayforge-jcz-equivalence.json index 43133a3..370d65d 100644 --- a/tests/fixtures/rayforge-jcz-equivalence.json +++ b/tests/fixtures/rayforge-jcz-equivalence.json @@ -20,11 +20,12 @@ {"command": "job_end"} ], "command_records_hex": [ - "128000080000000000000000", - "0c8094000000000000000000", + "518000000000000000000000", + "2680c8000000000000000000", "0680a7040000000000000000", "1b80f4010000000000000000", - "2680c8000000000000000000", + "128000080000000000000000", + "0c8094000000000000000000", "0180007000700000a0160000", "058000710070000000010000", "058000710071000000010000", @@ -33,6 +34,29 @@ "038064000000000000000000", "028000000000000000000000" ], + "controller_list_commands": [ + "ready", + "pulse_width", + "jump_speed", + "frequency", + "power", + "mark_speed", + "jump", + "mark", + "mark", + "mark", + "mark", + "laser_on_point", + "job_end" + ], + "list_buffer_size": 3072, + "list_record_capacity": 256, + "list_buffer_count": 1, + "list_buffer_sha256": [ + "df9894c746df451054d7237e1e020153e2a65cf2ceaea71dde7d9a40fabaf3e1" + ], + "list_meaningful_record_count": 13, + "list_padding_record_count": 243, "balormk_equivalence": { "power_50_percent": true, "speed_250_mm_s": true, @@ -42,8 +66,8 @@ "dwell_1_ms": true }, "limitations": [ - "12-byte command records only; no list buffering or transport", - "record ordering follows the Rayforge Ops stream", + "3072-byte list buffers only; no upload or transport", + "safe state ordering follows pinned BalorMK program/set_settings order while omitting port commands", "synthetic field mapping; no lens correction", "pulse width treated as nanoseconds from LaserStep UI contract" ] diff --git a/tests/fixtures/rayforge-jcz-negative-cases.json b/tests/fixtures/rayforge-jcz-negative-cases.json index 7c948be..86d8a51 100644 --- a/tests/fixtures/rayforge-jcz-negative-cases.json +++ b/tests/fixtures/rayforge-jcz-negative-cases.json @@ -32,6 +32,16 @@ "case": "unsupported_ops_command", "result": "rejected", "reason": "unsupported Ops command: CUSTOM" + }, + { + "case": "missing_required_state", + "result": "rejected", + "reason": "missing required JCZ state: pulse_width, jump_speed, frequency, mark_speed" + }, + { + "case": "state_change_after_motion", + "result": "rejected", + "reason": "JCZ state change after motion is not supported" } ], "all_failed_closed": true diff --git a/tests/test_rayforge_addon_spike.py b/tests/test_rayforge_addon_spike.py index c7d2d30..9529275 100644 --- a/tests/test_rayforge_addon_spike.py +++ b/tests/test_rayforge_addon_spike.py @@ -62,11 +62,12 @@ class RayforgeAddonSpikeTests(unittest.TestCase): self.assertTrue(all(len(record) == 24 for record in records)) self.assertEqual( [ - "128000080000000000000000", - "0c8094000000000000000000", + "518000000000000000000000", + "2680c8000000000000000000", "0680a7040000000000000000", "1b80f4010000000000000000", - "2680c8000000000000000000", + "128000080000000000000000", + "0c8094000000000000000000", "0180007000700000a0160000", "058000710070000000010000", "058000710071000000010000", @@ -78,11 +79,38 @@ class RayforgeAddonSpikeTests(unittest.TestCase): records, ) + def test_controller_list_is_fixed_size_and_safely_ordered(self): + result = json.loads(EQUIVALENCE_FIXTURE.read_text(encoding="utf-8")) + self.assertEqual(3072, result["list_buffer_size"]) + self.assertEqual(256, result["list_record_capacity"]) + self.assertEqual(1, result["list_buffer_count"]) + self.assertEqual(1, len(result["list_buffer_sha256"])) + self.assertEqual(13, result["list_meaningful_record_count"]) + self.assertEqual(243, result["list_padding_record_count"]) + self.assertEqual( + [ + "ready", + "pulse_width", + "jump_speed", + "frequency", + "power", + "mark_speed", + "jump", + "mark", + "mark", + "mark", + "mark", + "laser_on_point", + "job_end", + ], + result["controller_list_commands"], + ) + def test_negative_cases_all_fail_closed_without_transport(self): result = json.loads(NEGATIVE_FIXTURE.read_text(encoding="utf-8")) self.assertTrue(result["all_failed_closed"]) self.assertIsNone(result["transport"]) - self.assertEqual(6, len(result["cases"])) + self.assertEqual(8, len(result["cases"])) self.assertEqual( {"rejected"}, {case["result"] for case in result["cases"]} )