build: add no-device M1 acceptance checkpoint

This commit is contained in:
Codex 2026-10-20 21:03:48 +02:00
parent 7dc1abc8a7
commit f8d3c889e3
11 changed files with 457 additions and 31 deletions

View file

@ -41,11 +41,12 @@ screenshots, the closed-application preferences folder, machine photos, and
operator notes directly from the actual control computer. The bundle and
exported preferences are already preserved and analyzed.
The offline M1 reuse proofs are progressing: the Rayforge/MeerK40t path now
has deterministic mock encoding, fail-closed JCZ profile and parameter gates,
list buffering, cancellation/recovery, and a transport-free workflow preview.
The remaining gates are a private known-working `.lbrn2` project and a reviewed
Windows GTK runtime for the packaged no-device workflow test. See the
The offline M1 reuse proofs now pass as one consolidated acceptance checkpoint:
the Rayforge/MeerK40t path has deterministic mock encoding, fail-closed JCZ
profile and parameter gates, list buffering, cancellation/recovery, layer and
recipe semantics, and a transport-free workflow preview. The remaining gates
are a private known-working `.lbrn2` project and a reviewed Windows GTK runtime
for the packaged no-device workflow test. See the
[Phase 0 status](docs/status/phase-0.md) and
[Rayforge GUI preflight](docs/research/rayforge-windows-gui-preflight.md).
@ -74,10 +75,9 @@ hardware-free:
1. collect known-working `.lbrn2` projects and the remaining screenshots,
photos, preferences folder and operator notes;
2. extend the synthetic import tests with the now-observed `QPulseWidth`
evidence, without assuming its `.lbrn2` representation;
3. define the minimum Rayforge JCZ device-profile/import extension and finish
controller-list buffering and safe state ordering offline;
2. obtain or produce a reviewed Windows Rayforge build artifact without
installing a toolchain through this repository;
3. run and record the packaged no-device desktop interaction checklist;
4. accept, revise, or reject the proposed reuse architecture from evidence;
5. write and review a separate hardware protocol before any live controller
communication.
@ -103,6 +103,7 @@ project workspace or development destination.
- [LightBurn-to-Rayforge UX capability map](docs/research/lightburn-rayforge-ux-capability-map.md)
- [Rayforge layer visibility and output spike](docs/research/rayforge-layer-model-spike.md)
- [Rayforge material-recipe model spike](docs/research/rayforge-recipe-model-spike.md)
- [M1 no-device build and acceptance checkpoint](docs/research/m1-no-device-acceptance.md)
- [Proposed Rayforge/BalorMK reuse decision](docs/adr/0002-reuse-rayforge-and-balormk.md)
- [Live USB enumeration](docs/research/live-usb-enumeration-2026-10-17.md)
- [LightBurn device-export analysis](docs/research/lightburn-device-exports-2026-10-19.md)

View file

@ -118,8 +118,13 @@ Subsequent offline evidence:
canvas visibility and job participation are already independent serialized
states; LightBurn-style layer Output is a narrow aggregate UI concern, not a
new pipeline or document-model requirement.
- [M1 no-device acceptance checkpoint](../research/m1-no-device-acceptance.md):
the public regression suite and leak guard now form one transport-free gate
over addon lifecycle, workflow, layers, recipes, encoding, negative cases,
cancellation/recovery and synthetic imports. The packaged Windows GUI and a
known-working private project remain external gates.
These results support continuing toward complete list buffering and production
integration, but they do not yet satisfy every proof obligation or change this
ADR's proposed status.
These results satisfy the current model, buffering and no-device integration
proofs. They do not yet satisfy the packaged human-interaction or real-project
import obligations, so this ADR remains proposed.

View file

