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): - [Synthetic LightBurn MOPA import spike](../research/rayforge-lightburn-mopa-import-spike.md):
geometry and common settings survive, but frequency is not mapped to the geometry and common settings survive, but frequency is not mapped to the
workflow and pulse-width representation still lacks fixture evidence. 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 These results support continuing toward complete list buffering and production
integration, but they do not yet satisfy every proof obligation or change this 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 hold/resume work through the Rayforge driver contract, cancel clears every
pending list buffer, restart fails until explicit recovery, and a separately pending list buffer, restart fails until explicit recovery, and a separately
encoded follow-up job can then complete. Physical stop/reset commands remain 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 ## 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()