From 8ab25827dbc440689a161ac1b945f6cb3c667089 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 24 Oct 2026 15:34:11 +0200 Subject: [PATCH] Bind LightBurn MOPA units to verified evidence --- .gitattributes | 2 + README.md | 3 +- docs/CODEX_HANDOFF.md | 26 +++++---- docs/adr/0002-reuse-rayforge-and-balormk.md | 13 +++-- .../lightburn-device-exports-2026-10-19.md | 16 +++--- .../lightburn-local-settings-2026-10-24.md | 10 ++-- .../research/lightburn-project-mopa-import.md | 31 +++++----- .../lightburn-rayforge-ux-capability-map.md | 9 +-- docs/research/m1-no-device-acceptance.md | 9 ++- docs/research/open-source-landscape.md | 10 ++-- docs/research/rayforge-jcz-packet-spike.md | 4 +- .../rayforge-lightburn-device-import-spike.md | 19 ++++--- .../rayforge-lightburn-mopa-import-spike.md | 31 +++++----- docs/roadmap.md | 27 +++++---- docs/status/phase-0.md | 8 ++- .../spike_rayforge_lightburn_device_import.py | 6 ++ .../pirate_ray_jcz/profile_import.py | 56 ++++++++++++++++--- .../pirate_ray_jcz/project_import.py | 31 ++++++++-- .../pirate_ray_jcz/workflow_import.py | 10 ++++ .../rayforge-lightburn-device-import.json | 35 ++++++++++-- tests/fixtures/synthetic-jcz-device.lbdev | 7 ++- tests/test_lightburn_project_mopa.py | 29 ++++++++-- tests/test_lightburn_workflow_import.py | 23 ++++++-- ..._rayforge_lightburn_device_import_spike.py | 39 ++++++++++++- 24 files changed, 328 insertions(+), 126 deletions(-) diff --git a/.gitattributes b/.gitattributes index 9f76a7b..876ff7f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/README.md b/README.md index 6a10937..f4e6042 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index bf5022e..dd9d025 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -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 diff --git a/docs/adr/0002-reuse-rayforge-and-balormk.md b/docs/adr/0002-reuse-rayforge-and-balormk.md index 4e8e6fc..e0221b1 100644 --- a/docs/adr/0002-reuse-rayforge-and-balormk.md +++ b/docs/adr/0002-reuse-rayforge-and-balormk.md @@ -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. diff --git a/docs/research/lightburn-device-exports-2026-10-19.md b/docs/research/lightburn-device-exports-2026-10-19.md index 5dc8a14..86d1b72 100644 --- a/docs/research/lightburn-device-exports-2026-10-19.md +++ b/docs/research/lightburn-device-exports-2026-10-19.md @@ -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. diff --git a/docs/research/lightburn-local-settings-2026-10-24.md b/docs/research/lightburn-local-settings-2026-10-24.md index fb4d0b8..b8e0460 100644 --- a/docs/research/lightburn-local-settings-2026-10-24.md +++ b/docs/research/lightburn-local-settings-2026-10-24.md @@ -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 diff --git a/docs/research/lightburn-project-mopa-import.md b/docs/research/lightburn-project-mopa-import.md index e7d455d..6a00702 100644 --- a/docs/research/lightburn-project-mopa-import.md +++ b/docs/research/lightburn-project-mopa-import.md @@ -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 diff --git a/docs/research/lightburn-rayforge-ux-capability-map.md b/docs/research/lightburn-rayforge-ux-capability-map.md index 25a69b9..3120971 100644 --- a/docs/research/lightburn-rayforge-ux-capability-map.md +++ b/docs/research/lightburn-rayforge-ux-capability-map.md @@ -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 diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index d28c7c9..64c4726 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -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. diff --git a/docs/research/open-source-landscape.md b/docs/research/open-source-landscape.md index 626b982..706e34f 100644 --- a/docs/research/open-source-landscape.md +++ b/docs/research/open-source-landscape.md @@ -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: diff --git a/docs/research/rayforge-jcz-packet-spike.md b/docs/research/rayforge-jcz-packet-spike.md index ec6ba80..38ea3a7 100644 --- a/docs/research/rayforge-jcz-packet-spike.md +++ b/docs/research/rayforge-jcz-packet-spike.md @@ -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 diff --git a/docs/research/rayforge-lightburn-device-import-spike.md b/docs/research/rayforge-lightburn-device-import-spike.md index f0b27f3..5962677 100644 --- a/docs/research/rayforge-lightburn-device-import-spike.md +++ b/docs/research/rayforge-lightburn-device-import-spike.md @@ -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. diff --git a/docs/research/rayforge-lightburn-mopa-import-spike.md b/docs/research/rayforge-lightburn-mopa-import-spike.md index 5b5d457..a390b1d 100644 --- a/docs/research/rayforge-lightburn-mopa-import-spike.md +++ b/docs/research/rayforge-lightburn-mopa-import-spike.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index b5d6b08..ad099ef 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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, diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index 4dcf215..cd1c1e2 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -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. diff --git a/scripts/spike_rayforge_lightburn_device_import.py b/scripts/spike_rayforge_lightburn_device_import.py index 43501d6..4c2e62e 100644 --- a/scripts/spike_rayforge_lightburn_device_import.py +++ b/scripts/spike_rayforge_lightburn_device_import.py @@ -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"], diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py index 29b994b..82dade7 100644 --- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/profile_import.py @@ -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", ], } diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py index 3687e97..56f3a5e 100644 --- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/project_import.py @@ -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"]} diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py index 4b7d6e8..50e7f18 100644 --- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/workflow_import.py @@ -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, diff --git a/tests/fixtures/rayforge-lightburn-device-import.json b/tests/fixtures/rayforge-lightburn-device-import.json index 6e40f59..6b1992d 100644 --- a/tests/fixtures/rayforge-lightburn-device-import.json +++ b/tests/fixtures/rayforge-lightburn-device-import.json @@ -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": [ diff --git a/tests/fixtures/synthetic-jcz-device.lbdev b/tests/fixtures/synthetic-jcz-device.lbdev index 8b60228..d7ae713 100644 --- a/tests/fixtures/synthetic-jcz-device.lbdev +++ b/tests/fixtures/synthetic-jcz-device.lbdev @@ -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": [ { diff --git a/tests/test_lightburn_project_mopa.py b/tests/test_lightburn_project_mopa.py index 52e8fd6..30cdc3a 100644 --- a/tests/test_lightburn_project_mopa.py +++ b/tests/test_lightburn_project_mopa.py @@ -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'<{tag} type="{kind}">' + return (f'' + f'<{tag} type="{kind}">' f'{fields}').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 = '' + 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 ('', '', ''): 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 = ('' + duplicate = ('' ''+FIELDS+'' ''+FIELDS+'' '').encode() @@ -81,7 +102,7 @@ class ProjectMopaTests(unittest.TestCase): import_project_mopa(duplicate) def test_multiple_layers_preserve_order_and_missing_pulse(self): - data = ('' + data = ('' ''+FIELDS+'' '' '').encode() diff --git a/tests/test_lightburn_workflow_import.py b/tests/test_lightburn_workflow_import.py index 4b457bd..d5af0d3 100644 --- a/tests/test_lightburn_workflow_import.py +++ b/tests/test_lightburn_workflow_import.py @@ -30,7 +30,8 @@ class Step: def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"): - data = (f'<{tag} type="{kind}">' + data = (f'' + f'<{tag} type="{kind}">' f'{fields}').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( - ('' + ('' ''+FIELDS+'' ''+FIELDS+'' '').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( - ('' + ('' ''+FIELDS+'' ''+FIELDS+'' '').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( - ('' + ('' ''+FIELDS+'' ''+FIELDS+'' '').encode(), unit_contract=UNIT_CONTRACT) diff --git a/tests/test_rayforge_lightburn_device_import_spike.py b/tests/test_rayforge_lightburn_device_import_spike.py index 30b2a3e..b3872a2 100644 --- a/tests/test_rayforge_lightburn_device_import_spike.py +++ b/tests/test_rayforge_lightburn_device_import_spike.py @@ -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()