@ -0,0 +1,91 @@
# M1 no-device build and acceptance checkpoint
- Date: 2026-10-20
- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5`
- Scope: packaged Windows desktop and consolidated offline acceptance
- Hardware access: prohibited
## Goal
Close the remaining software-controlled part of M1 with one reproducible
checkpoint rather than treating isolated spikes as a finished desktop flow.
The checkpoint has two outputs:
1. a pinned Windows Rayforge bundle containing the external PirateRay
no-device addon;
2. one machine-readable acceptance report covering all current transport-free
evidence.
Neither output authorizes USB discovery, controller commands, galvo movement,
physical framing, arming or laser output.
## On-demand build
Run the build only when a Windows artifact is required:
```powershell
scripts/build_rayforge_windows_checkpoint.ps1
```
The script fetches the exact revision from `upstreams/rayforge.json`, stages
the no-device addon into the ignored build checkout, invokes Rayforge's
upstream MSYS2 build and creates a portable ZIP plus installer hashes under
`.build/artifacts/`. Normal clone, documentation and test workflows do not
download or install the build stack.
The checkpoint script never installs the Windows toolchain. If the reviewed
runtime is unavailable, it stops and reports the missing prerequisite. Any
toolchain preparation remains a separate, explicitly invoked upstream action
or should happen on a dedicated build runner.
Before staging, the script rejects transport imports in the addon. The build
manifest records the exact upstream commit and hashes every addon source file,
the portable archive and installer. It does not claim the GUI has been tested.
## Automated acceptance
Run with the isolated Rayforge Python environment:
```powershell
python scripts/run_m1_no_device_acceptance.py <rayforge-checkout> `
--output .build/artifacts/m1-no-device-acceptance.json
```
The runner executes the complete repository test suite and public-repository
guard, then collects and hashes the regression-verified golden reports for the
addon lifecycle, graphical workflow model, layer model, recipe model, JCZ
equivalence, negative cases, cancellation/recovery and both LightBurn import
spikes. It passes only when the repository checks pass, transport remains
absent, the controller backend remains unreachable, negative cases fail
closed, cancellation requires recovery and incomplete LightBurn import still
blocks encoding.
## Manual packaged-app checklist
Use only the isolated no-device profile and an empty work area. Do not connect
or configure a controller.
1. Verify the artifact and addon hashes against the build manifest.
2. Launch the portable build with a fresh application-data directory.
3. Confirm the UI identifies the state as offline/no-device.
4. Open synthetic geometry and inspect the 110 × 110 mm synthetic field.
5. Name, color, select and reorder layers.
6. Verify canvas visibility and output participation are independent.
7. Edit power, speed, frequency and pulse width and confirm Hz/ns labels.
8. Apply the synthetic unverified recipe; confirm mismatched compatibility is
blocked and no approved label appears.
9. Preview enabled geometry, ordering, hatch and out-of-field diagnostics.
10. Save and reopen; confirm geometry, order and supported settings survive.
11. Start simulation, hold, resume, cancel and explicitly recover.
12. Exit and preserve only the sanitized acceptance report and screenshots.
Any device discovery prompt, physical-device status, transport attempt or
unlabelled framing action is a stop condition and fails the checkpoint.
## Remaining external gates
- A known-working private `.lbrn2` is still required before implementing
project-level `QPulseWidth` mapping.
- Human interaction results from the packaged build must be recorded before
ADR 0002 can be accepted.
- Physical controller behavior remains outside M1.

View file

@ -45,10 +45,11 @@ revision into the ignored `.build/upstreams/` cache only when explicitly
invoked. Its default action prepares source only; it neither installs the
Windows toolchain nor starts a build.
Windows dependencies and bundle creation require separate
`-InstallWindowsDependencies` and `-BuildWindowsBundle` switches. This keeps a
multi-gigabyte MSYS2/UCRT64 toolchain out of normal clone, test and documentation
workflows and makes a CI-produced bundle the preferred interaction-test input.
The repository scripts do not install Windows dependencies. Bundle creation
requires the explicit `-BuildWindowsBundle` switch and an already reviewed,
prepared runtime. This keeps a multi-gigabyte MSYS2/UCRT64 toolchain out of
normal clone, test and documentation workflows and makes a CI-produced bundle
the preferred interaction-test input.
## Next action

View file

@ -316,6 +316,12 @@ has no MSYS2/UCRT64 Rayforge runtime. No dependencies were installed and no
GUI success is claimed. The packaged interaction test therefore remains
blocked on a reviewed Windows GTK runtime, not on controller behavior.
The transport-free portions are now consolidated by the
[M1 no-device acceptance checkpoint](research/m1-no-device-acceptance.md).
It reruns the public suite and leak guard and hashes the validated regression
reports. The on-demand build entry point fetches pinned source and stages the
addon but deliberately refuses to install a missing Windows toolchain.
## Decision rules
- A simulator test precedes every equivalent hardware behavior.

View file

