Bind LightBurn MOPA units to verified evidence

This commit is contained in:
Codex 2026-10-24 15:34:11 +02:00
parent 862d5a3cca
commit 8ab25827db
24 changed files with 328 additions and 126 deletions

2
.gitattributes vendored
View file

@ -4,6 +4,8 @@
*.py text eol=lf
*.json text eol=lf
*.jsonl text eol=lf
*.webmanifest text eol=lf
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.png binary

View file

@ -44,7 +44,8 @@ operator-verified projects are preserved and analyzed. The operator confirmed a
and [control-machine observation](docs/research/lightburn-control-machine-observation-2026-10-24.md).
Three operator-verified projects now establish project-level `frequency` and
`QPulseWidth` serialization; see the
`QPulseWidth` serialization. Correlation with the running LightBurn 2.1.03 UI
establishes raw Hz to displayed kHz and direct ns pulse width; see the
[verified-project analysis](docs/research/lightburn-verified-projects-2026-10-24.md).
Missing pulse width still requires explicit resolution before executable import.

View file

@ -77,9 +77,10 @@ gates remain:
1. A Windows artifact for the pinned Rayforge revision must be produced on a
reviewed build runner, verified without execution, and pass the manual
no-device desktop checklist.
2. Promote only complete vector MOPA imports after an explicit unit contract,
absent-pulse-width policy and exact machine-context validation. The private
serialization-evidence gate itself is complete.
2. The known-working private JPT `.lbrn2` serialization and unit-contract gates
are met for LightBurn 2.1.03 vector settings. Promotion still requires
explicit resolution of absent pulse width, image equivalence and complete
machine-context validation.
Raw projects, screenshots containing identifiers, machine data and build
binaries stay out of the public repository.
@ -89,13 +90,13 @@ binaries stay out of the public repository.
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. The
bridge binds source layer IDs, uses the host MOPA setters, serializes review
metadata and disables every staged step. Document preflight presents those
blockers and the offline driver rejects them before buffer preparation. It
never authorizes encoding. The next import task is GTK presentation plus an
independently established unit contract; absent pulse width and image
equivalence remain blockers.
element types and applies the evidence-bound Hz/ns contract only to LightBurn
2.1.03. The bridge binds source layer IDs, uses the host MOPA setters,
serializes exact unit provenance and disables every staged step. Document
preflight presents the remaining blockers and the offline driver rejects them
before buffer preparation. It never authorizes encoding. The next import task
is packaged GTK presentation; absent pulse width and image equivalence remain
blockers.
If a Windows artifact is available, keep the machine disconnected and run, in
order:
@ -108,8 +109,9 @@ order:
If no artifact is available, send the external-builder handoff in
`docs/research/m1-no-device-acceptance.md`; do not start another research
branch or install a build environment here. Kaz's explicit decision on ADR
0003 is also outstanding.
branch or install a build environment here. The evidence-backed vector MOPA
import mapping and synthetic tests are complete; Kaz's explicit decision on
ADR 0003 is also outstanding.
The M3 protocol can also be reviewed offline, but its
operation allowlist must stay empty until exact read-only request evidence is
available. ADR 0002 is accepted for the offline architecture. Until the

View file

