Stage imported MOPA settings in disabled Rayforge workflows

This commit is contained in:
Codex 2026-10-24 12:55:46 +02:00
parent 583a0d573a
commit be3fb933c1
9 changed files with 327 additions and 20 deletions

View file

@ -68,20 +68,23 @@ application until both gates below have real evidence:
reviewed build runner, verified without execution, and pass the manual reviewed build runner, verified without execution, and pass the manual
no-device desktop checklist. no-device desktop checklist.
2. The known-working private JPT `.lbrn2` serialization evidence gate is met. 2. The known-working private JPT `.lbrn2` serialization evidence gate is met.
Import mapping still requires explicit unit conversion and fail-closed Offline mapping and disabled-output workflow staging now have synthetic
handling of absent pulse width, with synthetic offline tests. tests. Promotion still requires an independently established unit contract,
explicit resolution of absent pulse width and machine-context validation.
Raw projects, screenshots containing identifiers, machine data and build Raw projects, screenshots containing identifiers, machine data and build
binaries stay out of the public repository. binaries stay out of the public repository.
## Suggested first task ## Suggested first task
An offline project MOPA seam and synthetic tests now exist; see An offline project MOPA seam, disabled-output Rayforge workflow bridge and
synthetic tests now exist; see
`docs/research/lightburn-project-mopa-import.md`. It preserves both process `docs/research/lightburn-project-mopa-import.md`. It preserves both process
element types and requires an explicit unit contract for normalization. It is element types and requires an explicit unit contract for normalization. The
not wired into Rayforge's workflow and never authorizes encoding. The next bridge binds source layer IDs, uses the host MOPA setters, serializes review
import task is host integration with layer identity and independently metadata and disables every staged step. It never authorizes encoding. The
established units; absent pulse width and image equivalence remain blockers. next import task is UI/preflight presentation plus independently established
units; absent pulse width and image equivalence remain blockers.
If a Windows artifact is available, keep the machine disconnected and run, in If a Windows artifact is available, keep the machine disconnected and run, in
order: order:

View file

@ -20,13 +20,22 @@ settings remain blocked pending image-processing equivalence, including
Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
`transport=None`, even with complete normalized values. This is a callable `transport=None`, even with complete normalized values. This is a callable
offline metadata seam; it is not yet wired to Rayforge's project importer or offline metadata seam. The companion workflow bridge matches the normalized
workflow. Host integration must bind settings to layer indices, establish LightBurn layer index to Rayforge's source-segment layer ID and stages complete
serialized units from independent evidence, resolve absent pulse width and vector MOPA values through the host's setters. Every staged step is disabled
validate exact machine, lens, correction and parameter limits. No existing through `Step.set_visible(False)`, and its raw values, normalized values,
golden lifecycle or upstream importer run was repeated; the package inventory review status and blockers are saved in the serializable `Step.extra` data.
was extended to include the new module. Image settings and incomplete settings remain disabled without applying MOPA
values. Multi-step staging is preflighted and rolled back on setter failure.
The bridge deliberately has no function that enables output or removes a
blocker. Host UI and preflight integration must still establish serialized
units from independent evidence, resolve absent pulse width and validate exact
machine, lens, correction and parameter limits before a separate reviewed
promotion path can exist. Rayforge's relevant Step and LaserStep files are now
included in the pinned upstream contract manifest.
Validation covers unit conversion, absent fields, case sensitivity, duplicate Validation covers unit conversion, absent fields, case sensitivity, duplicate
values, invalid numbers, image modes, mixed layers and malformed XML. Existing values, invalid numbers, image modes, mixed layers, exact layer binding,
packaging, no-device and public evidence checks remain part of the test suite. transactional rollback and malformed XML. Existing packaging, no-device and
public evidence checks remain part of the test suite.

View file

@ -59,6 +59,7 @@ its manifest and sidecar; installer-only publication is not accepted.
| `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration | | `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration |
| `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades | | `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades |
| `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists | | `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists |
| `Step.extra`, visibility serialization, MOPA setters | Imported host workflow interfaces | Used by the disabled-output import bridge; exact source files and symbols are pinned before host integration |
| Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism | | Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism |
| BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API | | BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API |

View file

