Document Rayforge LightBurn MOPA import gap

This commit is contained in:
Codex 2026-10-18 17:26:03 +02:00
parent 4742099be8
commit a63a4ee4a6
7 changed files with 292 additions and 0 deletions

View file

@ -100,6 +100,9 @@ Subsequent offline evidence:
- [JCZ 12-byte record spike](../research/rayforge-jcz-packet-spike.md): the
external encoder deterministically packs controller-shaped command records
matching the pinned BalorMK mock values, while retaining no transport.
- [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.
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,46 @@
# Rayforge synthetic LightBurn MOPA import spike
- Date: 2026-10-18
- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5`
- Fixture: synthetic public `.lbrn2`; no machine identifiers
- Scope: importer boundary only, with no discovery or transport
## Result
The pinned Rayforge importer successfully parsed a synthetic two-millimetre
square and preserved its layer name, geometry, 50 percent power, 250 mm/s
speed and one pass. LightBurn's observed `frequency` and
`overrideFrequency` elements also survive in the source asset's raw metadata.
However, the importer does not map frequency into the workflow layer settings.
It maps only power, speed and passes from this fixture. The inspected Rayforge
LightBurn fixtures and importer contain no evidence-backed mapping for a MOPA
pulse-width field. Consequently, the imported job is explicitly classified as
unsafe for JCZ encoding rather than being completed with a guessed value.
Two runs produced identical output with SHA-256
`9ebce7b5b3388abfeed024e25f8f46001260bf814da19808bfc83fb6554fc26e`.
The golden result is `tests/fixtures/rayforge-lightburn-mopa-import.json`.
## Evidence classification
- **Observed:** Rayforge's own LightBurn test fixtures contain `frequency` and
`overrideFrequency` elements.
- **Observed:** generic parsing retains these elements in source metadata.
- **Observed:** `_build_step_config` does not map frequency or pulse width.
- **Unknown:** the exact LightBurn field and unit for JPT MOPA pulse width in a
known-working galvo project.
- **Not inferred:** no pulse-width tag, unit, default or machine limit was
invented for the public fixture.
## Decision impact
Rayforge's existing LightBurn importer is useful for ordinary vector geometry
and common process settings, but it does not yet satisfy M1's MOPA parameter
survival requirement. The narrowest likely extension is an importer mapping
for frequency plus a separately evidenced pulse-width field, followed by an
explicit validation gate. A private known-working LightBurn project is still
needed to establish the pulse-width representation before implementing that
mapping.
No USB enumeration, controller command, motion or emission was attempted.

View file

@ -139,6 +139,14 @@ records whose opcodes, values, coordinates and distances match the pinned
BalorMK mock evidence. It deliberately omits list buffering and all transport;
controller-safe state ordering remains an explicit evidence gap.
[The synthetic LightBurn MOPA import spike](research/rayforge-lightburn-mopa-import-spike.md)
confirms that Rayforge preserves geometry, power, speed and passes and retains
an observed LightBurn frequency value in raw source metadata. Frequency is not
mapped into workflow settings, and no evidence-backed pulse-width field was
found, so the job deliberately fails the JCZ encoding gate. A private
known-working galvo fixture is required before implementing the missing MOPA
mapping.
The architecture decision remains proposed until these spikes produce
evidence. An independent app requires a documented gap analysis showing why
neither existing platform can be extended responsibly.

View file

@ -0,0 +1,137 @@
"""Inspect a synthetic LightBurn MOPA layer through pinned Rayforge.
This script performs no discovery or device I/O. It records which observed
LightBurn values survive Rayforge's importer boundary without inventing a
pulse-width field that is absent from the inspected fixture evidence.
"""
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, fixture: 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))
from rayforge.core.vectorization_spec import (
LayerImportMode,
PassthroughSpec,
)
from rayforge.image.lightburn.importer import LightBurnImporter
data = fixture.read_bytes()
importer = LightBurnImporter(data, source_file=fixture)
manifest = importer.scan()
parsed = importer.parse()
if parsed is None:
raise AssertionError("synthetic LightBurn fixture did not parse")
vectorized = importer.vectorize(
parsed,
PassthroughSpec(layer_import_mode=LayerImportMode.NEW_LAYERS),
)
source_asset = importer.create_source_asset(parsed)
raw = source_asset.metadata["lightburn_cut_settings"][
"Synthetic MOPA layer"
]
mapped = vectorized.layer_settings["0"]
geometry = vectorized.geometries_by_layer["0"]
bounds = [round(value, 6) for value in geometry.rect()]
expected_raw = {
"maxPower": 50,
"speed": 250,
"frequency": 40000,
"overrideFrequency": 1,
}
if any(raw.get(key) != value for key, value in expected_raw.items()):
raise AssertionError("raw LightBurn values changed during import")
if mapped.get("power") != 0.5 or mapped.get("cut_speed") != 15000:
raise AssertionError("power or speed mapping changed")
if "frequency" in mapped or "pulse_width" in mapped:
raise AssertionError("MOPA mapping changed; re-evaluate this spike")
return {
"rayforge_commit": commit,
"transport": None,
"fixture": fixture.name,
"fixture_provenance": "synthetic public data",
"manifest": {
"title": manifest.title,
"layers": [
{
"id": layer.id,
"name": layer.name,
"feature_count": layer.feature_count,
}
for layer in manifest.layers
],
"errors": [str(error) for error in manifest.errors],
},
"geometry_bounds_mm": bounds,
"raw_cut_setting": {
key: raw[key]
for key in (
"maxPower",
"speed",
"frequency",
"overrideFrequency",
"numPasses",
)
},
"mapped_layer_settings": mapped,
"mopa_survival": {
"frequency_preserved_in_source_metadata": raw["frequency"] == 40000,
"frequency_mapped_to_workflow": "frequency" in mapped,
"pulse_width_source_field_observed": False,
"pulse_width_mapped_to_workflow": "pulse_width" in mapped,
"safe_for_jcz_encoding": False,
},
"conclusion": (
"geometry, power, speed and passes survive; frequency remains only "
"in source metadata and no evidence-backed pulse-width field is mapped"
),
}
def main() -> None:
root = Path(__file__).parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path, help="pinned Rayforge checkout")
parser.add_argument(
"--fixture",
type=Path,
default=root / "tests" / "fixtures" / "synthetic-mopa-import.lbrn2",
)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
result = run(args.source.resolve(), args.fixture.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,43 @@
{
"conclusion": "geometry, power, speed and passes survive; frequency remains only in source metadata and no evidence-backed pulse-width field is mapped",
"fixture": "synthetic-mopa-import.lbrn2",
"fixture_provenance": "synthetic public data",
"geometry_bounds_mm": [
54.0,
54.0,
56.0,
56.0
],
"manifest": {
"errors": [],
"layers": [
{
"feature_count": 0,
"id": "0",
"name": "Synthetic MOPA layer"
}
],
"title": "synthetic-public-fixture"
},
"mapped_layer_settings": {
"cut_speed": 15000,
"passes": 1,
"power": 0.5
},
"mopa_survival": {
"frequency_mapped_to_workflow": false,
"frequency_preserved_in_source_metadata": true,
"pulse_width_mapped_to_workflow": false,
"pulse_width_source_field_observed": false,
"safe_for_jcz_encoding": false
},
"raw_cut_setting": {
"frequency": 40000,
"maxPower": 50,
"numPasses": 1,
"overrideFrequency": 1,
"speed": 250
},
"rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5",
"transport": null
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<LightBurnProject AppVersion="synthetic-public-fixture" FormatVersion="1">
<CutSetting type="Cut">
<index Value="0"/>
<name Value="Synthetic MOPA layer"/>
<maxPower Value="50"/>
<speed Value="250"/>
<frequency Value="40000"/>
<overrideFrequency Value="1"/>
<numPasses Value="1"/>
<doOutput Value="1"/>
</CutSetting>
<Shape Type="Rect" CutIndex="0" W="2" H="2" Cr="0">
<XForm>1 0 0 1 55 55</XForm>
</Shape>
<Notes ShowOnLoad="0" Notes="Synthetic public fixture; no machine identifiers or settings."/>
</LightBurnProject>

View file

@ -0,0 +1,38 @@
import json
import unittest
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path(__file__).parents[1]
RESULT = ROOT / "tests" / "fixtures" / "rayforge-lightburn-mopa-import.json"
SOURCE = ROOT / "tests" / "fixtures" / "synthetic-mopa-import.lbrn2"
class RayforgeLightBurnImportSpikeTests(unittest.TestCase):
def test_public_fixture_is_small_and_synthetic(self):
root = ET.fromstring(SOURCE.read_bytes())
self.assertEqual("synthetic-public-fixture", root.get("AppVersion"))
self.assertEqual(1, len(root.findall("Shape")))
self.assertNotIn("serial", SOURCE.read_text(encoding="utf-8").lower())
def test_observed_values_survive_raw_import(self):
result = json.loads(RESULT.read_text(encoding="utf-8"))
self.assertIsNone(result["transport"])
self.assertEqual(40000, result["raw_cut_setting"]["frequency"])
self.assertEqual(0.5, result["mapped_layer_settings"]["power"])
self.assertEqual(15000, result["mapped_layer_settings"]["cut_speed"])
self.assertEqual([54.0, 54.0, 56.0, 56.0], result["geometry_bounds_mm"])
def test_incomplete_mopa_import_fails_the_encoding_gate(self):
result = json.loads(RESULT.read_text(encoding="utf-8"))
survival = result["mopa_survival"]
self.assertTrue(survival["frequency_preserved_in_source_metadata"])
self.assertFalse(survival["frequency_mapped_to_workflow"])
self.assertFalse(survival["pulse_width_source_field_observed"])
self.assertFalse(survival["pulse_width_mapped_to_workflow"])
self.assertFalse(survival["safe_for_jcz_encoding"])
if __name__ == "__main__":
unittest.main()