@ -104,8 +104,9 @@ Subsequent offline evidence:
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.
geometry and common settings survive; the external addon now maps complete
vector frequency and pulse width with a LightBurn 2.1.03 evidence contract,
while keeping every staged step disabled.
- [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
@ -130,7 +131,9 @@ Subsequent offline evidence:
Three operator-verified private projects subsequently established the
project-level `frequency` and `QPulseWidth` serialization needed to implement
the disabled-output import seam. Together, the evidence satisfies the host
architecture decision. It does not authorize hardware access or claim that the
packaged Windows interaction and import-promotion release gates have passed.
the disabled-output import seam. The running LightBurn 2.1.03 UI further binds
those values to raw Hz/display kHz and direct ns. Together, the evidence
satisfies the accepted host architecture decision. It does not authorize
hardware access or claim that the packaged Windows interaction and
import-promotion release gates have passed.

View file

@ -66,9 +66,10 @@ Across the 60 exported cut-list entries:
LightBurn's official galvo cut-setting documentation presents frequency in
kHz and Q-Pulse Width in nanoseconds. The larger integers stored by the export
show that the serialized representation cannot simply be copied into a UI
field without an explicit conversion contract. The exact field name and unit
are now evidenced for a device-library export, but the representation inside
a `.lbrn2` project remains unconfirmed.
field without an explicit conversion contract. Subsequent operator-verified
LightBurn 2.1.03 project/UI correlation establishes raw Hz to display kHz and
direct-ns `QPulseWidth`; see the
[control-machine observation](lightburn-control-machine-observation-2026-10-24.md).
These observed recipe values and profile fields are **not certified hardware
limits or safe operating parameters**. They must not be used to probe the
@ -93,19 +94,16 @@ new general parser:
1. add evidence-backed JCZ device-profile recognition;
2. preserve source units and raw values at the import boundary;
3. map frequency and `QPulseWidth` only after a real `.lbrn2` fixture confirms
project serialization;
3. bind the now-confirmed LightBurn 2.1.03 Hz/ns contract to exact provenance;
4. validate imported values against a separately verified machine profile;
5. fail closed when units, profile identity or ranges are unknown.
## Remaining collection gaps
The exports settle the exact LightBurn release and the two named device
profiles. Phase 0 still needs:
profiles. The known-working projects, Device Settings screenshots and closed
preferences snapshot were subsequently collected. Phase 0 still needs:
- two or three known-working `.lbrn2` projects, especially a MOPA/hatch job;
- Device Settings and device-list screenshots;
- the closed-application LightBurn preferences directory;
- exterior machine, source, controller and installed-lens photographs;
- mounted-lens, connection, startup and interlock notes;
- the stock driver's identity and version on the control computer.

View file

@ -42,11 +42,11 @@ unverified test files and instructed us not to use them. Nine temporary copies
were removed from this acquisition before analysis or commit. Their originals
were unchanged; no project content was analyzed or retained as evidence.
This collection closes the missing closed-application settings snapshot. It
does **not** close the known-working JPT project gate: project-level frequency
and pulse-width serialization still require independently identified,
operator-verified project evidence. Do not derive an executable import mapping
from these settings or the excluded test projects.
This collection closes the missing closed-application settings snapshot. The
gate was subsequently closed by three separately supplied, operator-verified
projects plus the running LightBurn 2.1.03 UI. The excluded candidates remain
unverified and are still not evidence. The resulting vector import mapping
stays disabled and review-gated; it is not executable machine authorization.
The reviewed Windows artifact and manual no-device desktop acceptance also
remain outstanding. No controller, USB endpoint, framing, motion or laser

View file

@ -5,12 +5,15 @@ The addon's `project_import.py` reads observed `CutSetting` and
It preserves raw `frequency`, `QPulseWidth`, layer order and image dither mode.
The synthetic unit tests contain no private geometry or complete recipes.
Normalization requires the caller to declare the serialized-unit contract
`frequency=Hz;QPulseWidth=ns`. This is an explicit input, not an inference from
numeric magnitude or an authorization claim. With that contract, frequency is
represented in Hz and kHz (division by 1000), and pulse width in ns. Without
it, only raw values are returned. The seam does not establish the contract on
behalf of the caller or read machine limits from these values.
Normalization requires the caller to opt into the evidence-backed serialized
unit contract `frequency=Hz;QPulseWidth=ns`. The contract is restricted to
LightBurn `AppVersion="2.1.03"`; a missing or different version fails closed.
It comes from correlating an operator-verified project's raw
`frequency=1000000` and `QPulseWidth=10` with LightBurn's visible `1000 kHz`
and `10 ns` controls. This is not an inference from magnitude or an
authorization claim. Frequency is represented in Hz and kHz (division by
1000), while pulse width remains in ns. Without explicit contract selection,
only raw values are returned.
Absent fields remain absent, never zero or inherited defaults. Malformed,
non-finite, non-positive, duplicate and excessively large values are rejected.
@ -23,17 +26,19 @@ Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
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.
through `Step.set_visible(False)`, and its raw values, normalized values, exact
unit-contract provenance, review status and blockers are saved in the
serializable `Step.extra` data. Tampered or missing provenance is rejected
before any step is mutated.
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.
blocker. Host UI and preflight integration must still resolve absent pulse
width and validate exact machine, lens, correction and parameter limits before
a separate reviewed promotion path can exist. Image-layer equivalence remains
unverified. Rayforge's relevant Step and LaserStep files are included in the
pinned upstream contract manifest.
The addon now also exposes a transport-free document preflight report with
stable issue codes and human-readable blocker messages. The offline driver

View file

@ -67,7 +67,7 @@ required by real workshop jobs and supported by evidence.
| Hide geometry without disabling output, and disable output without hiding geometry | LightBurn separates `Show` and `Output` | Rayforge separates `Layer.visible` canvas state from `Step.visible` job participation and serializes both; output is operation-level rather than a layer-level control | Proven offline at model/source level; aggregate UI untested | Add or propose a tri-state layer Output control over existing step states, then verify it in the packaged no-device GUI |
| Edit common settings quickly | The layer list exposes mode, speed, power, passes and applicable galvo fields | Rayforge's generated laser-step settings expose power, speed, frequency and pulse width; serialization is proven offline | Proven offline at the model/UI-contract level | Confirm editing and save/reload in the packaged no-device GUI |
| Reveal complexity progressively | LightBurn Beginner Mode hides some advanced settings; PirateRay calls for Beginner, Advanced and Expert levels without moving the canvas | Rayforge capability sections can hide machine-dependent fields, but three user-facing disclosure levels are not proven | Product requirement | Define the smallest beginner field set from real job needs before adding controls |
| Preserve MOPA units and values | Galvo frequency is presented in kHz and Q-pulse width in ns | Rayforge's workflow uses Hz and ns and preserves both; LightBurn project import does not yet map frequency or pulse width into workflow settings | Partly proven; import gated | Keep explicit conversion at the import boundary and wait for a known-working `.lbrn2` before mapping `QPulseWidth` |
| Preserve MOPA units and values | Galvo frequency is presented in kHz and Q-pulse width in ns | Rayforge's workflow uses Hz and ns; the addon maps complete LightBurn 2.1.03 vector settings with an evidence-bound raw-Hz/display-kHz and direct-ns contract | Proven offline for complete vector settings; image/absent-pulse cases gated | Verify editing and persistence in the packaged no-device GUI while retaining the version and provenance checks |
| Apply a reusable material recipe | LightBurn libraries organize presets by material, thickness and description and apply/link them to layers | Rayforge recipes already match machine, head, material, thickness and step type and preserve Hz/ns settings; the external addon now validates versioned provenance plus exact source/lens/correction compatibility while withholding hardware authorization | Proven offline at model/source level; approval policy and portable collections remain | Integrate the validated metadata into recipe selection before changing the existing recipe UI |
| Inspect planned behavior before any run | LightBurn Preview reflects enabled layers and planned order | Rayforge produced a populated `JobArtifact` with Ops, path length and no machine code | Proven offline; human interaction untested | Verify layer filtering, order, hatch and warnings in the packaged no-device GUI |
| Reject a visibly invalid job | Preview/preflight should reveal unintended output; the target product must reject profile mismatch and out-of-field geometry | Existing JCZ negative tests reject profile mismatch, out-of-field geometry, invalid values and unsupported Ops | Proven offline at encoder boundary | Surface these failures as understandable preflight messages without weakening the fail-closed gate |
@ -102,9 +102,10 @@ The PirateRay-specific work should remain narrow:
## Gaps that must not be papered over
1. Three known-working private galvo projects establish project-level
`QPulseWidth` serialization. Omission/default behavior still requires an
explicit policy before promotion to executable output.
1. Three known-working private galvo projects and the running LightBurn 2.1.03
UI establish the vector Hz/ns serialization contract. Missing
`QPulseWidth` remains absence, never a guessed default; image-processing
equivalence is also unproven.
2. The current Windows environment has not launched the pinned Rayforge GTK
application; the headless UI contract is not a human interaction test.
3. `RequireFrameBeforeStart` is observed in a LightBurn device export and

View file

@ -163,10 +163,13 @@ ISO-8601 review timestamp with timezone. No status authorizes hardware access.
## Remaining release gates
- The known-working project gate and LightBurn 2.1.03 Hz/ns serialization
contract are complete. Missing pulse width and image equivalence remain
explicit blockers.
- Human interaction results from the packaged build must be recorded before
M1 can be called a packaged desktop checkpoint.
- The three known-working private projects establish serialized MOPA fields.
Promotion from disabled staging still requires the explicit unit contract,
absent-pulse-width policy and exact machine-context validation already
enforced by preflight.
Promotion from disabled staging still requires an absent-pulse-width policy,
image equivalence and exact machine-context validation already enforced by
preflight.
- Physical controller behavior remains outside M1.

View file

@ -159,8 +159,9 @@ Source inspection found particularly relevant implementation details:
including speed, power, passes, offsets, raster interval/angle and dither;
- `.lbdev` device profiles have a separate importer;
- laser steps and recipes serialize `frequency` in Hz; the laser-step UI
describes `pulse_width` as nanoseconds, while the Ruida encoder calls the
same Ops value microseconds, so the unit contract requires clarification;
describes `pulse_width` as nanoseconds. Later LightBurn 2.1.03 evidence binds
the JPT import seam to raw Hz/display kHz and direct ns without changing the
separate Ruida encoder question;
- the neutral `Ops` command set contains frequency and pulse-width commands;
- binary rather than G-code drivers are supported through an `OpsEncoder`;
- driver state, cancel/hold, discovery, machine profiles, simulator and
@ -224,8 +225,9 @@ stability and binary-wheel review.
## Finding 4: LightBurn migration is mostly an extension problem
Rayforge's existing LightBurn import removes the need to create a general
`.lbrn2` parser. The focused task is to build a private, known-working fixture
matrix and identify which galvo-specific fields are currently dropped.
`.lbrn2` parser. The private known-working fixture matrix now identifies the
vector MOPA fields and units; the focused remaining work is packaged GUI
verification plus unsupported image and omitted-value behavior.
Likely gaps to test include:

View file

@ -41,8 +41,8 @@ operations continue to fail closed.
- Port-state commands are deliberately omitted because their machine-specific
bit semantics can arm output and have not yet passed the hardware gate.
- The 110 mm coordinate mapping is synthetic and has no lens correction.
- Machine-specific bounds and the pulse-width unit contract still require
authoritative evidence; they are not guessed here.
- The LightBurn 2.1.03 import unit contract is now evidenced, but certified
machine-specific bounds and physical behavior remain unknown and unguessed.
- No hardware compatibility or safe marking behavior is established.
## Decision impact

View file

@ -18,12 +18,13 @@ for JCZ use rather than receiving guessed defaults.
The external addon now supplies the narrow profile seam missing from Rayforge
core. It recognizes only the explicit public synthetic profile identity,
requires `JCZFiber`, positive work-area dimensions and
`RequireFrameBeforeStart: true`, and associates the result only with
`PirateRayJCZNoDeviceDriver`. Frequency and `QPulseWidth` remain raw
observations with unverified serialized-unit conversion. Encoding remains
blocked because the physical profile, lens/correction match, project
serialization and machine-specific limits are not yet verified.
requires LightBurn 2.1.03, `JCZFiber`, the observed 110 x 110 mm work area,
Q-pulse-width enablement, fiber-type code 3, a valid 1-4000 kHz profile range,
and `RequireFrameBeforeStart: true`. It associates the result only with
`PirateRayJCZNoDeviceDriver`. Cut-list frequency is normalized from serialized
Hz to display kHz and `QPulseWidth` remains ns. Encoding stays blocked because
physical identity, lens/correction match, certified limits and promotion
review are not verified.
The checked result is
`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the
@ -52,8 +53,8 @@ python scripts/spike_rayforge_lightburn_device_import.py C:\path\to\rayforge
## Next seam
The next extension is offline controller-list buffering and explicit safe
state ordering. Cut-list recipes and project-layer `QPulseWidth` mapping remain
gated until a known-working `.lbrn2` fixture establishes project serialization.
Offline controller-list buffering and explicit state ordering are complete.
The next software gate is packaged Windows GTK interaction. Image-layer
equivalence and absent `QPulseWidth` remain blocked rather than defaulted.
No USB enumeration, controller command, motion, framing or emission occurred.

View file

@ -12,14 +12,13 @@ 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 project fixtures and importer contain no evidence-backed mapping for
a MOPA pulse-width field. A later private LightBurn device-library export
establishes the field name `QPulseWidth` and its nanosecond UI contract, but it
does not establish how that value is serialized in a `.lbrn2` project.
Consequently, the imported job is explicitly classified as unsafe for JCZ
encoding rather than being completed with a guessed mapping.
The pinned upstream importer does not map frequency into workflow settings; it
maps only power, speed and passes from this fixture. The external addon now
supplies that narrow mapping for LightBurn 2.1.03. Subsequent operator-verified
evidence correlates raw `frequency=1000000` and `QPulseWidth=10` with visible
`1000 kHz` and `10 ns`, establishing the version-bound Hz/ns serialization
contract. The staged job nevertheless remains unsafe for JCZ encoding because
machine, lens, correction and promotion review are still unresolved.
Two runs produced identical output with SHA-256
`9ebce7b5b3388abfeed024e25f8f46001260bf814da19808bfc83fb6554fc26e`.
@ -35,18 +34,18 @@ The golden result is `tests/fixtures/rayforge-lightburn-mopa-import.json`.
uses `QPulseWidth`; official LightBurn documentation defines Q-pulse width
in nanoseconds. See the
[device-export analysis](lightburn-device-exports-2026-10-19.md).
- **Unknown:** the exact `QPulseWidth` representation and omission/default
behavior in a known-working galvo `.lbrn2` project.
- **Observed after this spike:** three known-working galvo projects use the
case-sensitive `QPulseWidth` field. Missing fields remain absent; their
effective default is still unknown and is never invented.
- **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 `QPulseWidth`, followed by an explicit unit conversion and
validation gate. A private known-working LightBurn project is still needed to
establish project serialization before implementing that mapping.
Rayforge's existing LightBurn importer remains useful for ordinary vector
geometry and common process settings. The external addon now implements the
narrow frequency/`QPulseWidth` mapping with a version- and evidence-bound unit
contract, stages values on disabled workflow steps, and retains all blockers.
Image equivalence, absent pulse width and physical compatibility remain gated.
No USB enumeration, controller command, motion or emission was attempted.

View file

@ -66,11 +66,15 @@ It does **not** yet establish:
- status, interlock, error, abort, and recovery behavior;
- whether an apparently harmless query can move a galvo or arm emission;
- the effective pulse-width default for settings that omit `QPulseWidth`.
- safe controller discovery, state and recovery behavior beyond the offline
mock evidence.
The later LightBurn export collection now establishes version `2.1.03`, a
110 x 110 mm `JCZFiber`/`LibUSB` JPT profile, and the `QPulseWidth` device
library field. See the
[device-export analysis](research/lightburn-device-exports-2026-10-19.md).
The later verified-project and live-UI collection establishes LightBurn 2.1.03
project frequency as serialized Hz/displayed kHz and `QPulseWidth` as ns.
Consequently, treating a vendor DLL or opaque profile as a callable black box
would be premature and would make the safety boundary impossible to audit.
@ -154,11 +158,12 @@ and explicit state-before-motion ordering. Upload, execution and physical
transport remain outside M1.
[The synthetic LightBurn MOPA import spike](research/rayforge-lightburn-mopa-import-spike.md)
confirms that Rayforge preserves geometry, power, speed and passes. Three later
operator-verified projects establish project-level `frequency` and
`QPulseWidth` fields. The addon now stages complete vector settings into
disabled Rayforge steps and rejects them at preflight until units, defaults and
machine context are explicitly approved.
confirms that Rayforge preserves geometry, power, speed and passes. Three
operator-verified LightBurn 2.1.03 projects plus the running UI establish the
serialized Hz/display-kHz relationship and direct ns `QPulseWidth` contract.
The external addon maps complete vector settings into disabled workflow steps
with exact evidence provenance. Missing pulse width, image equivalence and
every hardware gate still fail closed.
ADR 0002 accepts the Rayforge-addon architecture with BalorMK as the pinned
behavioral oracle. Packaged Windows interaction is a release gate, not an open
@ -271,8 +276,8 @@ the M3 protocol draft are all present. Work now converges on two deliverables:
1. produce the pinned Windows Rayforge checkpoint on a reviewed external build
runner, verify the transferred artifact set and complete the no-device
desktop interaction checklist;
2. promote complete vector MOPA imports only after recording the explicit unit
contract, absent-pulse-width policy and exact machine-context validation.
2. promote complete vector MOPA imports only after an absent-pulse-width
policy, image equivalence and exact machine-context validation.
Exterior photographs, additional screenshots and speculative protocol research
are not M1 blockers. Hardware work remains separately gated by M3.
@ -291,10 +296,10 @@ Rayforge preserves the common name and work-area fields, but maps no
profile model. The fixture and regression test remain transport-free.
Item 3 now has a minimal external-addon seam: it recognizes only the synthetic
`JCZFiber` fixture with explicit provenance and frame-before-start, binds it to
the no-device JCZ driver, preserves process values as raw observations with
unit uncertainty, and keeps encoding disabled until the unit/default and exact
machine-context review passes.
LightBurn 2.1.03 `JCZFiber` fixture with explicit provenance, the observed
110 x 110 mm/Q-pulse/frequency/frame contract, and binds it to the no-device
JCZ driver. Vector project values use the evidence-backed Hz/ns mapping, while
encoding remains disabled pending physical compatibility and review.
Item 4 now covers transport-free fixed-size list buffering and an explicit
state-before-motion order derived from pinned BalorMK. Port commands, upload,

View file

@ -110,13 +110,17 @@ evidence for:
The remaining M1 release gates are deliberately narrow:
- vector project frequency and `QPulseWidth` now use a LightBurn 2.1.03,
evidence-bound Hz/ns contract; absent pulse width and image equivalence stay
blocked;
- packaged Windows/GTK interaction is blocked until a reviewed Rayforge
runtime with PyGObject and MSYS2/UCRT64 is available; the repository build
entry point fetches pinned source but never installs that runtime;
- three operator-verified projects establish project-level `frequency` and
`QPulseWidth` serialization, and the offline bridge stages complete vector
settings with output disabled; promotion still requires an explicit unit
contract, absent-pulse-width policy and exact machine-context validation;
settings with output disabled; promotion still requires an
absent-pulse-width policy, image equivalence and exact machine-context
validation;
- graphical workflow, lens correction and controller behavior remain unproven
at the physical boundary and belong to later gated milestones.

View file

@ -60,12 +60,18 @@ def run(source: Path, fixture: Path, addon: Path) -> dict[str, object]:
"fixture": fixture.name,
"fixture_provenance": "synthetic public data",
"raw_profile": {
"app_version": document["AppVersion"],
"name": raw["DisplayName"],
"type": raw["Type"],
"work_area_mm": [raw["Width"], raw["Height"]],
"require_frame_before_start": settings[
"RequireFrameBeforeStart"
],
"q_pulse_width_enabled": settings["Laser_QPulseWidthEnabled"],
"fiber_type_raw": settings["Laser_FiberType"],
"frequency_ui_range_khz": [
settings["Laser_MinFreq"], settings["Laser_MaxFreq"]
],
"cut_entry": {
"frequency": cut_entry["frequency"],
"QPulseWidth": cut_entry["QPulseWidth"],

View file

@ -7,6 +7,11 @@ from typing import Any
DRIVER_NAME = "PirateRayJCZNoDeviceDriver"
SYNTHETIC_PROFILE_ID = "synthetic-jpt-mopa-110-v1"
EVIDENCE_APP_VERSION = "2.1.03"
EVIDENCE_WORK_AREA_MM = (110, 110)
EVIDENCE_FIBER_TYPE_RAW = 3
EVIDENCE_FIBER_TYPE_UI_LABEL = "IPG_YLPM"
FREQUENCY_HZ_PER_KHZ = 1000
class JCZProfileImportError(ValueError):
@ -24,9 +29,10 @@ def _is_positive_number(value: Any) -> bool:
def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
"""Recognize the public synthetic profile without enabling encoding.
The returned process values remain serialized raw observations. This
function does not infer LightBurn project fields, units, machine limits,
lens correction, controller transport, or hardware compatibility.
The returned process values use the independently observed LightBurn
2.1.03 Hz/ns serialization contract. This function does not infer physical
machine identity, safe operating limits, lens correction, controller
transport, or hardware compatibility.
"""
if document.get("FixtureProvenance") != (
@ -35,6 +41,8 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
raise JCZProfileImportError("profile provenance is absent or unsupported")
if document.get("SyntheticProfileId") != SYNTHETIC_PROFILE_ID:
raise JCZProfileImportError("synthetic profile identity mismatch")
if document.get("AppVersion") != EVIDENCE_APP_VERSION:
raise JCZProfileImportError("unsupported LightBurn AppVersion")
devices = document.get("DeviceList")
if not isinstance(devices, list) or len(devices) != 1:
@ -47,12 +55,24 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
height = device.get("Height")
if not all(_is_positive_number(value) for value in (width, height)):
raise JCZProfileImportError("positive work-area dimensions are required")
if (width, height) != EVIDENCE_WORK_AREA_MM:
raise JCZProfileImportError("synthetic JPT profile must use 110 x 110 mm")
settings = device.get("Settings")
if not isinstance(settings, dict) or settings.get(
"RequireFrameBeforeStart"
) is not True:
raise JCZProfileImportError("RequireFrameBeforeStart must be explicitly true")
if settings.get("Laser_QPulseWidthEnabled") is not True:
raise JCZProfileImportError("Q-pulse-width control must be explicitly enabled")
if settings.get("Laser_FiberType") != EVIDENCE_FIBER_TYPE_RAW:
raise JCZProfileImportError("observed fiber-type code mismatch")
min_frequency = settings.get("Laser_MinFreq")
max_frequency = settings.get("Laser_MaxFreq")
if not all(
_is_positive_number(value) for value in (min_frequency, max_frequency)
) or min_frequency > max_frequency:
raise JCZProfileImportError("valid observed frequency range is required")
cut_list = device.get("CutList")
if not isinstance(cut_list, list) or not cut_list:
@ -70,17 +90,24 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
raise JCZProfileImportError(
"frequency and QPulseWidth must be positive raw values"
)
frequency_khz = frequency / FREQUENCY_HZ_PER_KHZ
if not min_frequency <= frequency_khz <= max_frequency:
raise JCZProfileImportError(
"cut-list frequency is outside the observed profile range"
)
preserved_entries.append(
{
"name": entry.get("Name", ""),
"frequency": {
"raw_value": frequency,
"unit_status": "serialized-unit-conversion-unverified",
"serialized_unit": "Hz",
"value_hz": frequency,
"value_khz": frequency_khz,
},
"q_pulse_width": {
"raw_value": pulse_width,
"ui_unit_contract": "nanoseconds",
"serialized_unit_conversion": "unverified",
"serialized_unit": "ns",
"value_ns": pulse_width,
},
}
)
@ -91,20 +118,31 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
"transport": None,
"source": {
"format": "LightBurn synthetic .lbdev",
"app_version": document["AppVersion"],
"provenance": document["FixtureProvenance"],
"backend": "JCZFiber",
},
"work_area_mm": [float(width), float(height)],
"safety": {"require_frame_before_start": True},
"observed_device_contract": {
"q_pulse_width_enabled": True,
"frequency_ui_range_khz": [min_frequency, max_frequency],
"frequency_serialized_unit": "Hz",
"q_pulse_width_serialized_unit": "ns",
"fiber_type_raw": EVIDENCE_FIBER_TYPE_RAW,
"fiber_type_ui_label": EVIDENCE_FIBER_TYPE_UI_LABEL,
"fiber_type_identity_status": "label-is-not-manufacturer-identity",
},
"process_metadata": {
"status": "preserved-raw-not-mapped",
"status": "normalized-offline-not-authorized",
"cut_list": preserved_entries,
},
"encoding_allowed": False,
"blockers": [
"synthetic profile is not physical machine identity",
"lens and correction profile are unverified",
"LightBurn project QPulseWidth serialization is unverified",
"machine-specific parameter limits are unverified",
"IPG_YLPM UI label is not manufacturer identity",
"observed profile ranges are not certified safe machine limits",
"image settings with absent QPulseWidth remain unresolved",
],
}

View file

@ -7,6 +7,19 @@ import xml.etree.ElementTree as ET
UNIT_CONTRACT = "frequency=Hz;QPulseWidth=ns"
UNIT_CONTRACT_APP_VERSION = "2.1.03"
UNIT_CONTRACT_EVIDENCE = {
"status": "operator-verified-ui-project-correlation",
"lightburn_app_version": UNIT_CONTRACT_APP_VERSION,
"frequency_serialized_unit": "Hz",
"frequency_ui_unit": "kHz",
"frequency_serialized_units_per_ui_unit": 1000,
"q_pulse_width_serialized_unit": "ns",
"q_pulse_width_ui_unit": "ns",
"analysis_ref": (
"docs/research/lightburn-control-machine-observation-2026-10-24.md"
),
}
class ProjectImportError(ValueError):
@ -44,9 +57,10 @@ def _layer_id(element: ET.Element) -> str | None:
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
"""Preserve observed XML fields and optionally normalize declared units.
The caller must supply an independently established serialized-unit contract.
No unit is inferred from magnitude. Missing pulse width is never defaulted.
Results are offline metadata, not executable Rayforge workflow settings.
The caller must opt into the independently established LightBurn 2.1.03
serialized-unit contract. No unit is inferred from magnitude. Missing pulse
width is never defaulted. Results are offline metadata, not executable
Rayforge workflow settings.
"""
if unit_contract not in (None, UNIT_CONTRACT):
raise ProjectImportError("unsupported serialized-unit contract")
@ -62,6 +76,12 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
raise ProjectImportError("invalid project XML") from exc
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
raise ProjectImportError("unsupported project format")
app_version = root.get("AppVersion")
if unit_contract is not None and app_version != UNIT_CONTRACT_APP_VERSION:
raise ProjectImportError(
"unit contract requires LightBurn AppVersion "
+ UNIT_CONTRACT_APP_VERSION
)
layers = []
layer_ids: set[str] = set()
for setting in root:
@ -102,7 +122,10 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
"normalized": normalized, "blockers": blockers})
if not layers:
raise ProjectImportError("project has no process settings")
return {"layers": layers, "unit_contract": unit_contract, "transport": None,
evidence = dict(UNIT_CONTRACT_EVIDENCE) if unit_contract else None
return {"layers": layers, "unit_contract": unit_contract,
"unit_contract_evidence": evidence,
"lightburn_app_version": app_version, "transport": None,
"encoding_allowed": False, "hardware_use_allowed": False,
"blockers": ["machine identity, correction and limits unverified",
"offline import seam is not wired to host workflow"]}

View file

@ -6,6 +6,8 @@ from collections.abc import Mapping
from copy import deepcopy
from typing import Any
from .project_import import UNIT_CONTRACT, UNIT_CONTRACT_EVIDENCE
METADATA_KEY = "pirateray_import"
@ -59,6 +61,8 @@ def stage_mopa_workflow_steps(
isinstance(item, str) and item for item in global_blockers
):
raise WorkflowImportError("global blockers are missing")
unit_contract = imported.get("unit_contract")
unit_contract_evidence = imported.get("unit_contract_evidence")
plans = []
step_identities: set[int] = set()
@ -86,6 +90,10 @@ def stage_mopa_workflow_steps(
apply_values = normalized is not None and not layer_blockers
frequency = pulse_width = None
if apply_values:
if unit_contract != UNIT_CONTRACT:
raise WorkflowImportError("normalized units lack the supported contract")
if unit_contract_evidence != UNIT_CONTRACT_EVIDENCE:
raise WorkflowImportError("normalized units lack exact evidence provenance")
if not callable(getattr(step, "set_frequency", None)) or not callable(
getattr(step, "set_pulse_width", None)
):
@ -114,6 +122,8 @@ def stage_mopa_workflow_steps(
"source_layer_id": layer_id,
"raw": deepcopy(layer.get("raw")),
"normalized": deepcopy(layer.get("normalized")),
"unit_contract": unit_contract,
"unit_contract_evidence": deepcopy(unit_contract_evidence),
"blockers": blockers,
"encoding_allowed": False,
"hardware_use_allowed": False,

View file

@ -3,33 +3,49 @@
"blockers": [
"synthetic profile is not physical machine identity",
"lens and correction profile are unverified",
"LightBurn project QPulseWidth serialization is unverified",
"machine-specific parameter limits are unverified"
"IPG_YLPM UI label is not manufacturer identity",
"observed profile ranges are not certified safe machine limits",
"image settings with absent QPulseWidth remain unresolved"
],
"driver": "PirateRayJCZNoDeviceDriver",
"encoding_allowed": false,
"observed_device_contract": {
"fiber_type_identity_status": "label-is-not-manufacturer-identity",
"fiber_type_raw": 3,
"fiber_type_ui_label": "IPG_YLPM",
"frequency_serialized_unit": "Hz",
"frequency_ui_range_khz": [
1,
4000
],
"q_pulse_width_enabled": true,
"q_pulse_width_serialized_unit": "ns"
},
"process_metadata": {
"cut_list": [
{
"frequency": {
"raw_value": 40000,
"unit_status": "serialized-unit-conversion-unverified"
"serialized_unit": "Hz",
"value_hz": 40000,
"value_khz": 40.0
},
"name": "Synthetic MOPA entry",
"q_pulse_width": {
"raw_value": 100,
"serialized_unit_conversion": "unverified",
"ui_unit_contract": "nanoseconds"
"serialized_unit": "ns",
"value_ns": 100
}
}
],
"status": "preserved-raw-not-mapped"
"status": "normalized-offline-not-authorized"
},
"profile_id": "synthetic-jpt-mopa-110-v1",
"safety": {
"require_frame_before_start": true
},
"source": {
"app_version": "2.1.03",
"backend": "JCZFiber",
"format": "LightBurn synthetic .lbdev",
"provenance": "synthetic public data; not a machine export"
@ -61,11 +77,18 @@
]
},
"raw_profile": {
"app_version": "2.1.03",
"cut_entry": {
"QPulseWidth": 100,
"frequency": 40000
},
"fiber_type_raw": 3,
"frequency_ui_range_khz": [
1,
4000
],
"name": "Synthetic JCZ MOPA",
"q_pulse_width_enabled": true,
"require_frame_before_start": true,
"type": "JCZFiber",
"work_area_mm": [

View file

@ -1,6 +1,7 @@
{
"FixtureProvenance": "synthetic public data; not a machine export",
"SyntheticProfileId": "synthetic-jpt-mopa-110-v1",
"AppVersion": "2.1.03",
"DeviceList": [
{
"DisplayName": "Synthetic JCZ MOPA",
@ -10,7 +11,11 @@
"Height": 110,
"HomeOnStartup": false,
"Settings": {
"RequireFrameBeforeStart": true
"RequireFrameBeforeStart": true,
"Laser_QPulseWidthEnabled": true,
"Laser_FiberType": 3,
"Laser_MinFreq": 1,
"Laser_MaxFreq": 4000
},
"CutList": [
{

View file

@ -4,12 +4,14 @@ 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 (
ProjectImportError, UNIT_CONTRACT, import_project_mopa,
ProjectImportError, UNIT_CONTRACT, UNIT_CONTRACT_EVIDENCE,
import_project_mopa,
)
def project(fields, tag="CutSetting", kind="Scan", layer_id="0"):
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
return (f'<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
f'<{tag} type="{kind}">'
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
@ -22,6 +24,8 @@ class ProjectMopaTests(unittest.TestCase):
self.assertEqual(result["layers"][0]["normalized"], {
"frequency_hz": 125000, "frequency_khz": 125, "pulse_width_ns": 25,
})
self.assertEqual(result["unit_contract_evidence"], UNIT_CONTRACT_EVIDENCE)
self.assertEqual(result["lightburn_app_version"], "2.1.03")
self.assertFalse(result["encoding_allowed"])
self.assertFalse(result["hardware_use_allowed"])
self.assertIsNone(result["transport"])
@ -36,6 +40,23 @@ class ProjectMopaTests(unittest.TestCase):
with self.assertRaises(ProjectImportError):
import_project_mopa(project(FIELDS), unit_contract="kHz/us")
def test_contract_is_version_bound_and_matches_verified_ui_correlation(self):
verified = '<frequency Value="1000000"/><QPulseWidth Value="10"/>'
layer = import_project_mopa(
project(verified), unit_contract=UNIT_CONTRACT
)["layers"][0]
self.assertEqual(layer["normalized"], {
"frequency_hz": 1000000,
"frequency_khz": 1000,
"pulse_width_ns": 10,
})
for app_version in ("2.0.00", ""):
data = project(verified).replace(b'AppVersion="2.1.03"',
f'AppVersion="{app_version}"'.encode())
with self.subTest(app_version=app_version):
with self.assertRaisesRegex(ProjectImportError, "AppVersion 2.1.03"):
import_project_mopa(data, unit_contract=UNIT_CONTRACT)
def test_missing_fields_remain_absent(self):
for fields in ('<frequency Value="125000"/>', '<QPulseWidth Value="25"/>', ''):
with self.subTest(fields=fields):
@ -73,7 +94,7 @@ class ProjectMopaTests(unittest.TestCase):
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 = ('<LightBurnProject FormatVersion="1">'
duplicate = ('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode()
@ -81,7 +102,7 @@ class ProjectMopaTests(unittest.TestCase):
import_project_mopa(duplicate)
def test_multiple_layers_preserve_order_and_missing_pulse(self):
data = ('<LightBurnProject FormatVersion="1">'
data = ('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
'<CutSetting type="Cut"><index Value="0"/>'+FIELDS+'</CutSetting>'
'<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>'
'</CutSetting_Img></LightBurnProject>').encode()

View file

@ -30,7 +30,8 @@ class Step:
def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"):
data = (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
data = (f'<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
f'<{tag} type="{kind}">'
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
return import_project_mopa(data, unit_contract=UNIT_CONTRACT)
@ -50,6 +51,10 @@ class WorkflowImportTests(unittest.TestCase):
self.assertEqual(gate["status"], "staged-review-required")
self.assertFalse(gate["encoding_allowed"])
self.assertTrue(gate["blockers"])
self.assertEqual(gate["unit_contract"], UNIT_CONTRACT)
self.assertEqual(
gate["unit_contract_evidence"], imported(FIELDS)["unit_contract_evidence"]
)
def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
for result in (
@ -74,7 +79,7 @@ class WorkflowImportTests(unittest.TestCase):
stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
two_layers = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
@ -84,7 +89,7 @@ class WorkflowImportTests(unittest.TestCase):
def test_preflight_failure_does_not_mutate_any_step(self):
result = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
@ -94,9 +99,19 @@ class WorkflowImportTests(unittest.TestCase):
self.assertTrue(first.visible)
self.assertEqual((first.frequency, first.pulse_width), (1, 2))
def test_tampered_unit_evidence_is_rejected_before_mutation(self):
result = imported(FIELDS)
result["unit_contract_evidence"]["frequency_ui_unit"] = "MHz"
step = Step()
with self.assertRaisesRegex(WorkflowImportError, "exact evidence provenance"):
stage_mopa_workflow_steps(result, {"3": step})
self.assertTrue(step.visible)
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
self.assertEqual(step.extra, {"preserved": True})
def test_setter_failure_rolls_back_every_step(self):
result = import_project_mopa(
('<LightBurnProject FormatVersion="1">'
('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)

View file

@ -54,11 +54,20 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
self.assertEqual("PirateRayJCZNoDeviceDriver", seam["driver"])
self.assertIsNone(seam["transport"])
self.assertFalse(seam["encoding_allowed"])
self.assertEqual("preserved-raw-not-mapped", seam["process_metadata"]["status"])
self.assertEqual("normalized-offline-not-authorized", seam["process_metadata"]["status"])
entry = seam["process_metadata"]["cut_list"][0]
self.assertEqual(40000, entry["frequency"]["raw_value"])
self.assertEqual(40000, entry["frequency"]["value_hz"])
self.assertEqual(40, entry["frequency"]["value_khz"])
self.assertEqual(100, entry["q_pulse_width"]["raw_value"])
self.assertEqual("unverified", entry["q_pulse_width"]["serialized_unit_conversion"])
self.assertEqual(100, entry["q_pulse_width"]["value_ns"])
contract = seam["observed_device_contract"]
self.assertEqual([1, 4000], contract["frequency_ui_range_khz"])
self.assertEqual("IPG_YLPM", contract["fiber_type_ui_label"])
self.assertEqual(
"label-is-not-manufacturer-identity",
contract["fiber_type_identity_status"],
)
def test_addon_seam_rejects_wrong_backend_and_missing_frame_gate(self):
source = json.loads(SOURCE.read_text(encoding="utf-8"))
@ -86,6 +95,32 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
with self.assertRaises(JCZProfileImportError):
import_lightburn_jcz_profile(ambiguous_frequency)
for frequency in (500, 4_001_000):
outside_range = deepcopy(source)
outside_range["DeviceList"][0]["CutList"][0]["frequency"] = frequency
with self.subTest(frequency=frequency):
with self.assertRaisesRegex(JCZProfileImportError, "outside"):
import_lightburn_jcz_profile(outside_range)
def test_observed_profile_contract_fails_closed_on_mismatch(self):
source = json.loads(SOURCE.read_text(encoding="utf-8"))
mutations = (
("wrong version", lambda doc: doc.update(AppVersion="2.2.00")),
("wrong width", lambda doc: doc["DeviceList"][0].update(Width=300)),
("q pulse disabled", lambda doc: doc["DeviceList"][0]["Settings"].update(
Laser_QPulseWidthEnabled=False)),
("wrong fiber code", lambda doc: doc["DeviceList"][0]["Settings"].update(
Laser_FiberType=2)),
("reversed frequency range", lambda doc: doc["DeviceList"][0]["Settings"].update(
Laser_MinFreq=4000, Laser_MaxFreq=1)),
)
for label, mutate in mutations:
candidate = deepcopy(source)
mutate(candidate)
with self.subTest(label=label):
with self.assertRaises(JCZProfileImportError):
import_lightburn_jcz_profile(candidate)
if __name__ == "__main__":
unittest.main()