@ -29,6 +29,18 @@ def _number(element: ET.Element, field: str) -> str | None:
return raw return raw
def _layer_id(element: ET.Element) -> str | None:
children = element.findall("index")
if not children:
return None
if len(children) != 1 or set(children[0].attrib) != {"Value"}:
raise ProjectImportError("ambiguous layer index")
raw = children[0].get("Value", "")
if not raw.isdecimal():
raise ProjectImportError("invalid layer index")
return str(int(raw))
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict: def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
"""Preserve observed XML fields and optionally normalize declared units. """Preserve observed XML fields and optionally normalize declared units.
@ -51,6 +63,7 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1": if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
raise ProjectImportError("unsupported project format") raise ProjectImportError("unsupported project format")
layers = [] layers = []
layer_ids: set[str] = set()
for setting in root: for setting in root:
if setting.tag not in ("CutSetting", "CutSetting_Img"): if setting.tag not in ("CutSetting", "CutSetting_Img"):
continue continue
@ -58,6 +71,11 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan") expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan")
if kind not in expected: if kind not in expected:
raise ProjectImportError("unsupported layer type") raise ProjectImportError("unsupported layer type")
layer_id = _layer_id(setting)
if layer_id is not None and layer_id in layer_ids:
raise ProjectImportError("duplicate layer index")
if layer_id is not None:
layer_ids.add(layer_id)
raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")} raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")}
blockers = [] blockers = []
if unit_contract is None: if unit_contract is None:
@ -78,7 +96,8 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
"frequency_khz": float(Decimal(raw["frequency"]) / 1000), "frequency_khz": float(Decimal(raw["frequency"]) / 1000),
"pulse_width_ns": float(Decimal(raw["QPulseWidth"])), "pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
} }
layers.append({"element": setting.tag, "type": kind, "raw": raw, layers.append({"element": setting.tag, "type": kind,
"source_layer_id": layer_id, "raw": raw,
"image_mode": image_mode, "image_mode": image_mode,
"normalized": normalized, "blockers": blockers}) "normalized": normalized, "blockers": blockers})
if not layers: if not layers:

View file

@ -0,0 +1,138 @@
"""Stage MOPA metadata on Rayforge workflow steps with output disabled."""
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from typing import Any
METADATA_KEY = "pirateray_import"
class WorkflowImportError(ValueError):
"""The imported layer cannot be bound safely to a Rayforge step."""
def _host_integer(value: Any, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise WorkflowImportError(f"{label} must be numeric")
result = int(value)
if result <= 0 or result != value:
raise WorkflowImportError(f"{label} must be a positive integer")
return result
def stage_mopa_workflow_steps(
imported: Mapping[str, Any],
steps_by_layer_id: Mapping[str, Any],
) -> dict[str, Any]:
"""Bind complete imported MOPA settings while keeping every step disabled.
The mapping is constructed by the host integration from the source segment's
``layer_id`` to the single workflow step created by Rayforge's assembler.
This seam never enables a step and never removes an existing blocker.
"""
if imported.get("encoding_allowed") is not False:
raise WorkflowImportError("import must explicitly prohibit encoding")
if imported.get("hardware_use_allowed") is not False:
raise WorkflowImportError("import must explicitly prohibit hardware use")
layers = imported.get("layers")
if not isinstance(layers, list):
raise WorkflowImportError("imported layers are missing")
by_id: dict[str, Mapping[str, Any]] = {}
for layer in layers:
if not isinstance(layer, Mapping):
raise WorkflowImportError("imported layer must be an object")
layer_id = layer.get("source_layer_id")
if layer_id is None:
continue
if not isinstance(layer_id, str) or layer_id in by_id:
raise WorkflowImportError("imported layer identity is ambiguous")
by_id[layer_id] = layer
if not steps_by_layer_id:
raise WorkflowImportError("no Rayforge workflow steps were supplied")
global_blockers = imported.get("blockers")
if not isinstance(global_blockers, list) or not all(
isinstance(item, str) and item for item in global_blockers
):
raise WorkflowImportError("global blockers are missing")
plans = []
step_identities: set[int] = set()
for layer_id, step in steps_by_layer_id.items():
if not isinstance(layer_id, str):
raise WorkflowImportError("Rayforge layer ID must be text")
if id(step) in step_identities:
raise WorkflowImportError("one Rayforge step cannot represent multiple layers")
step_identities.add(id(step))
layer = by_id.get(layer_id)
if layer is None:
raise WorkflowImportError(f"no imported settings for layer {layer_id}")
if not isinstance(getattr(step, "extra", None), dict):
raise WorkflowImportError("Rayforge step has no serializable extra data")
if METADATA_KEY in step.extra:
raise WorkflowImportError("Rayforge step already has PirateRay import data")
if not hasattr(step, "visible") or not callable(
getattr(step, "set_visible", None)
):
raise WorkflowImportError("Rayforge step has no output visibility gate")
normalized = layer.get("normalized")
layer_blockers = layer.get("blockers")
if not isinstance(layer_blockers, list):
raise WorkflowImportError("layer blockers are missing")
apply_values = normalized is not None and not layer_blockers
frequency = pulse_width = None
if apply_values:
if not callable(getattr(step, "set_frequency", None)) or not callable(
getattr(step, "set_pulse_width", None)
):
raise WorkflowImportError("Rayforge step has no MOPA setters")
frequency = _host_integer(normalized.get("frequency_hz"), "frequency")
pulse_width = _host_integer(
normalized.get("pulse_width_ns"), "pulse width"
)
plans.append((layer_id, step, layer, frequency, pulse_width))
snapshots = [
(step, step.visible, deepcopy(step.extra), getattr(step, "frequency", None),
getattr(step, "pulse_width", None))
for _layer_id, step, _layer, _frequency, _pulse_width in plans
]
staged = []
try:
for layer_id, step, layer, frequency, pulse_width in plans:
step.set_visible(False)
blockers = list(global_blockers) + list(layer["blockers"])
if frequency is not None and pulse_width is not None:
step.set_frequency(frequency)
step.set_pulse_width(pulse_width)
step.extra[METADATA_KEY] = {
"status": "staged-review-required",
"source_layer_id": layer_id,
"raw": deepcopy(layer.get("raw")),
"normalized": deepcopy(layer.get("normalized")),
"blockers": blockers,
"encoding_allowed": False,
"hardware_use_allowed": False,
}
staged.append({
"source_layer_id": layer_id,
"parameters_applied": frequency is not None,
"output_enabled": False,
"blockers": blockers,
})
except Exception:
for step, visible, extra, frequency, pulse_width in snapshots:
step.set_visible(visible)
step.extra.clear()
step.extra.update(extra)
if frequency is not None:
step.frequency = frequency
if pulse_width is not None:
step.pulse_width = pulse_width
raise
return {"steps": staged, "encoding_allowed": False,
"hardware_use_allowed": False, "transport": None}

View file

@ -23,6 +23,7 @@
"pirate_ray_jcz/rayforge_compat.py", "pirate_ray_jcz/rayforge_compat.py",
"pirate_ray_jcz/recipe_metadata.py", "pirate_ray_jcz/recipe_metadata.py",
"pirate_ray_jcz/worker.py", "pirate_ray_jcz/worker.py",
"pirate_ray_jcz/workflow_import.py",
"rayforge-addon.yaml" "rayforge-addon.yaml"
], ],
"registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup" "registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup"

