Characterize Rayforge graphical workflow seam

This commit is contained in:
Codex 2026-10-19 19:42:20 +02:00
parent 9201f432a2
commit fa41bf5d51
6 changed files with 336 additions and 1 deletions

View file

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

View file

@ -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 <rayforge-checkout>
```
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.

View file

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

View file

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

View file

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

View file

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