@ -1,6 +1,6 @@
# Phase 0 status
Last updated: 2026-10-19
Last updated: 2026-10-20
## Completed
@ -72,13 +72,19 @@ evidence for:
- fail-closed profile and encoder negative cases;
- Rayforge workflow-model serialization and preview behavior for power,
frequency and pulse width.
- independent layer visibility/output state and ordered serialization;
- material-recipe matching plus a fail-closed PirateRay provenance and exact
machine/source/lens/correction compatibility gate;
- one consolidated no-device acceptance report that reruns all 69 public
tests and the leak guard, then hashes the regression evidence.
The remaining M1 gates are deliberately explicit:
- a private known-working `.lbrn2` project is still required before mapping
project-level `QPulseWidth` into workflow settings;
- packaged Windows/GTK interaction is blocked until a reviewed Rayforge
runtime with PyGObject and MSYS2/UCRT64 is available;
runtime with PyGObject and MSYS2/UCRT64 is available; the repository build
entry point fetches pinned source but never installs that runtime;
- graphical workflow, import, lens correction and controller protocol
behavior remain unproven at the physical boundary;
- ADR 0002 remains **Proposed** until those gaps are resolved or rejected by

View file

@ -0,0 +1,111 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$CacheRoot,
[string]$ArtifactRoot
)
$ErrorActionPreference = "Stop"
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$upstreamManifest = Get-Content -Raw -LiteralPath (
Join-Path $repositoryRoot "upstreams\rayforge.json"
) | ConvertFrom-Json
$shortCommit = $upstreamManifest.commit.Substring(0, 12)
if (-not $CacheRoot) {
$CacheRoot = Join-Path $repositoryRoot ".build\upstreams"
}
if (-not $ArtifactRoot) {
$ArtifactRoot = Join-Path $repositoryRoot ".build\artifacts"
}
& (Join-Path $PSScriptRoot "prepare_rayforge_build.ps1") `
-CacheRoot $CacheRoot
if ($LASTEXITCODE -ne 0) {
throw "Pinned Rayforge source preparation failed"
}
$checkout = Join-Path $CacheRoot "rayforge-$shortCommit"
$verifiedCommit = (& git -C $checkout rev-parse HEAD).Trim()
if ($verifiedCommit -ne $upstreamManifest.commit) {
throw "Rayforge pin mismatch before packaging"
}
$addonSource = Join-Path $repositoryRoot (
"spikes\rayforge-jcz-no-device-addon"
)
$addonText = Get-ChildItem -LiteralPath $addonSource -Recurse -File |
Where-Object { $_.Extension -eq ".py" } |
ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }
$forbidden = @("import usb", "import serial", "import socket", "pyusb")
foreach ($needle in $forbidden) {
if (($addonText -join "`n").ToLowerInvariant().Contains($needle)) {
throw "No-device addon contains forbidden transport token: $needle"
}
}
$addonDestination = Join-Path $checkout (
"rayforge\builtin_addons\rayforge-addon-the-pirate-ray-jcz"
)
if ($PSCmdlet.ShouldProcess($addonDestination, "Stage no-device addon")) {
New-Item -ItemType Directory -Force -Path $addonDestination | Out-Null
Copy-Item -Path (Join-Path $addonSource "*") `
-Destination $addonDestination -Recurse -Force
}
$prepareArguments = @{
CacheRoot = $CacheRoot
BuildWindowsBundle = $true
}
& (Join-Path $PSScriptRoot "prepare_rayforge_build.ps1") @prepareArguments
if ($LASTEXITCODE -ne 0) {
throw "Pinned Rayforge Windows bundle build failed"
}
$bundleName = "rayforge-vpirateray-$shortCommit"
$bundleDirectory = Join-Path $checkout "dist\$bundleName"
$application = Join-Path $bundleDirectory "$bundleName.exe"
$installer = Join-Path $checkout "dist\$bundleName-installer.exe"
if (-not (Test-Path -LiteralPath $application)) {
throw "Expected packaged application was not produced: $application"
}
if (-not (Test-Path -LiteralPath $installer)) {
throw "Expected installer was not produced: $installer"
}
New-Item -ItemType Directory -Force -Path $ArtifactRoot | Out-Null
$archive = Join-Path $ArtifactRoot "$bundleName.zip"
if ($PSCmdlet.ShouldProcess($archive, "Create portable checkpoint archive")) {
Compress-Archive -LiteralPath $bundleDirectory -DestinationPath $archive -Force
}
$addonFiles = Get-ChildItem -LiteralPath $addonSource -Recurse -File |
Sort-Object FullName |
ForEach-Object {
[ordered]@{
path = $_.FullName.Substring($addonSource.Length + 1).Replace("\", "/")
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant()
}
}
$result = [ordered]@{
checkpoint = "Rayforge Windows no-device bundle"
rayforge_commit = $verifiedCommit
addon = "the_pirate_ray_jcz"
addon_source_files = $addonFiles
hardware_access = $false
transport = $null
bundle = [ordered]@{
path = $archive
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
bytes = (Get-Item -LiteralPath $archive).Length
}
installer = [ordered]@{
path = $installer
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
bytes = (Get-Item -LiteralPath $installer).Length
}
manual_interaction_required = $true
}
$manifestPath = Join-Path $ArtifactRoot "rayforge-windows-checkpoint.json"
$result | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding utf8
Write-Output "Checkpoint manifest: $manifestPath"

View file

@ -1,7 +1,6 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$CacheRoot,
[switch]$InstallWindowsDependencies,
[switch]$BuildWindowsBundle
)
@ -58,7 +57,7 @@ if ($verifiedCommit -ne $manifest.commit) {
Write-Output "Rayforge source ready at $checkout"
Write-Output "Verified commit: $verifiedCommit"
if (-not $InstallWindowsDependencies -and -not $BuildWindowsBundle) {
if (-not $BuildWindowsBundle) {
Write-Output "No dependencies were installed and no build was started."
return
}
@ -70,19 +69,10 @@ if (-not (Test-Path -LiteralPath $bash)) {
$msysCheckout = (& $bash -lc "cygpath -u '$checkout'").Trim()
if ($InstallWindowsDependencies) {
if ($PSCmdlet.ShouldProcess("C:\msys64", "Install Rayforge Windows build dependencies")) {
& $bash -lc "cd '$msysCheckout' && bash scripts/win/win_setup.sh"
if ($LASTEXITCODE -ne 0) {
throw "Rayforge Windows dependency setup failed with exit code $LASTEXITCODE"
}
}
}
if ($BuildWindowsBundle) {
$environmentFile = Join-Path $checkout ".msys2_env"
if (-not (Test-Path -LiteralPath $environmentFile)) {
throw "Windows dependencies are not prepared. Re-run with -InstallWindowsDependencies."
throw "A reviewed Rayforge Windows build runtime is not prepared. No dependencies were installed."
}
if ($PSCmdlet.ShouldProcess($checkout, "Build pinned Rayforge Windows bundle")) {

View file

@ -0,0 +1,156 @@
"""Consolidate the transport-free M1 regression evidence."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PINNED_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5"
EVIDENCE = {
"addon_lifecycle": "rayforge-addon-spike.json",
"graphical_workflow_model": "rayforge-graphical-workflow.json",
"layer_model": "rayforge-layer-model.json",
"recipe_model": "rayforge-recipe-model.json",
"jcz_equivalence": "rayforge-jcz-equivalence.json",
"jcz_negative_cases": "rayforge-jcz-negative-cases.json",
"cancel_recovery": "rayforge-jcz-cancel-recovery.json",
"lightburn_mopa_import": "rayforge-lightburn-mopa-import.json",
"lightburn_device_import": "rayforge-lightburn-device-import.json",
}
def _commit_of(source: Path) -> str:
result = subprocess.run(
["git", "-C", str(source), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _run_check(command: list[str], environment: dict[str, str]) -> dict:
result = subprocess.run(
command,
cwd=ROOT,
check=False,
capture_output=True,
text=True,
timeout=120,
env=environment,
)
combined = (result.stdout + result.stderr).strip()
if result.returncode != 0:
raise RuntimeError(combined or f"check failed: {command}")
return {
"status": "passed",
"command": command,
"output_tail": combined.splitlines()[-4:],
}
def _load_evidence() -> dict[str, dict]:
fixture_dir = ROOT / "tests" / "fixtures"
results = {}
for name, filename in EVIDENCE.items():
path = fixture_dir / filename
results[name] = {
"path": path.relative_to(ROOT).as_posix(),
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"report": json.loads(path.read_text(encoding="utf-8")),
}
return results
def run(source: Path) -> dict:
commit = _commit_of(source)
if commit != PINNED_COMMIT:
raise RuntimeError(f"expected Rayforge {PINNED_COMMIT}, found {commit}")
temp_dir = ROOT / ".build" / "acceptance-temp"
temp_dir.mkdir(parents=True, exist_ok=True)
environment = os.environ.copy()
environment["TEMP"] = str(temp_dir)
environment["TMP"] = str(temp_dir)
repository_checks = {
"unit_tests": _run_check(
[sys.executable, "-m", "unittest", "discover", "-s", "tests"],
environment,
),
"public_repo_guard": _run_check(
[sys.executable, "scripts/check_public_repo.py", "."],
environment,
),
}
evidence = _load_evidence()
graphical = evidence["graphical_workflow_model"]["report"]
negative = evidence["jcz_negative_cases"]["report"]
cancellation = evidence["cancel_recovery"]["report"]
device_import = evidence["lightburn_device_import"]["report"]
transport_free = all(
item["report"].get("transport") is None
for item in evidence.values()
if "transport" in item["report"]
)
controller_unreachable = (
graphical["controller_backend_reachable"] is False
)
checks = {
"repository_checks_passed": all(
item["status"] == "passed" for item in repository_checks.values()
),
"transport_free": transport_free,
"controller_backend_unreachable": controller_unreachable,
"negative_cases_fail_closed": negative["all_failed_closed"],
"cancel_requires_recovery": cancellation[
"restart_without_recovery_rejected"
],
"incomplete_import_blocks_encoding": not device_import[
"addon_profile_seam"
]["encoding_allowed"],
}
passed = all(checks.values())
return {
"checkpoint": "M1 no-device acceptance",
"status": "passed" if passed else "failed",
"rayforge_commit": commit,
"hardware_access": False,
"controller_backend_reachable": not controller_unreachable,
"checks": checks,
"repository_checks": repository_checks,
"evidence": evidence,
"remaining_manual_gates": [
"packaged Windows GUI interaction",
"known-working private LightBurn project import",
],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("rayforge_checkout", type=Path)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
(ROOT / ".build").mkdir(exist_ok=True)
result = run(args.rayforge_checkout.resolve())
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered, encoding="utf-8", newline="\n")
print(f"status={result['status']}")
print(f"sha256={hashlib.sha256(rendered.encode()).hexdigest()}")
return 0 if result["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,59 @@
import json
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ACCEPTANCE = ROOT / "scripts" / "run_m1_no_device_acceptance.py"
BUILD = ROOT / "scripts" / "build_rayforge_windows_checkpoint.ps1"
UPSTREAM = ROOT / "upstreams" / "rayforge.json"
class M1AcceptancePipelineTests(unittest.TestCase):
def test_acceptance_collects_every_current_offline_proof(self):
source = ACCEPTANCE.read_text(encoding="utf-8")
expected = (
"rayforge-addon-spike.json",
"rayforge-graphical-workflow.json",
"rayforge-layer-model.json",
"rayforge-recipe-model.json",
"rayforge-jcz-equivalence.json",
"rayforge-jcz-negative-cases.json",
"rayforge-jcz-cancel-recovery.json",
"rayforge-lightburn-mopa-import.json",
"rayforge-lightburn-device-import.json",
)
for script in expected:
self.assertIn(script, source)
def test_acceptance_is_pinned_and_reports_remaining_manual_gates(self):
manifest = json.loads(UPSTREAM.read_text(encoding="utf-8"))
source = ACCEPTANCE.read_text(encoding="utf-8")
self.assertIn(manifest["commit"], source)
self.assertIn("packaged Windows GUI interaction", source)
self.assertIn("known-working private LightBurn project import", source)
self.assertIn('"-m", "unittest", "discover"', source)
self.assertIn("scripts/check_public_repo.py", source)
self.assertIn("timeout=120", source)
def test_build_is_explicit_pinned_and_packages_the_addon(self):
source = BUILD.read_text(encoding="utf-8")
self.assertNotIn("InstallWindowsDependencies", source)
self.assertIn("prepare_rayforge_build.ps1", source)
self.assertIn("rayforge-addon-the-pirate-ray-jcz", source)
self.assertIn("Compress-Archive", source)
self.assertIn("Get-FileHash -Algorithm SHA256", source)
def test_acceptance_has_no_hardware_commands(self):
source = ACCEPTANCE.read_text(encoding="utf-8").lower()
for forbidden in ("usb.core", "import usb", "open_device"):
self.assertNotIn(forbidden, source)
def test_build_rejects_transport_imports_in_staged_addon(self):
source = BUILD.read_text(encoding="utf-8").lower()
for forbidden in ("import usb", "import serial", "import socket"):
self.assertIn(forbidden, source)
if __name__ == "__main__":
unittest.main()

View file

@ -21,7 +21,7 @@ class UpstreamBuildReferenceTests(unittest.TestCase):
script = PREPARE.read_text(encoding="utf-8")
self.assertIn('".build\\upstreams"', script)
self.assertIn("SupportsShouldProcess", script)
self.assertIn("InstallWindowsDependencies", script)
self.assertNotIn("InstallWindowsDependencies", script)
self.assertIn("BuildWindowsBundle", script)
self.assertIn("No dependencies were installed", script)