View file

@ -8,9 +8,9 @@ from pirate_ray_jcz.project_import import (
) )
def project(fields, tag="CutSetting", kind="Scan"): def project(fields, tag="CutSetting", kind="Scan", layer_id="0"):
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">' return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
f'{fields}</{tag}></LightBurnProject>').encode() f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>' FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
@ -25,6 +25,7 @@ class ProjectMopaTests(unittest.TestCase):
self.assertFalse(result["encoding_allowed"]) self.assertFalse(result["encoding_allowed"])
self.assertFalse(result["hardware_use_allowed"]) self.assertFalse(result["hardware_use_allowed"])
self.assertIsNone(result["transport"]) self.assertIsNone(result["transport"])
self.assertEqual(result["layers"][0]["source_layer_id"], "0")
def test_units_are_never_inferred_from_magnitude(self): def test_units_are_never_inferred_from_magnitude(self):
result = import_project_mopa(project(FIELDS)) result = import_project_mopa(project(FIELDS))
@ -69,10 +70,20 @@ class ProjectMopaTests(unittest.TestCase):
with self.assertRaises(ProjectImportError): with self.assertRaises(ProjectImportError):
import_project_mopa(data) import_project_mopa(data)
def test_layer_indices_are_normalized_and_must_be_unique(self):
layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0]
self.assertEqual(layer["source_layer_id"], "7")
duplicate = ('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode()
with self.assertRaisesRegex(ProjectImportError, "duplicate layer index"):
import_project_mopa(duplicate)
def test_multiple_layers_preserve_order_and_missing_pulse(self): def test_multiple_layers_preserve_order_and_missing_pulse(self):
data = ('<LightBurnProject FormatVersion="1">' data = ('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut">'+FIELDS+'</CutSetting>' '<CutSetting type="Cut"><index Value="0"/>'+FIELDS+'</CutSetting>'
'<CutSetting_Img type="Image"><frequency Value="125000"/>' '<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>'
'</CutSetting_Img></LightBurnProject>').encode() '</CutSetting_Img></LightBurnProject>').encode()
layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"] layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
self.assertEqual([x["type"] for x in layers], ["Cut", "Image"]) self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])

