From fa41bf5d515c9d8dfc2e4d38ed500d49a056f1bd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 19:42:20 +0200 Subject: [PATCH] Characterize Rayforge graphical workflow seam --- docs/adr/0002-reuse-rayforge-and-balormk.md | 4 + .../rayforge-graphical-workflow-spike.md | 61 +++++++ docs/roadmap.md | 9 +- scripts/spike_rayforge_graphical_workflow.py | 160 ++++++++++++++++++ .../fixtures/rayforge-graphical-workflow.json | 58 +++++++ .../test_rayforge_graphical_workflow_spike.py | 45 +++++ 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 docs/research/rayforge-graphical-workflow-spike.md create mode 100644 scripts/spike_rayforge_graphical_workflow.py create mode 100644 tests/fixtures/rayforge-graphical-workflow.json create mode 100644 tests/test_rayforge_graphical_workflow_spike.py diff --git a/docs/adr/0002-reuse-rayforge-and-balormk.md b/docs/adr/0002-reuse-rayforge-and-balormk.md index 2295603..1b02e9f 100644 --- a/docs/adr/0002-reuse-rayforge-and-balormk.md +++ b/docs/adr/0002-reuse-rayforge-and-balormk.md @@ -106,6 +106,10 @@ Subsequent offline evidence: - [Synthetic LightBurn MOPA import spike](../research/rayforge-lightburn-mopa-import-spike.md): geometry and common settings survive, but frequency is not mapped to the workflow and pulse-width representation still lacks fixture evidence. +- [Graphical workflow model spike](../research/rayforge-graphical-workflow-spike.md): + Rayforge's generated settings contract exposes frequency and pulse width, + preserves them through step serialization and supplies preview Ops without + transport; packaged GTK interaction remains untested. These results support continuing toward complete list buffering and production integration, but they do not yet satisfy every proof obligation or change this diff --git a/docs/research/rayforge-graphical-workflow-spike.md b/docs/research/rayforge-graphical-workflow-spike.md new file mode 100644 index 0000000..fe8d2cb --- /dev/null +++ b/docs/research/rayforge-graphical-workflow-spike.md @@ -0,0 +1,61 @@ +# Rayforge graphical workflow model spike + +- Date: 2026-10-19 +- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- Transport: none +- Controller backend reachable: no + +## Result + +Pinned Rayforge exposes power, cut speed, frequency and pulse width through +the existing laser-step `VarSet` contract. The graphical laser settings page +builds its rows from that contract and places frequency and pulse width in a +capability-gated **Machine** section. The declared units are Hz and ns. + +A synthetic contour step retained power `0.42`, cut speed `750`, frequency +`40000` and pulse width `100` through Rayforge's normal step serialization and +deserialization. The same values were placed in a transport-free `Ops` stream +and wrapped in Rayforge's preview-facing `JobArtifact`. Its preview contained +nine operations, a 40 mm declared path and no encoded machine code. + +The deterministic result is stored in +`tests/fixtures/rayforge-graphical-workflow.json`. + +## Interpretation + +This is evidence that a JCZ addon can reuse Rayforge's existing workflow +model, settings-page machinery and preview artifact instead of creating a +parallel editor. It also confirms that MOPA frequency and pulse width can be +represented in the workflow UI and project-step model once a compatible laser +head advertises those capabilities. + +This is not a claim that the packaged application has been exercised by a +human. It is a headless model, UI-contract and preview-artifact test. The +Machine section intentionally remains hidden unless the active machine and +head provide PWM parameters. + +## Remaining graphical evidence + +A later no-device Windows test must: + +1. launch a packaged Rayforge build with the external addon; +2. select a synthetic JCZ-capable profile and confirm the Machine section; +3. open or create geometry, edit MOPA values and inspect the preview; +4. save and reload the project without losing those values; +5. exercise start, hold, cancel and explicit recovery against the no-device + driver. + +That test must still expose no USB discovery or controller transport. Until it +passes, the graphical half of roadmap item 5 and ADR 0002 remain open. + +## Reproduce + +Run against the pinned Rayforge checkout: + +```powershell +python scripts/spike_rayforge_graphical_workflow.py +``` + +On Windows the imported Rayforge task manager creates a local asyncio wake-up +socket. A sandbox that blocks local socket creation cannot run the spike even +though the spike performs no network or controller I/O. diff --git a/docs/roadmap.md b/docs/roadmap.md index 67c0934..39891df 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -297,7 +297,14 @@ The cancellation half of item 5 now has an asynchronous no-device proof: hold/resume work through the Rayforge driver contract, cancel clears every pending list buffer, restart fails until explicit recovery, and a separately encoded follow-up job can then complete. Physical stop/reset commands remain -absent. Graphical workflow assessment is still open. +absent. + +The first graphical-workflow assessment now confirms that Rayforge's existing +generated settings UI exposes frequency and pulse width with Hz/ns contracts, +the values survive step serialization, and the preview artifact is populated +without machine code or transport. This is a headless UI-contract and pipeline +proof, not a completed human GTK test. Packaged Windows interaction with the +external addon remains open before item 5 or ADR 0002 can close. ## Decision rules diff --git a/scripts/spike_rayforge_graphical_workflow.py b/scripts/spike_rayforge_graphical_workflow.py new file mode 100644 index 0000000..632474f --- /dev/null +++ b/scripts/spike_rayforge_graphical_workflow.py @@ -0,0 +1,160 @@ +"""Characterize Rayforge's graphical MOPA workflow without opening hardware.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +RAYFORGE_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5" + + +def _commit_of(source: Path) -> str: + result = subprocess.run( + ["git", "-C", str(source), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def run(source: Path) -> dict[str, object]: + commit = _commit_of(source) + if commit != RAYFORGE_COMMIT: + raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}") + + sys.path.insert(0, str(source)) + sys.path.insert( + 0, + str(source / "rayforge" / "builtin_addons" / "rayforge-addon-laser"), + ) + sys.path.insert( + 0, + str(source / "rayforge" / "builtin_addons" / "rayforge-addon-post"), + ) + + from post_processors.worker import register_transformers + from rayforge.pipeline.transformer.registry import transformer_registry + + register_transformers(transformer_registry) + + from laser_essentials.steps.contour_step import ContourStep + from rayforge.pipeline.artifact.job import JobArtifact + from raygeo.ops import Ops + + step = ContourStep(name="Synthetic MOPA contour") + step.power = 0.42 + step.cut_speed = 750 + step.frequency = 40000 + step.pulse_width = 100 + serialized = step.to_dict() + restored = ContourStep.from_dict(serialized) + + vars_by_key = {var.key: var for var in step.recipe_varset()} + ui_source = ( + source + / "rayforge" + / "builtin_addons" + / "rayforge-addon-laser" + / "laser_essentials" + / "widgets" + / "laser_step_page.py" + ).read_text(encoding="utf-8") + + ops = Ops() + ops.set_power(step.power) + ops.set_feed_rate(step.cut_speed) + ops.set_frequency(step.frequency) + ops.set_pulse_width(step.pulse_width) + ops.move_to(10, 10) + ops.line_to(20, 10) + ops.line_to(20, 20) + ops.line_to(10, 20) + ops.line_to(10, 10) + artifact = JobArtifact( + ops=ops, + distance=40.0, + generation_id=1, + time_estimate=0.053, + ) + preview = artifact.preview_ops + if preview is None: + raise AssertionError("Rayforge produced no preview operation stream") + + required = ("power", "cut_speed", "frequency", "pulse_width") + missing = [key for key in required if key not in vars_by_key] + if missing: + raise AssertionError(f"workflow variables missing: {missing}") + + return { + "rayforge_commit": commit, + "transport": None, + "controller_backend_reachable": False, + "assessment_scope": "headless model, UI contract, and preview artifact", + "workflow_controls": { + key: { + "label": vars_by_key[key].label, + "unit_contract": ( + "Hz" if key == "frequency" else "ns" if key == "pulse_width" else None + ), + } + for key in required + }, + "ui_contract": { + "machine_section_keys": ["frequency", "pulse_width"], + "varset_generated_rows": "add_varset_section" in ui_source, + "machine_section_is_capability_gated": "get_pwm_params" in ui_source, + }, + "serialized_step": { + "type": serialized["step_type"], + "power": serialized["power"], + "cut_speed": serialized["cut_speed"], + "frequency": serialized["frequency"], + "pulse_width": serialized["pulse_width"], + }, + "round_trip_preserved": { + "power": restored.power == step.power, + "cut_speed": restored.cut_speed == step.cut_speed, + "frequency": restored.frequency == step.frequency, + "pulse_width": restored.pulse_width == step.pulse_width, + }, + "preview": { + "available": True, + "operation_count": len(preview.to_dict()["commands"]), + "distance_mm": artifact.distance, + "time_estimate_s": artifact.time_estimate, + "machine_code": artifact.machine_code, + }, + "remaining_manual_evidence": [ + "launch the packaged Windows application with the external addon", + "confirm the Machine section is visible for a synthetic JCZ-capable head", + "exercise open, edit, preview, save, reload, cancel, and recovery by user input", + ], + "conclusion": ( + "Rayforge's existing workflow model and generated settings UI expose " + "frequency and pulse width, preserve them in project-step serialization, " + "and provide a preview operation stream without transport; packaged GTK " + "interaction remains a manual evidence gap" + ), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path, help="pinned Rayforge checkout") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = run(args.source.resolve()) + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8", newline="\n") + else: + print(rendered, end="") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/rayforge-graphical-workflow.json b/tests/fixtures/rayforge-graphical-workflow.json new file mode 100644 index 0000000..37fb7e4 --- /dev/null +++ b/tests/fixtures/rayforge-graphical-workflow.json @@ -0,0 +1,58 @@ +{ + "assessment_scope": "headless model, UI contract, and preview artifact", + "conclusion": "Rayforge's existing workflow model and generated settings UI expose frequency and pulse width, preserve them in project-step serialization, and provide a preview operation stream without transport; packaged GTK interaction remains a manual evidence gap", + "controller_backend_reachable": false, + "preview": { + "available": true, + "distance_mm": 40.0, + "machine_code": null, + "operation_count": 9, + "time_estimate_s": 0.053 + }, + "rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5", + "remaining_manual_evidence": [ + "launch the packaged Windows application with the external addon", + "confirm the Machine section is visible for a synthetic JCZ-capable head", + "exercise open, edit, preview, save, reload, cancel, and recovery by user input" + ], + "round_trip_preserved": { + "cut_speed": true, + "frequency": true, + "power": true, + "pulse_width": true + }, + "serialized_step": { + "cut_speed": 750, + "frequency": 40000, + "power": 0.42, + "pulse_width": 100, + "type": "ContourStep" + }, + "transport": null, + "ui_contract": { + "machine_section_is_capability_gated": true, + "machine_section_keys": [ + "frequency", + "pulse_width" + ], + "varset_generated_rows": true + }, + "workflow_controls": { + "cut_speed": { + "label": "Cut Speed", + "unit_contract": null + }, + "frequency": { + "label": "Frequency", + "unit_contract": "Hz" + }, + "power": { + "label": "Power", + "unit_contract": null + }, + "pulse_width": { + "label": "Pulse Width", + "unit_contract": "ns" + } + } +} diff --git a/tests/test_rayforge_graphical_workflow_spike.py b/tests/test_rayforge_graphical_workflow_spike.py new file mode 100644 index 0000000..d254217 --- /dev/null +++ b/tests/test_rayforge_graphical_workflow_spike.py @@ -0,0 +1,45 @@ +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +RESULT = ROOT / "tests" / "fixtures" / "rayforge-graphical-workflow.json" + + +class RayforgeGraphicalWorkflowSpikeTests(unittest.TestCase): + def setUp(self): + self.result = json.loads(RESULT.read_text(encoding="utf-8")) + + def test_workflow_is_transport_free(self): + self.assertIsNone(self.result["transport"]) + self.assertFalse(self.result["controller_backend_reachable"]) + + def test_mopa_controls_have_explicit_units(self): + controls = self.result["workflow_controls"] + self.assertEqual("Hz", controls["frequency"]["unit_contract"]) + self.assertEqual("ns", controls["pulse_width"]["unit_contract"]) + self.assertTrue(self.result["ui_contract"]["varset_generated_rows"]) + self.assertTrue( + self.result["ui_contract"]["machine_section_is_capability_gated"] + ) + + def test_step_round_trip_preserves_process_values(self): + self.assertTrue(all(self.result["round_trip_preserved"].values())) + step = self.result["serialized_step"] + self.assertEqual(40000, step["frequency"]) + self.assertEqual(100, step["pulse_width"]) + + def test_preview_exists_without_machine_code(self): + preview = self.result["preview"] + self.assertTrue(preview["available"]) + self.assertGreater(preview["operation_count"], 0) + self.assertIsNone(preview["machine_code"]) + + def test_manual_gtk_evidence_is_not_claimed(self): + self.assertGreater(len(self.result["remaining_manual_evidence"]), 0) + self.assertIn("headless", self.result["assessment_scope"]) + + +if __name__ == "__main__": + unittest.main()