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 *.py text eol=lf
*.json text eol=lf *.json text eol=lf
*.jsonl text eol=lf *.jsonl text eol=lf
*.webmanifest text eol=lf
*.xml text eol=lf
*.yaml text eol=lf *.yaml text eol=lf
*.yml text eol=lf *.yml text eol=lf
*.png binary *.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). and [control-machine observation](docs/research/lightburn-control-machine-observation-2026-10-24.md).
Three operator-verified projects now establish project-level `frequency` and 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). [verified-project analysis](docs/research/lightburn-verified-projects-2026-10-24.md).
Missing pulse width still requires explicit resolution before executable import. 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 1. A Windows artifact for the pinned Rayforge revision must be produced on a
reviewed build runner, verified without execution, and pass the manual reviewed build runner, verified without execution, and pass the manual
no-device desktop checklist. no-device desktop checklist.
2. Promote only complete vector MOPA imports after an explicit unit contract, 2. The known-working private JPT `.lbrn2` serialization and unit-contract gates
absent-pulse-width policy and exact machine-context validation. The private are met for LightBurn 2.1.03 vector settings. Promotion still requires
serialization-evidence gate itself is complete. explicit resolution of absent pulse width, image equivalence and complete
machine-context validation.
Raw projects, screenshots containing identifiers, machine data and build Raw projects, screenshots containing identifiers, machine data and build
binaries stay out of the public repository. 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 An offline project MOPA seam, disabled-output Rayforge workflow bridge and
synthetic tests now exist; see synthetic tests now exist; see
`docs/research/lightburn-project-mopa-import.md`. It preserves both process `docs/research/lightburn-project-mopa-import.md`. It preserves both process
element types and requires an explicit unit contract for normalization. The element types and applies the evidence-bound Hz/ns contract only to LightBurn
bridge binds source layer IDs, uses the host MOPA setters, serializes review 2.1.03. The bridge binds source layer IDs, uses the host MOPA setters,
metadata and disables every staged step. Document preflight presents those serializes exact unit provenance and disables every staged step. Document
blockers and the offline driver rejects them before buffer preparation. It preflight presents the remaining blockers and the offline driver rejects them
never authorizes encoding. The next import task is GTK presentation plus an before buffer preparation. It never authorizes encoding. The next import task
independently established unit contract; absent pulse width and image is packaged GTK presentation; absent pulse width and image equivalence remain
equivalence remain blockers. blockers.
If a Windows artifact is available, keep the machine disconnected and run, in If a Windows artifact is available, keep the machine disconnected and run, in
order: order:
@ -108,8 +109,9 @@ order:
If no artifact is available, send the external-builder handoff in If no artifact is available, send the external-builder handoff in
`docs/research/m1-no-device-acceptance.md`; do not start another research `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 branch or install a build environment here. The evidence-backed vector MOPA
0003 is also outstanding. 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 The M3 protocol can also be reviewed offline, but its
operation allowlist must stay empty until exact read-only request evidence is operation allowlist must stay empty until exact read-only request evidence is
available. ADR 0002 is accepted for the offline architecture. Until the 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 external encoder deterministically packs controller-shaped command records
matching the pinned BalorMK mock values, while retaining no transport. matching the pinned BalorMK mock values, while retaining no transport.
- [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; the external addon now maps complete
workflow and pulse-width representation still lacks fixture evidence. 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): - [Graphical workflow model spike](../research/rayforge-graphical-workflow-spike.md):
Rayforge's generated settings contract exposes frequency and pulse width, Rayforge's generated settings contract exposes frequency and pulse width,
preserves them through step serialization and supplies preview Ops without 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 Three operator-verified private projects subsequently established the
project-level `frequency` and `QPulseWidth` serialization needed to implement project-level `frequency` and `QPulseWidth` serialization needed to implement
the disabled-output import seam. Together, the evidence satisfies the host the disabled-output import seam. The running LightBurn 2.1.03 UI further binds
architecture decision. It does not authorize hardware access or claim that the those values to raw Hz/display kHz and direct ns. Together, the evidence
packaged Windows interaction and import-promotion release gates have passed. 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 LightBurn's official galvo cut-setting documentation presents frequency in
kHz and Q-Pulse Width in nanoseconds. The larger integers stored by the export 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 show that the serialized representation cannot simply be copied into a UI
field without an explicit conversion contract. The exact field name and unit field without an explicit conversion contract. Subsequent operator-verified
are now evidenced for a device-library export, but the representation inside LightBurn 2.1.03 project/UI correlation establishes raw Hz to display kHz and
a `.lbrn2` project remains unconfirmed. 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 These observed recipe values and profile fields are **not certified hardware
limits or safe operating parameters**. They must not be used to probe the 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; 1. add evidence-backed JCZ device-profile recognition;
2. preserve source units and raw values at the import boundary; 2. preserve source units and raw values at the import boundary;
3. map frequency and `QPulseWidth` only after a real `.lbrn2` fixture confirms 3. bind the now-confirmed LightBurn 2.1.03 Hz/ns contract to exact provenance;
project serialization;
4. validate imported values against a separately verified machine profile; 4. validate imported values against a separately verified machine profile;
5. fail closed when units, profile identity or ranges are unknown. 5. fail closed when units, profile identity or ranges are unknown.
## Remaining collection gaps ## Remaining collection gaps
The exports settle the exact LightBurn release and the two named device 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; - exterior machine, source, controller and installed-lens photographs;
- mounted-lens, connection, startup and interlock notes; - mounted-lens, connection, startup and interlock notes;
- the stock driver's identity and version on the control computer. - 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 removed from this acquisition before analysis or commit. Their originals
were unchanged; no project content was analyzed or retained as evidence. were unchanged; no project content was analyzed or retained as evidence.
This collection closes the missing closed-application settings snapshot. It This collection closes the missing closed-application settings snapshot. The
does **not** close the known-working JPT project gate: project-level frequency gate was subsequently closed by three separately supplied, operator-verified
and pulse-width serialization still require independently identified, projects plus the running LightBurn 2.1.03 UI. The excluded candidates remain
operator-verified project evidence. Do not derive an executable import mapping unverified and are still not evidence. The resulting vector import mapping
from these settings or the excluded test projects. stays disabled and review-gated; it is not executable machine authorization.
The reviewed Windows artifact and manual no-device desktop acceptance also The reviewed Windows artifact and manual no-device desktop acceptance also
remain outstanding. No controller, USB endpoint, framing, motion or laser 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. It preserves raw `frequency`, `QPulseWidth`, layer order and image dither mode.
The synthetic unit tests contain no private geometry or complete recipes. The synthetic unit tests contain no private geometry or complete recipes.
Normalization requires the caller to declare the serialized-unit contract Normalization requires the caller to opt into the evidence-backed serialized
`frequency=Hz;QPulseWidth=ns`. This is an explicit input, not an inference from unit contract `frequency=Hz;QPulseWidth=ns`. The contract is restricted to
numeric magnitude or an authorization claim. With that contract, frequency is LightBurn `AppVersion="2.1.03"`; a missing or different version fails closed.
represented in Hz and kHz (division by 1000), and pulse width in ns. Without It comes from correlating an operator-verified project's raw
it, only raw values are returned. The seam does not establish the contract on `frequency=1000000` and `QPulseWidth=10` with LightBurn's visible `1000 kHz`
behalf of the caller or read machine limits from these values. 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, Absent fields remain absent, never zero or inherited defaults. Malformed,
non-finite, non-positive, duplicate and excessively large values are rejected. 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 offline metadata seam. The companion workflow bridge matches the normalized
LightBurn layer index to Rayforge's source-segment layer ID and stages complete 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 vector MOPA values through the host's setters. Every staged step is disabled
through `Step.set_visible(False)`, and its raw values, normalized values, through `Step.set_visible(False)`, and its raw values, normalized values, exact
review status and blockers are saved in the serializable `Step.extra` data. 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 Image settings and incomplete settings remain disabled without applying MOPA
values. Multi-step staging is preflighted and rolled back on setter failure. values. Multi-step staging is preflighted and rolled back on setter failure.
The bridge deliberately has no function that enables output or removes a The bridge deliberately has no function that enables output or removes a
blocker. Host UI and preflight integration must still establish serialized blocker. Host UI and preflight integration must still resolve absent pulse
units from independent evidence, resolve absent pulse width and validate exact width and validate exact machine, lens, correction and parameter limits before
machine, lens, correction and parameter limits before a separate reviewed a separate reviewed promotion path can exist. Image-layer equivalence remains
promotion path can exist. Rayforge's relevant Step and LaserStep files are now unverified. Rayforge's relevant Step and LaserStep files are included in the
included in the pinned upstream contract manifest. pinned upstream contract manifest.
The addon now also exposes a transport-free document preflight report with The addon now also exposes a transport-free document preflight report with
stable issue codes and human-readable blocker messages. The offline driver 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 ## Gaps that must not be papered over
1. Three known-working private galvo projects establish project-level 1. Three known-working private galvo projects and the running LightBurn 2.1.03
`QPulseWidth` serialization. Omission/default behavior still requires an UI establish the vector Hz/ns serialization contract. Missing
explicit policy before promotion to executable output. `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 2. The current Windows environment has not launched the pinned Rayforge GTK
application; the headless UI contract is not a human interaction test. application; the headless UI contract is not a human interaction test.
3. `RequireFrameBeforeStart` is observed in a LightBurn device export and 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 ## 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 - Human interaction results from the packaged build must be recorded before
M1 can be called a packaged desktop checkpoint. M1 can be called a packaged desktop checkpoint.
- The three known-working private projects establish serialized MOPA fields. - The three known-working private projects establish serialized MOPA fields.
Promotion from disabled staging still requires the explicit unit contract, Promotion from disabled staging still requires an absent-pulse-width policy,
absent-pulse-width policy and exact machine-context validation already image equivalence and exact machine-context validation already enforced by
enforced by preflight. preflight.
- Physical controller behavior remains outside M1. - 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; including speed, power, passes, offsets, raster interval/angle and dither;
- `.lbdev` device profiles have a separate importer; - `.lbdev` device profiles have a separate importer;
- laser steps and recipes serialize `frequency` in Hz; the laser-step UI - laser steps and recipes serialize `frequency` in Hz; the laser-step UI
describes `pulse_width` as nanoseconds, while the Ruida encoder calls the describes `pulse_width` as nanoseconds. Later LightBurn 2.1.03 evidence binds
same Ops value microseconds, so the unit contract requires clarification; 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; - the neutral `Ops` command set contains frequency and pulse-width commands;
- binary rather than G-code drivers are supported through an `OpsEncoder`; - binary rather than G-code drivers are supported through an `OpsEncoder`;
- driver state, cancel/hold, discovery, machine profiles, simulator and - 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 ## Finding 4: LightBurn migration is mostly an extension problem
Rayforge's existing LightBurn import removes the need to create a general 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 `.lbrn2` parser. The private known-working fixture matrix now identifies the
matrix and identify which galvo-specific fields are currently dropped. 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: 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 - Port-state commands are deliberately omitted because their machine-specific
bit semantics can arm output and have not yet passed the hardware gate. 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. - The 110 mm coordinate mapping is synthetic and has no lens correction.
- Machine-specific bounds and the pulse-width unit contract still require - The LightBurn 2.1.03 import unit contract is now evidenced, but certified
authoritative evidence; they are not guessed here. machine-specific bounds and physical behavior remain unknown and unguessed.
- No hardware compatibility or safe marking behavior is established. - No hardware compatibility or safe marking behavior is established.
## Decision impact ## 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 The external addon now supplies the narrow profile seam missing from Rayforge
core. It recognizes only the explicit public synthetic profile identity, core. It recognizes only the explicit public synthetic profile identity,
requires `JCZFiber`, positive work-area dimensions and requires LightBurn 2.1.03, `JCZFiber`, the observed 110 x 110 mm work area,
`RequireFrameBeforeStart: true`, and associates the result only with Q-pulse-width enablement, fiber-type code 3, a valid 1-4000 kHz profile range,
`PirateRayJCZNoDeviceDriver`. Frequency and `QPulseWidth` remain raw and `RequireFrameBeforeStart: true`. It associates the result only with
observations with unverified serialized-unit conversion. Encoding remains `PirateRayJCZNoDeviceDriver`. Cut-list frequency is normalized from serialized
blocked because the physical profile, lens/correction match, project Hz to display kHz and `QPulseWidth` remains ns. Encoding stays blocked because
serialization and machine-specific limits are not yet verified. physical identity, lens/correction match, certified limits and promotion
review are not verified.
The checked result is The checked result is
`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the `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 ## Next seam
The next extension is offline controller-list buffering and explicit safe Offline controller-list buffering and explicit state ordering are complete.
state ordering. Cut-list recipes and project-layer `QPulseWidth` mapping remain The next software gate is packaged Windows GTK interaction. Image-layer
gated until a known-working `.lbrn2` fixture establishes project serialization. equivalence and absent `QPulseWidth` remain blocked rather than defaulted.
No USB enumeration, controller command, motion, framing or emission occurred. 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 speed and one pass. LightBurn's observed `frequency` and
`overrideFrequency` elements also survive in the source asset's raw metadata. `overrideFrequency` elements also survive in the source asset's raw metadata.
However, the importer does not map frequency into the workflow layer settings. The pinned upstream importer does not map frequency into workflow settings; it
It maps only power, speed and passes from this fixture. The inspected Rayforge maps only power, speed and passes from this fixture. The external addon now
LightBurn project fixtures and importer contain no evidence-backed mapping for supplies that narrow mapping for LightBurn 2.1.03. Subsequent operator-verified
a MOPA pulse-width field. A later private LightBurn device-library export evidence correlates raw `frequency=1000000` and `QPulseWidth=10` with visible
establishes the field name `QPulseWidth` and its nanosecond UI contract, but it `1000 kHz` and `10 ns`, establishing the version-bound Hz/ns serialization
does not establish how that value is serialized in a `.lbrn2` project. contract. The staged job nevertheless remains unsafe for JCZ encoding because
Consequently, the imported job is explicitly classified as unsafe for JCZ machine, lens, correction and promotion review are still unresolved.
encoding rather than being completed with a guessed mapping.
Two runs produced identical output with SHA-256 Two runs produced identical output with SHA-256
`9ebce7b5b3388abfeed024e25f8f46001260bf814da19808bfc83fb6554fc26e`. `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 uses `QPulseWidth`; official LightBurn documentation defines Q-pulse width
in nanoseconds. See the in nanoseconds. See the
[device-export analysis](lightburn-device-exports-2026-10-19.md). [device-export analysis](lightburn-device-exports-2026-10-19.md).
- **Unknown:** the exact `QPulseWidth` representation and omission/default - **Observed after this spike:** three known-working galvo projects use the
behavior in a known-working galvo `.lbrn2` project. 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 - **Not inferred:** no pulse-width tag, unit, default or machine limit was
invented for the public fixture. invented for the public fixture.
## Decision impact ## Decision impact
Rayforge's existing LightBurn importer is useful for ordinary vector geometry Rayforge's existing LightBurn importer remains useful for ordinary vector
and common process settings, but it does not yet satisfy M1's MOPA parameter geometry and common process settings. The external addon now implements the
survival requirement. The narrowest likely extension is an importer mapping narrow frequency/`QPulseWidth` mapping with a version- and evidence-bound unit
for frequency plus `QPulseWidth`, followed by an explicit unit conversion and contract, stages values on disabled workflow steps, and retains all blockers.
validation gate. A private known-working LightBurn project is still needed to Image equivalence, absent pulse width and physical compatibility remain gated.
establish project serialization before implementing that mapping.
No USB enumeration, controller command, motion or emission was attempted. 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; - status, interlock, error, abort, and recovery behavior;
- whether an apparently harmless query can move a galvo or arm emission; - whether an apparently harmless query can move a galvo or arm emission;
- the effective pulse-width default for settings that omit `QPulseWidth`. - 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 The later LightBurn export collection now establishes version `2.1.03`, a
110 x 110 mm `JCZFiber`/`LibUSB` JPT profile, and the `QPulseWidth` device 110 x 110 mm `JCZFiber`/`LibUSB` JPT profile, and the `QPulseWidth` device
library field. See the library field. See the
[device-export analysis](research/lightburn-device-exports-2026-10-19.md). [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 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. 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. transport remain outside M1.
[The synthetic LightBurn MOPA import spike](research/rayforge-lightburn-mopa-import-spike.md) [The synthetic LightBurn MOPA import spike](research/rayforge-lightburn-mopa-import-spike.md)
confirms that Rayforge preserves geometry, power, speed and passes. Three later confirms that Rayforge preserves geometry, power, speed and passes. Three
operator-verified projects establish project-level `frequency` and operator-verified LightBurn 2.1.03 projects plus the running UI establish the
`QPulseWidth` fields. The addon now stages complete vector settings into serialized Hz/display-kHz relationship and direct ns `QPulseWidth` contract.
disabled Rayforge steps and rejects them at preflight until units, defaults and The external addon maps complete vector settings into disabled workflow steps
machine context are explicitly approved. 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 ADR 0002 accepts the Rayforge-addon architecture with BalorMK as the pinned
behavioral oracle. Packaged Windows interaction is a release gate, not an open 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 1. produce the pinned Windows Rayforge checkpoint on a reviewed external build
runner, verify the transferred artifact set and complete the no-device runner, verify the transferred artifact set and complete the no-device
desktop interaction checklist; desktop interaction checklist;
2. promote complete vector MOPA imports only after recording the explicit unit 2. promote complete vector MOPA imports only after an absent-pulse-width
contract, absent-pulse-width policy and exact machine-context validation. policy, image equivalence and exact machine-context validation.
Exterior photographs, additional screenshots and speculative protocol research Exterior photographs, additional screenshots and speculative protocol research
are not M1 blockers. Hardware work remains separately gated by M3. 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. profile model. The fixture and regression test remain transport-free.
Item 3 now has a minimal external-addon seam: it recognizes only the synthetic 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 LightBurn 2.1.03 `JCZFiber` fixture with explicit provenance, the observed
the no-device JCZ driver, preserves process values as raw observations with 110 x 110 mm/Q-pulse/frequency/frame contract, and binds it to the no-device
unit uncertainty, and keeps encoding disabled until the unit/default and exact JCZ driver. Vector project values use the evidence-backed Hz/ns mapping, while
machine-context review passes. encoding remains disabled pending physical compatibility and review.
Item 4 now covers transport-free fixed-size list buffering and an explicit Item 4 now covers transport-free fixed-size list buffering and an explicit
state-before-motion order derived from pinned BalorMK. Port commands, upload, 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: 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 - packaged Windows/GTK interaction is blocked until a reviewed Rayforge
runtime with PyGObject and MSYS2/UCRT64 is available; the repository build runtime with PyGObject and MSYS2/UCRT64 is available; the repository build
entry point fetches pinned source but never installs that runtime; entry point fetches pinned source but never installs that runtime;
- three operator-verified projects establish project-level `frequency` and - three operator-verified projects establish project-level `frequency` and
`QPulseWidth` serialization, and the offline bridge stages complete vector `QPulseWidth` serialization, and the offline bridge stages complete vector
settings with output disabled; promotion still requires an explicit unit settings with output disabled; promotion still requires an
contract, absent-pulse-width policy and exact machine-context validation; absent-pulse-width policy, image equivalence and exact machine-context
validation;
- graphical workflow, lens correction and controller behavior remain unproven - graphical workflow, lens correction and controller behavior remain unproven
at the physical boundary and belong to later gated milestones. 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": fixture.name,
"fixture_provenance": "synthetic public data", "fixture_provenance": "synthetic public data",
"raw_profile": { "raw_profile": {
"app_version": document["AppVersion"],
"name": raw["DisplayName"], "name": raw["DisplayName"],
"type": raw["Type"], "type": raw["Type"],
"work_area_mm": [raw["Width"], raw["Height"]], "work_area_mm": [raw["Width"], raw["Height"]],
"require_frame_before_start": settings[ "require_frame_before_start": settings[
"RequireFrameBeforeStart" "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": { "cut_entry": {
"frequency": cut_entry["frequency"], "frequency": cut_entry["frequency"],
"QPulseWidth": cut_entry["QPulseWidth"], "QPulseWidth": cut_entry["QPulseWidth"],

View file

@ -7,6 +7,11 @@ from typing import Any
DRIVER_NAME = "PirateRayJCZNoDeviceDriver" DRIVER_NAME = "PirateRayJCZNoDeviceDriver"
SYNTHETIC_PROFILE_ID = "synthetic-jpt-mopa-110-v1" 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): 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]: def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
"""Recognize the public synthetic profile without enabling encoding. """Recognize the public synthetic profile without enabling encoding.
The returned process values remain serialized raw observations. This The returned process values use the independently observed LightBurn
function does not infer LightBurn project fields, units, machine limits, 2.1.03 Hz/ns serialization contract. This function does not infer physical
lens correction, controller transport, or hardware compatibility. machine identity, safe operating limits, lens correction, controller
transport, or hardware compatibility.
""" """
if document.get("FixtureProvenance") != ( 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") raise JCZProfileImportError("profile provenance is absent or unsupported")
if document.get("SyntheticProfileId") != SYNTHETIC_PROFILE_ID: if document.get("SyntheticProfileId") != SYNTHETIC_PROFILE_ID:
raise JCZProfileImportError("synthetic profile identity mismatch") raise JCZProfileImportError("synthetic profile identity mismatch")
if document.get("AppVersion") != EVIDENCE_APP_VERSION:
raise JCZProfileImportError("unsupported LightBurn AppVersion")
devices = document.get("DeviceList") devices = document.get("DeviceList")
if not isinstance(devices, list) or len(devices) != 1: 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") height = device.get("Height")
if not all(_is_positive_number(value) for value in (width, height)): if not all(_is_positive_number(value) for value in (width, height)):
raise JCZProfileImportError("positive work-area dimensions are required") 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") settings = device.get("Settings")
if not isinstance(settings, dict) or settings.get( if not isinstance(settings, dict) or settings.get(
"RequireFrameBeforeStart" "RequireFrameBeforeStart"
) is not True: ) is not True:
raise JCZProfileImportError("RequireFrameBeforeStart must be explicitly 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") cut_list = device.get("CutList")
if not isinstance(cut_list, list) or not cut_list: 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( raise JCZProfileImportError(
"frequency and QPulseWidth must be positive raw values" "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( preserved_entries.append(
{ {
"name": entry.get("Name", ""), "name": entry.get("Name", ""),
"frequency": { "frequency": {
"raw_value": frequency, "raw_value": frequency,
"unit_status": "serialized-unit-conversion-unverified", "serialized_unit": "Hz",
"value_hz": frequency,
"value_khz": frequency_khz,
}, },
"q_pulse_width": { "q_pulse_width": {
"raw_value": pulse_width, "raw_value": pulse_width,
"ui_unit_contract": "nanoseconds", "serialized_unit": "ns",
"serialized_unit_conversion": "unverified", "value_ns": pulse_width,
}, },
} }
) )
@ -91,20 +118,31 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
"transport": None, "transport": None,
"source": { "source": {
"format": "LightBurn synthetic .lbdev", "format": "LightBurn synthetic .lbdev",
"app_version": document["AppVersion"],
"provenance": document["FixtureProvenance"], "provenance": document["FixtureProvenance"],
"backend": "JCZFiber", "backend": "JCZFiber",
}, },
"work_area_mm": [float(width), float(height)], "work_area_mm": [float(width), float(height)],
"safety": {"require_frame_before_start": True}, "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": { "process_metadata": {
"status": "preserved-raw-not-mapped", "status": "normalized-offline-not-authorized",
"cut_list": preserved_entries, "cut_list": preserved_entries,
}, },
"encoding_allowed": False, "encoding_allowed": False,
"blockers": [ "blockers": [
"synthetic profile is not physical machine identity", "synthetic profile is not physical machine identity",
"lens and correction profile are unverified", "lens and correction profile are unverified",
"LightBurn project QPulseWidth serialization is unverified", "IPG_YLPM UI label is not manufacturer identity",
"machine-specific parameter limits are unverified", "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 = "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): 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: def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
"""Preserve observed XML fields and optionally normalize declared units. """Preserve observed XML fields and optionally normalize declared units.
The caller must supply an independently established serialized-unit contract. The caller must opt into the independently established LightBurn 2.1.03
No unit is inferred from magnitude. Missing pulse width is never defaulted. serialized-unit contract. No unit is inferred from magnitude. Missing pulse
Results are offline metadata, not executable Rayforge workflow settings. width is never defaulted. Results are offline metadata, not executable
Rayforge workflow settings.
""" """
if unit_contract not in (None, UNIT_CONTRACT): if unit_contract not in (None, UNIT_CONTRACT):
raise ProjectImportError("unsupported serialized-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 raise ProjectImportError("invalid project XML") from exc
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1": if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
raise ProjectImportError("unsupported project format") 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 = [] layers = []
layer_ids: set[str] = set() layer_ids: set[str] = set()
for setting in root: for setting in root:
@ -102,7 +122,10 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
"normalized": normalized, "blockers": blockers}) "normalized": normalized, "blockers": blockers})
if not layers: if not layers:
raise ProjectImportError("project has no process settings") 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, "encoding_allowed": False, "hardware_use_allowed": False,
"blockers": ["machine identity, correction and limits unverified", "blockers": ["machine identity, correction and limits unverified",
"offline import seam is not wired to host workflow"]} "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 copy import deepcopy
from typing import Any from typing import Any
from .project_import import UNIT_CONTRACT, UNIT_CONTRACT_EVIDENCE
METADATA_KEY = "pirateray_import" METADATA_KEY = "pirateray_import"
@ -59,6 +61,8 @@ def stage_mopa_workflow_steps(
isinstance(item, str) and item for item in global_blockers isinstance(item, str) and item for item in global_blockers
): ):
raise WorkflowImportError("global blockers are missing") raise WorkflowImportError("global blockers are missing")
unit_contract = imported.get("unit_contract")
unit_contract_evidence = imported.get("unit_contract_evidence")
plans = [] plans = []
step_identities: set[int] = set() step_identities: set[int] = set()
@ -86,6 +90,10 @@ def stage_mopa_workflow_steps(
apply_values = normalized is not None and not layer_blockers apply_values = normalized is not None and not layer_blockers
frequency = pulse_width = None frequency = pulse_width = None
if apply_values: 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( if not callable(getattr(step, "set_frequency", None)) or not callable(
getattr(step, "set_pulse_width", None) getattr(step, "set_pulse_width", None)
): ):
@ -114,6 +122,8 @@ def stage_mopa_workflow_steps(
"source_layer_id": layer_id, "source_layer_id": layer_id,
"raw": deepcopy(layer.get("raw")), "raw": deepcopy(layer.get("raw")),
"normalized": deepcopy(layer.get("normalized")), "normalized": deepcopy(layer.get("normalized")),
"unit_contract": unit_contract,
"unit_contract_evidence": deepcopy(unit_contract_evidence),
"blockers": blockers, "blockers": blockers,
"encoding_allowed": False, "encoding_allowed": False,
"hardware_use_allowed": False, "hardware_use_allowed": False,

View file

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

View file

@ -1,6 +1,7 @@
{ {
"FixtureProvenance": "synthetic public data; not a machine export", "FixtureProvenance": "synthetic public data; not a machine export",
"SyntheticProfileId": "synthetic-jpt-mopa-110-v1", "SyntheticProfileId": "synthetic-jpt-mopa-110-v1",
"AppVersion": "2.1.03",
"DeviceList": [ "DeviceList": [
{ {
"DisplayName": "Synthetic JCZ MOPA", "DisplayName": "Synthetic JCZ MOPA",
@ -10,7 +11,11 @@
"Height": 110, "Height": 110,
"HomeOnStartup": false, "HomeOnStartup": false,
"Settings": { "Settings": {
"RequireFrameBeforeStart": true "RequireFrameBeforeStart": true,
"Laser_QPulseWidthEnabled": true,
"Laser_FiberType": 3,
"Laser_MinFreq": 1,
"Laser_MaxFreq": 4000
}, },
"CutList": [ "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")) sys.path.insert(0, str(Path(__file__).parents[1] / "spikes/rayforge-jcz-no-device-addon"))
from pirate_ray_jcz.project_import import ( 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"): 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() f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
@ -22,6 +24,8 @@ class ProjectMopaTests(unittest.TestCase):
self.assertEqual(result["layers"][0]["normalized"], { self.assertEqual(result["layers"][0]["normalized"], {
"frequency_hz": 125000, "frequency_khz": 125, "pulse_width_ns": 25, "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["encoding_allowed"])
self.assertFalse(result["hardware_use_allowed"]) self.assertFalse(result["hardware_use_allowed"])
self.assertIsNone(result["transport"]) self.assertIsNone(result["transport"])
@ -36,6 +40,23 @@ class ProjectMopaTests(unittest.TestCase):
with self.assertRaises(ProjectImportError): with self.assertRaises(ProjectImportError):
import_project_mopa(project(FIELDS), unit_contract="kHz/us") 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): def test_missing_fields_remain_absent(self):
for fields in ('<frequency Value="125000"/>', '<QPulseWidth Value="25"/>', ''): for fields in ('<frequency Value="125000"/>', '<QPulseWidth Value="25"/>', ''):
with self.subTest(fields=fields): with self.subTest(fields=fields):
@ -73,7 +94,7 @@ class ProjectMopaTests(unittest.TestCase):
def test_layer_indices_are_normalized_and_must_be_unique(self): def test_layer_indices_are_normalized_and_must_be_unique(self):
layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0] layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0]
self.assertEqual(layer["source_layer_id"], "7") 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="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>' '<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode() '</LightBurnProject>').encode()
@ -81,7 +102,7 @@ class ProjectMopaTests(unittest.TestCase):
import_project_mopa(duplicate) import_project_mopa(duplicate)
def test_multiple_layers_preserve_order_and_missing_pulse(self): 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 type="Cut"><index Value="0"/>'+FIELDS+'</CutSetting>'
'<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>' '<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>'
'</CutSetting_Img></LightBurnProject>').encode() '</CutSetting_Img></LightBurnProject>').encode()

View file

@ -30,7 +30,8 @@ class Step:
def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"): 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() f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
return import_project_mopa(data, unit_contract=UNIT_CONTRACT) 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.assertEqual(gate["status"], "staged-review-required")
self.assertFalse(gate["encoding_allowed"]) self.assertFalse(gate["encoding_allowed"])
self.assertTrue(gate["blockers"]) 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): def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
for result in ( for result in (
@ -74,7 +79,7 @@ class WorkflowImportTests(unittest.TestCase):
stage_mopa_workflow_steps(imported(FIELDS), {"3": step}) stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
two_layers = import_project_mopa( 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="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>' '<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT) '</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
@ -84,7 +89,7 @@ class WorkflowImportTests(unittest.TestCase):
def test_preflight_failure_does_not_mutate_any_step(self): def test_preflight_failure_does_not_mutate_any_step(self):
result = import_project_mopa( 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="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>' '<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT) '</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
@ -94,9 +99,19 @@ class WorkflowImportTests(unittest.TestCase):
self.assertTrue(first.visible) self.assertTrue(first.visible)
self.assertEqual((first.frequency, first.pulse_width), (1, 2)) 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): def test_setter_failure_rolls_back_every_step(self):
result = import_project_mopa( 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="1"/>'+FIELDS+'</CutSetting>'
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>' '<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT) '</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)

View file

@ -54,11 +54,20 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
self.assertEqual("PirateRayJCZNoDeviceDriver", seam["driver"]) self.assertEqual("PirateRayJCZNoDeviceDriver", seam["driver"])
self.assertIsNone(seam["transport"]) self.assertIsNone(seam["transport"])
self.assertFalse(seam["encoding_allowed"]) 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] entry = seam["process_metadata"]["cut_list"][0]
self.assertEqual(40000, entry["frequency"]["raw_value"]) 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(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): def test_addon_seam_rejects_wrong_backend_and_missing_frame_gate(self):
source = json.loads(SOURCE.read_text(encoding="utf-8")) source = json.loads(SOURCE.read_text(encoding="utf-8"))
@ -86,6 +95,32 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
with self.assertRaises(JCZProfileImportError): with self.assertRaises(JCZProfileImportError):
import_lightburn_jcz_profile(ambiguous_frequency) 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__": if __name__ == "__main__":
unittest.main() unittest.main()