Add transport-free JCZ list buffering

This commit is contained in:
Codex 2026-10-19 19:25:40 +02:00
parent 47d83684ee
commit bf5dd439ce
9 changed files with 220 additions and 21 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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",
],

View file

@ -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,
}

View file

@ -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,
},
)

View file

@ -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"
]

View file

@ -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

View file

@ -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"]}
)