View file

@ -0,0 +1,113 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1] / "spikes/rayforge-jcz-no-device-addon"))
from pirate_ray_jcz.project_import import UNIT_CONTRACT, import_project_mopa
from pirate_ray_jcz.workflow_import import (
METADATA_KEY, WorkflowImportError, stage_mopa_workflow_steps,
)
class Step:
def __init__(self, fail=False):
self.visible = True
self.frequency = 1
self.pulse_width = 2
self.extra = {"preserved": True}
self.fail = fail
def set_frequency(self, value):
self.frequency = value
def set_visible(self, value):
self.visible = value
def set_pulse_width(self, value):
if self.fail:
raise ValueError("synthetic setter failure")
self.pulse_width = value
def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"):
data = (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
return import_project_mopa(data, unit_contract=UNIT_CONTRACT)
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
class WorkflowImportTests(unittest.TestCase):
def test_complete_vector_settings_are_staged_but_output_is_disabled(self):
step = Step()
result = stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
self.assertEqual((step.frequency, step.pulse_width), (125000, 25))
self.assertFalse(step.visible)
self.assertFalse(result["encoding_allowed"])
self.assertIsNone(result["transport"])
gate = step.extra[METADATA_KEY]
self.assertEqual(gate["status"], "staged-review-required")
self.assertFalse(gate["encoding_allowed"])
self.assertTrue(gate["blockers"])
def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
for result in (
imported('<frequency Value="125000"/>'),
imported(FIELDS + '<ditherMode Value="3dslice"/>', kind="Image", tag="CutSetting_Img"),
):
with self.subTest(result=result):
step = Step()
staged = stage_mopa_workflow_steps(result, {"3": step})
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
self.assertFalse(step.visible)
self.assertFalse(staged["steps"][0]["parameters_applied"])
def test_layer_identity_must_match(self):
with self.assertRaisesRegex(WorkflowImportError, "no imported settings"):
stage_mopa_workflow_steps(imported(FIELDS), {"4": Step()})
def test_existing_metadata_and_reused_step_are_rejected(self):
step = Step()
step.extra[METADATA_KEY] = {"status": "existing"}
with self.assertRaisesRegex(WorkflowImportError, "already has"):
stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
two_layers = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
reused = Step()
with self.assertRaisesRegex(WorkflowImportError, "multiple layers"):
stage_mopa_workflow_steps(two_layers, {"1": reused, "2": reused})
def test_preflight_failure_does_not_mutate_any_step(self):
result = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
first = Step()
with self.assertRaisesRegex(WorkflowImportError, "no serializable extra"):
stage_mopa_workflow_steps(result, {"1": first, "2": object()})
self.assertTrue(first.visible)
self.assertEqual((first.frequency, first.pulse_width), (1, 2))
def test_setter_failure_rolls_back_every_step(self):
result = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
first, second = Step(), Step(fail=True)
with self.assertRaisesRegex(ValueError, "synthetic setter failure"):
stage_mopa_workflow_steps(result, {"1": first, "2": second})
for step in (first, second):
self.assertTrue(step.visible)
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
self.assertEqual(step.extra, {"preserved": True})
if __name__ == "__main__":
unittest.main()

View file

@ -38,6 +38,18 @@
"sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b", "sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b",
"stability": "host-lifecycle-implementation", "stability": "host-lifecycle-implementation",
"required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"] "required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"]
},
{
"path": "rayforge/core/step.py",
"sha256": "4d25da9e4fb9f854562bfd33d238db3660c3056eeadfaa95c8669c6770d504f4",
"stability": "imported-host-workflow-interface",
"required_symbols": ["Step", "Step.set_visible", "Step.to_dict", "Step.from_dict"]
},
{
"path": "rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py",
"sha256": "59c94fec6861edc94383db856bba0c335e0e7c4d16a878e7c2a2c62b657e06c5",
"stability": "imported-host-workflow-interface",
"required_symbols": ["LaserStep", "LaserStep.set_frequency", "LaserStep.set_pulse_width", "LaserStep.to_dict", "LaserStep.from_dict"]
} }
], ],
"purpose": "Pinned host application for offline reuse and JCZ addon validation" "purpose": "Pinned host application for offline reuse and JCZ addon validation"