diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 8de200e..69a768b 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -68,20 +68,23 @@ application until both gates below have real evidence: reviewed build runner, verified without execution, and pass the manual no-device desktop checklist. 2. The known-working private JPT `.lbrn2` serialization evidence gate is met. - Import mapping still requires explicit unit conversion and fail-closed - handling of absent pulse width, with synthetic offline tests. + Offline mapping and disabled-output workflow staging now have synthetic + 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 binaries stay out of the public repository. ## 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 -element types and requires an explicit unit contract for normalization. It is -not wired into Rayforge's workflow and never authorizes encoding. The next -import task is host integration with layer identity and independently -established units; absent pulse width and image equivalence remain blockers. +element types and requires an explicit unit contract for normalization. The +bridge binds source layer IDs, uses the host MOPA setters, serializes review +metadata and disables every staged step. It never authorizes encoding. The +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 order: diff --git a/docs/research/lightburn-project-mopa-import.md b/docs/research/lightburn-project-mopa-import.md index 5e7828b..df31071 100644 --- a/docs/research/lightburn-project-mopa-import.md +++ b/docs/research/lightburn-project-mopa-import.md @@ -20,13 +20,22 @@ settings remain blocked pending image-processing equivalence, including Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and `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 -workflow. Host integration must bind settings to layer indices, establish -serialized units from independent evidence, resolve absent pulse width and -validate exact machine, lens, correction and parameter limits. No existing -golden lifecycle or upstream importer run was repeated; the package inventory -was extended to include the new module. +offline metadata seam. The companion workflow bridge matches the normalized +LightBurn layer index to Rayforge's source-segment layer ID and stages complete +vector MOPA values through the host's setters. Every staged step is disabled +through `Step.set_visible(False)`, and its raw values, normalized values, +review status and blockers are saved in the serializable `Step.extra` data. +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 -values, invalid numbers, image modes, mixed layers and malformed XML. Existing -packaging, no-device and public evidence checks remain part of the test suite. +values, invalid numbers, image modes, mixed layers, exact layer binding, +transactional rollback and malformed XML. Existing packaging, no-device and +public evidence checks remain part of the test suite. diff --git a/docs/research/upstream-contract-and-license-audit.md b/docs/research/upstream-contract-and-license-audit.md index c8f41e0..2f34941 100644 --- a/docs/research/upstream-contract-and-license-audit.md +++ b/docs/research/upstream-contract-and-license-audit.md @@ -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 | | `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 | +| `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 | | BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API | diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py index 8b6a611..3687e97 100644 --- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py @@ -29,6 +29,18 @@ def _number(element: ET.Element, field: str) -> str | None: 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: """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": raise ProjectImportError("unsupported project format") layers = [] + layer_ids: set[str] = set() for setting in root: if setting.tag not in ("CutSetting", "CutSetting_Img"): 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") if kind not in expected: 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")} blockers = [] 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), "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, "normalized": normalized, "blockers": blockers}) if not layers: diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py new file mode 100644 index 0000000..4b7d6e8 --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py @@ -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} diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json index 95b36dc..d9a407c 100644 --- a/tests/fixtures/rayforge-addon-spike.json +++ b/tests/fixtures/rayforge-addon-spike.json @@ -23,6 +23,7 @@ "pirate_ray_jcz/rayforge_compat.py", "pirate_ray_jcz/recipe_metadata.py", "pirate_ray_jcz/worker.py", + "pirate_ray_jcz/workflow_import.py", "rayforge-addon.yaml" ], "registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup" diff --git a/tests/test_lightburn_project_mopa.py b/tests/test_lightburn_project_mopa.py index 2984be3..52e8fd6 100644 --- a/tests/test_lightburn_project_mopa.py +++ b/tests/test_lightburn_project_mopa.py @@ -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'<{tag} type="{kind}">' - f'{fields}').encode() + f'{fields}').encode() FIELDS = '' @@ -25,6 +25,7 @@ class ProjectMopaTests(unittest.TestCase): self.assertFalse(result["encoding_allowed"]) self.assertFalse(result["hardware_use_allowed"]) self.assertIsNone(result["transport"]) + self.assertEqual(result["layers"][0]["source_layer_id"], "0") def test_units_are_never_inferred_from_magnitude(self): result = import_project_mopa(project(FIELDS)) @@ -69,10 +70,20 @@ class ProjectMopaTests(unittest.TestCase): with self.assertRaises(ProjectImportError): 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 = ('' + ''+FIELDS+'' + ''+FIELDS+'' + '').encode() + with self.assertRaisesRegex(ProjectImportError, "duplicate layer index"): + import_project_mopa(duplicate) + def test_multiple_layers_preserve_order_and_missing_pulse(self): data = ('' - ''+FIELDS+'' - '' + ''+FIELDS+'' + '' '').encode() layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"] self.assertEqual([x["type"] for x in layers], ["Cut", "Image"]) diff --git a/tests/test_lightburn_workflow_import.py b/tests/test_lightburn_workflow_import.py new file mode 100644 index 0000000..4b457bd --- /dev/null +++ b/tests/test_lightburn_workflow_import.py @@ -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'<{tag} type="{kind}">' + f'{fields}').encode() + return import_project_mopa(data, unit_contract=UNIT_CONTRACT) + + +FIELDS = '' + + +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(''), + imported(FIELDS + '', 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( + ('' + ''+FIELDS+'' + ''+FIELDS+'' + '').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( + ('' + ''+FIELDS+'' + ''+FIELDS+'' + '').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( + ('' + ''+FIELDS+'' + ''+FIELDS+'' + '').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() diff --git a/upstreams/rayforge.json b/upstreams/rayforge.json index d6cf0d5..e8b4e82 100644 --- a/upstreams/rayforge.json +++ b/upstreams/rayforge.json @@ -38,6 +38,18 @@ "sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b", "stability": "host-lifecycle-implementation", "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"