@photivra/engine 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +156 -1
  2. package/CITATION.cff +1 -1
  3. package/NODE_SUPPORT.md +19 -3
  4. package/README.md +6 -6
  5. package/SECURITY.md +1 -1
  6. package/dist/core/portable-math.d.ts +6 -0
  7. package/dist/core/portable-math.js +39 -0
  8. package/dist/core/portable-math.js.map +1 -1
  9. package/dist/core/version.d.ts +1 -1
  10. package/dist/core/version.js +1 -1
  11. package/dist/core/version.js.map +1 -1
  12. package/dist/facade/chart-material-layers.d.ts +115 -0
  13. package/dist/facade/chart-material-layers.js +224 -0
  14. package/dist/facade/chart-material-layers.js.map +1 -0
  15. package/dist/facade/chart-radiance-batch.d.ts +8 -2
  16. package/dist/facade/chart-radiance-batch.js +11 -4
  17. package/dist/facade/chart-radiance-batch.js.map +1 -1
  18. package/dist/facade/chart-scenes.d.ts +134 -0
  19. package/dist/facade/chart-scenes.js +32 -22
  20. package/dist/facade/chart-scenes.js.map +1 -1
  21. package/dist/facade/diffraction-provider.d.ts +8 -2
  22. package/dist/facade/diffraction-provider.js +71 -2
  23. package/dist/facade/diffraction-provider.js.map +1 -1
  24. package/dist/facade/display-view-gpu-session.d.ts +18 -0
  25. package/dist/facade/display-view-gpu-session.js +280 -0
  26. package/dist/facade/display-view-gpu-session.js.map +1 -0
  27. package/dist/facade/display-view-gpu.d.ts +146 -0
  28. package/dist/facade/display-view-gpu.js +59 -0
  29. package/dist/facade/display-view-gpu.js.map +1 -0
  30. package/dist/facade/display-view.d.ts +48 -1
  31. package/dist/facade/display-view.js +7 -7
  32. package/dist/facade/display-view.js.map +1 -1
  33. package/dist/facade/expected-signal-setup.d.ts +5 -0
  34. package/dist/facade/expected-signal-setup.js +16 -0
  35. package/dist/facade/expected-signal-setup.js.map +1 -1
  36. package/dist/facade/expected-signal.d.ts +48 -3
  37. package/dist/facade/expected-signal.js +8 -0
  38. package/dist/facade/expected-signal.js.map +1 -1
  39. package/dist/facade/scene-radiance.d.ts +4 -2
  40. package/dist/facade/scene-radiance.js +7 -4
  41. package/dist/facade/scene-radiance.js.map +1 -1
  42. package/dist/facade/simulate-capture.d.ts +108 -8
  43. package/dist/facade/simulate-capture.js +226 -33
  44. package/dist/facade/simulate-capture.js.map +1 -1
  45. package/dist/facade/simulate-display-view.d.ts +31 -3
  46. package/dist/facade/simulate-display-view.js +73 -13
  47. package/dist/facade/simulate-display-view.js.map +1 -1
  48. package/dist/gpu/chart-composite-gpu.d.ts +119 -0
  49. package/dist/gpu/chart-composite-gpu.js +572 -0
  50. package/dist/gpu/chart-composite-gpu.js.map +1 -0
  51. package/dist/gpu/chart-radiance-gpu.d.ts +6 -6
  52. package/dist/gpu/chart-radiance-gpu.js +9 -11
  53. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  54. package/dist/gpu/display-view-chart-material-module.d.ts +10 -0
  55. package/dist/gpu/display-view-chart-material-module.js +58 -0
  56. package/dist/gpu/display-view-chart-material-module.js.map +1 -0
  57. package/dist/gpu/display-view-composite-material-module.d.ts +68 -0
  58. package/dist/gpu/display-view-composite-material-module.js +248 -0
  59. package/dist/gpu/display-view-composite-material-module.js.map +1 -0
  60. package/dist/gpu/display-view-quality-gpu.d.ts +185 -0
  61. package/dist/gpu/display-view-quality-gpu.js +1000 -0
  62. package/dist/gpu/display-view-quality-gpu.js.map +1 -0
  63. package/dist/gpu/display-view-speed-gpu.d.ts +156 -0
  64. package/dist/gpu/display-view-speed-gpu.js +524 -0
  65. package/dist/gpu/display-view-speed-gpu.js.map +1 -0
  66. package/dist/gpu/image-kernel-accelerator.d.ts +23 -4
  67. package/dist/gpu/image-kernel-accelerator.js +58 -10
  68. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  69. package/dist/gpu/image-kernel-execution.d.ts +55 -3
  70. package/dist/gpu/image-kernel-execution.js.map +1 -1
  71. package/dist/gpu/sensor-stage-gpu.d.ts +11 -0
  72. package/dist/gpu/sensor-stage-gpu.js +42 -13
  73. package/dist/gpu/sensor-stage-gpu.js.map +1 -1
  74. package/dist/gpu/webgpu-runtime.d.ts +4 -2
  75. package/dist/gpu/webgpu-runtime.js +13 -4
  76. package/dist/gpu/webgpu-runtime.js.map +1 -1
  77. package/dist/gpu/webgpu-switch.d.ts +4 -0
  78. package/dist/gpu/webgpu-switch.js +29 -0
  79. package/dist/gpu/webgpu-switch.js.map +1 -0
  80. package/dist/gpu/wgsl-chart-composite-kernel.d.ts +52 -0
  81. package/dist/gpu/wgsl-chart-composite-kernel.js +515 -0
  82. package/dist/gpu/wgsl-chart-composite-kernel.js.map +1 -0
  83. package/dist/gpu/wgsl-chart-kernel.d.ts +11 -9
  84. package/dist/gpu/wgsl-chart-kernel.js +33 -27
  85. package/dist/gpu/wgsl-chart-kernel.js.map +1 -1
  86. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +95 -0
  87. package/dist/gpu/wgsl-display-view-quality-kernel.js +769 -0
  88. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -0
  89. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +75 -0
  90. package/dist/gpu/wgsl-display-view-speed-kernel.js +386 -0
  91. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -0
  92. package/dist/gpu/wgsl-sensor-kernel.d.ts +28 -9
  93. package/dist/gpu/wgsl-sensor-kernel.js +94 -50
  94. package/dist/gpu/wgsl-sensor-kernel.js.map +1 -1
  95. package/dist/index.d.ts +5 -2
  96. package/dist/index.js +2 -0
  97. package/dist/index.js.map +1 -1
  98. package/dist/optics/defocus-disc-kernel.d.ts +61 -0
  99. package/dist/optics/defocus-disc-kernel.js +191 -0
  100. package/dist/optics/defocus-disc-kernel.js.map +1 -0
  101. package/docs/API_REFERENCE.md +47 -1
  102. package/docs/QUICK_START.md +150 -5
  103. package/docs/USAGE.md +81 -3
  104. package/docs/api/core-version.md +1 -1
  105. package/docs/api/exports.json +127 -2
  106. package/docs/api/facade-chart-material-layers.md +137 -0
  107. package/docs/api/facade-chart-radiance-batch.md +7 -2
  108. package/docs/api/facade-display-view-gpu.md +94 -0
  109. package/docs/api/facade-scene-radiance.md +3 -2
  110. package/docs/api/facade-simulate-capture.md +95 -11
  111. package/docs/api/facade-simulate-display-view.md +17 -2
  112. package/docs/api/gpu-image-kernel-accelerator.md +10 -3
  113. package/docs/api/gpu-image-kernel-execution.md +71 -4
  114. package/docs/api/gpu-webgpu-switch.md +23 -0
  115. package/package.json +3 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,161 @@
1
1
  # Changelog
2
2
 
3
- ## 2.10.0 — unreleased (prepared October 27, 2026)
3
+ ## 2.12.0 — unreleased (prepared October 31, 2026)
4
+
5
+ A minor release for the app's Beta camera: Auto ISO through the one-call API, and the deviation from the standard exposure for an exposure meter. With `iso` absent or a number, samples, RAW codes and files are the same as 2.11.1; the exposure result gains `isoControl` and `standardExposureDeviationStops`.
6
+ - **New:** `exposure.iso: "auto"` in every exposure mode, with `exposure.autoIso` in aperture priority and an Auto ISO report in the result (#516, #518); `exposure.standardExposureDeviationStops` (#515, #517).
7
+ - **Compatibility:** two new root type exports (1,767), none removed; `exposure.iso` widens to `number | "auto"`; new result fields only.
8
+
9
+ ### Added
10
+
11
+ - **`simulateCapture` and the display view: Auto ISO in every exposure mode (#516).** `exposure.iso: "auto"` hands the ISO to the existing exposure-mode resolvers' Auto ISO branches, within the body's Auto ISO range, in `manual`, `aperture-priority`, `shutter-priority` and `program`; the default stays ISO 100. With `iso` absent or a number nothing changes: every hash lock holds, and the only result change is the new `exposure.isoControl: "manual"`.
12
+ - **Per mode:** manual keeps the aperture and shutter (`resolveManualExposureMode`, automatic ISO); aperture priority uses `resolveAperturePriorityAutoIsoExposureMode` with the new `exposure.autoIso: { slowestPreferredShutterSeconds, afterMaximumIso? }` (the slowest preferred shutter is required, within the body's shutter range; `afterMaximumIso` is `"allow-slower-shutter"`, the default, or `"hold-preferred-shutter"`); shutter priority uses the `minimum-iso-aperture-first` policy; program uses the program line at the lowest ISO and raises the ISO beyond the line's end. Every policy starts at the lowest selectable Auto ISO and snaps to the nearest selectable ISO in stops (the lower on a tie). The automatic slowest shutter (#504) and photographer-set limits (#505) are not part of this change.
13
+ - **Result:** `exposure.settings.iso` is the ISO used; `exposure.isoControl` is `"automatic"`; `exposure.autoIso` (`SimulateCaptureAutoIsoReport`) gives `idealIsoBeforeConstraints`, `autoIsoMinimum`, `autoIsoMaximum`, `clamped`, the resolver's `limitingConstraint` (for example `"iso-maximum"`) and `residualStops`, and in aperture priority the policy, the slowest preferred shutter and `afterMaximumIsoFallbackUsed`. The display view (`simulateDisplayView`, `prepareDisplayView`) and the ideal reference report the same fields; a new `exposure.autoIso` assumption records the policy.
14
+ - **Validation:** a string other than `"auto"`, `exposure.autoIso` outside aperture priority with Auto ISO, a missing, non-finite or out-of-range slowest preferred shutter, an unknown `afterMaximumIso` or key, and a body without Auto ISO fail with `InvalidConfigurationError`.
15
+ - **Tests** (`test/simulate-capture-auto-iso.test.ts`): each mode with `"auto"` (the default scene at f/8, 1/125 s gives ISO 100; three stops darker gives ISO 800, byte-identical to a manual ISO 800 capture in manual and aperture priority; compensation; the minimum and maximum with their limiting constraints; both `afterMaximumIso` policies; shutter priority past the widest aperture; program past the line's end); a capture, its display view (direct and prepared), its ideal reference and its region parts give the same exposure and report, and the parts stitch to the capture's bytes; fourteen invalid options fail with a domain error in the capture and the prepared view.
16
+ - New root exports: the types `SimulateCaptureAutoIsoAfterMaximum` and `SimulateCaptureAutoIsoReport` (1,767). `SimulateCaptureOptions.exposure.iso` widens to `number | "auto"` and gains `autoIso`; the capture, display-view and ideal-reference `exposure` gain `isoControl` and the optional `autoIso`. Docs: `docs/QUICK_START.md` ("Inputs and defaults", "Auto ISO") and `docs/USAGE.md`.
17
+ - **The deviation from the standard exposure, in stops (#515, for Photivra/apps#124).** `simulateCapture`'s and the display view's `exposure` gain `standardExposureDeviationStops`: Δ = log₂(N²/t) − log₂(0.65·L·S/10), from the CIPA DC-004-2020 relations the facade already meters with (S = 10/H, §3.2; H = 0.65·L·t/N², §5.5 (4)), with the settings used and `meteredSceneLuminanceCdPerSquareMeter`; no new metering. Positive is darker than the standard exposure (under), negative brighter (over); in an automatic mode it is minus the compensation plus any step or limit the resolver hit. It is `null` when the metered luminance is 0 (a black scene, which manual mode captures), never infinite. No other result, sample or RAW code changes; every hash lock holds.
18
+ - Tests: `test/exposure-deviation.test.ts`: 0 with the defaults (|Δ| ≈ 1.2 × 10⁻⁵ from the metering quadrature), +0.919 at f/11, +1.000 at f/8√2, −1.059 at 1/60 s, −1.000 at ISO 200, −⅔ in aperture priority with +⅔ stop, the 30 s limit at 0.01 lux (+9.16), the display view's value equal to the capture's, and `null` for a black scene.
19
+
20
+ ## 2.11.1 — published October 31, 2026
21
+
22
+ A patch release that publishes 2.11.0's engine to npm. The owner tagged `v2.11.0` on `6bef763` and published its GitHub release on October 28, 2026, but its npm publish run (40636849817) was cancelled at the verify job's 25-minute limit while the single-job coverage gate was still running, so `@photivra/engine@2.11.0` was never published; npm `latest` is 2.10.0. A published tag runs the publish workflow of its own commit, so 2.11.0 cannot be published again; 2.11.1 carries the fixed workflow.
23
+ - **No API, behaviour or value change from 2.11.0:** `src/` differs only in `ENGINE_API_VERSION`; the packed files differ only in the version fields and the release documentation (docs/RELEASE_2_11_1.md, "Same package").
24
+ - **Publish workflow (#511):** coverage runs as four time-balanced shards in parallel with `verify`; `coverage gate` merges them and enforces the thresholds on the whole suite; `publish` needs both. A shard skips only on ci.yml's coverage proof of the exact tree and Node.js release.
25
+ - **For users of 2.11.0's GitHub release:** the features and compatibility notes are those of 2.11.0 below.
26
+
27
+ ## 2.11.0 — tagged October 28, 2026; not published to npm (see 2.11.1)
28
+
29
+ A minor release: the GPU preview. `prepareDisplayView(request, { gpu, gpuDevice })` renders the display view on WebGPU (the Speed and Quality kernels, every engine chart and declared `material-layers` scene, and `lens.defocus`) and labels which backend ran; `simulateCapture` and the display view render `lens.defocus` out of focus; one switch turns the engine's GPU use off for a realm. With the default `gpu: "off"` and without `lens.defocus`, samples, RAW codes and files are the same as 2.10.0; display views gain `diagnostics.fast.execution`.
30
+ - **New:** `setWebGpuEnabled`/`getWebGpuEnabled` and the accelerator's `webgpu` boolean (#474); `prepareDisplayView(request, { gpu, gpuDevice })`, `dispose()` and `diagnostics.fast.execution` (#481); the GPU display view in Speed (#478) and Quality (#479), with composite charts and `material-layers` scenes (#477, #491) and defocus (#492); `lens.defocus: { model: "geometric-disc" }` in captures and display views (#492, an approximation).
31
+ - **GPU accuracy** (float32 emulation and SwiftShader, a software adapter; not hardware): every component inside its a-priori bound on every fixture; ΔE2000 at most 4.7 × 10⁻⁵ in Speed, 4.1 × 10⁻⁴ in Quality and 1.7 × 10⁻⁴ out of focus; samples differ from the CPU's by at most one code; the noise draw is bit-identical to the CPU's. The WebGPU kernels' margins now follow the WGSL accuracy table (#471), and the Speed variance bound names the output quantization term (#496).
32
+ - **Not measured:** GPU timing on hardware (TBD, #475). The owner's Apple M5 Pro runs of `npm run accept:gpu:hardware` and `npm run test:webgpu:hardware` are the release gate for the GPU preview.
33
+ - **Compatibility:** twenty-three new root exports (1,765), none removed; new optional request fields and options; new members of result and reason unions (`ImageKernelFallbackReason` `webgpu-disabled`, `ImageKernelFailureReason` `conformance-failed`, `lens.defocus` in `SIMULATE_CAPTURE_COST_OPTIONS`); `PreparedDisplayView` gains `dispose()` and `ImageKernelAccelerator` gains `adapter`.
34
+
35
+ ### Added
36
+
37
+ - **Display view: `lens.defocus` on the GPU (#492, step 2c).** The engine's GPU display-view renderer (`createDisplayViewGpuRenderer`) now renders defocused views for Speed and Quality requests: its `supports()` accepts `DisplayViewGpuRequest.defocus`. No result changes with the default `gpu: "off"`, no CPU byte changes (every display-view hash lock holds), and in focus every GPU result is bit-identical to before (the emulation's samples, expected values, noise and bounds hashed on main and on this change for seven in-focus fixtures). The CPU display view's per-node kernels (the disc, or each node's diffraction kernel convolved with it, up to the caps of radius 64 and 96) are gathered in the Quality site pass:
38
+ - **Speed takes the Quality passes.** Speed has no gather; out of focus the expected signal has no query points (it reads the lattice formula), so the Speed kernel's query arithmetic does not apply. A defocused Speed request runs the Quality lattice and site passes, whose lattice and gather already exist, and the Quality pixel pass with one candidate, its own bilinear demosaic, and the same host finishing. The disc is the same at every node, and the table already folds the nodes, so the gather runs once per site. In-focus Speed stays on the Speed kernel, unchanged.
39
+ - **Factored table.** A neutral material's value is its reflectance times the white's combined kernel, so the defocus pass uploads one neutral row and one row per spectral material (3·(1 + S)·T values) and multiplies by the material's reflectance on the device. #491's composite scene has 4 143 neutral materials; a table per material would need 134 MB at radius 12 and 7 GB at radius 94.
40
+ - **Blocked sum.** Each group's taps are a dense cw × ch rectangle, so a site sums rows, then groups, then the total (`DISPLAY_VIEW_QUALITY_SITES_BLOCKED_WGSL`, no tap records). The site's relative bound is (cw_max + ch_max + G + 3)·E: 1.5 × 10⁻⁶ at radius 1 and 4.6 × 10⁻⁵ at radius 94 (144 400 taps per site), against (T + 3)·E = 1.7 % that the tap-list sum would carry there. The bound therefore does not widen with the tap count: at radius 94 it is below the in-focus 400-tap term (4.8 × 10⁻⁵). Each term's float32 roundings (its row value and its reflectance, 2⁻²⁴ each, and the product) are inside the + 3, and terms below the float32 normal range that a device may flush add their mass.
41
+ - **Lattice resolution.** With a wide disc nearly every site reads some lattice point the material module flags as uncertain, and that point's spread over every material flagged 95–100 % of the composite's pixels for CPU recomputation. In defocused renders the host now reads back the lattice and resolves each flagged point with the reference's own `materialAt` at the reference's lattice point, so no site carries material uncertainty and no fixture pixel is flagged (62–361 points resolved per block on the composite and the star).
42
+ - **Limits.** The site pass is split over dispatches of at most 2³⁰ taps (`DISPLAY_VIEW_QUALITY_SITE_TAPS_PER_DISPATCH`; a choice, untimed on hardware; results are the same for any split, tested). A kernel table above the device's storage binding limit is refused by name with `insufficient-limits`, never clipped. The GPU session now treats `insufficient-limits` thrown by a render like `unsupported-on-backend`: `"auto"` renders that part on the CPU, labelled, and `"require"` fails. The site pass uses no workgroup memory and binds at most seven storage buffers.
43
+ - **New self-check case** `display-view:quality:checkerboard-13x9-defocus-blocked`: the engine's 6.3-site disc, the bilinear route, the blocked sum split over 18 dispatches. It runs with the two existing cases before a device's first display-view render.
44
+ - **Measured agreement** (docs/WEBGPU.md, "Defocus on the GPU"): 30 fixtures on the float32 emulation and on SwiftShader. The 24-case grid covers focus 0.8× and 1.25×, f/2.8, f/8 and f/16, 6 µm and 1.4 µm, and Speed and Quality with diffraction. The other six are the composite under the label layer (Speed and Quality, and Quality at radius 94), a bar Siemens star, the disc at its 64-site cap, and a combined radius of 94. Every component is inside its bound, using at most 2.0 % of the expected-value bound and 2.8 % of the variance bound. ΔE2000 is at most 1.7 × 10⁻⁴ and |Δ|/σ at most 1.4 × 10⁻⁴. One sample of 17 712 differs, by one code. No pixel is flagged, and every case meets the #480 thresholds. **GPU timing: TBD-TIMING (#475)**; SwiftShader per-stage times are recorded, and they are not GPU performance.
45
+ - Tests: `test/display-view-defocus-gpu.test.ts` (emulation: the 30 fixtures; the bound terms; the dispatch split; `supports()`; the route through `prepareDisplayView`; the `insufficient-limits` refusal and the session's fallback; the self-check; every assembled display-view shader declares no name twice), `test/webgpu/display-view.webgpu.ts` (SwiftShader: the 30 fixtures and the self-check), and the shared fixtures in `test/helpers/display-view-defocus-gpu-fixtures.ts`. Internal additions: `DisplayViewGpuSetup.defocusRadius`, the Quality result's `blockedSum`, `siteSumRelativeBound` and `latticePointsResolved`, and `stageMilliseconds.siteDispatches`.
46
+ - **Display view: the Quality kernels on the GPU (#479): diffraction kernels, the four-spot anti-aliasing filter, the AHD candidates and the low-signal correction, behind the same `prepareDisplayView(request, { gpu: "auto" | "require" })` renderer.** No result changes with the default `gpu: "off"`, no CPU reference byte changes (the display-view hash locks are unchanged), and none for requests the kernels do not cover (caller scene functions, coarser views, footprints above 6 real pixels per axis: the CPU reference, labelled `unsupported-on-backend`; `lens.defocus` out of focus is rendered since #492 step 2c, above). Every engine chart and declared `material-layers` scene is covered through #491's material modules, shared by both kernels; with diffraction the composite module is given the lattice groups and the reference's lattice formula as its query lattice. The renderer implements `dispose()`. `DisplayViewGpuRequest` gains `defocus` (#492: true when `lens.defocus` resolves a non-zero disc; the renderer gathers it since #492 step 2c, above). The GPU session now registers one renderer, `createDisplayViewGpuRenderer` (id `photivra-display-view`), that runs the Speed kernel for requests it covers and the Quality passes for the rest. `src/gpu/wgsl-display-view-quality-kernel.ts` has three passes per band of display rows: the lattice materials of every lattice group (offsets sharing a fractional shift, as the reference groups them) through the material module; the site moments, gathering each site's expected electrons over every tap of every group from a table of the reference's own combined kernels per material and channel (formed on the host in float64 from the expected signal's tables; 400 taps per site at f/8 on 6 µm), then the readout moments and the white clip; and the display pixels, running the reference's per-real-pixel loop with both AHD candidates' full channel covariance, development, the Stein covariance, the residual, AHD's choice variance and the correction's trilinear interpolation. **The correction states stay on the host (#447):** the kernel records every grid key it needs in a bitmap and flags pixels whose state is missing; the host computes those states with the reference's `computeDisplayViewCorrectionStates`, uploads the sorted table and dispatches the band's pixel pass again; the renderer keeps the states per correction key across renders. The host finishes every pixel with the reference's own float64 functions, so **the noise draw is bit-identical to the CPU's**, as in Speed. Flagged display pixels (an uncertain lattice material whose effect could exceed 2⁻¹⁰ of a site's electrons, a correction-threshold decision within its bound, a missing state, a non-finite value) are recomputed with the reference. New self-check case `display-view:quality:checkerboard-13x9-diffraction-ahd` (diffraction, four spots, AHD and the correction on a 38 × 26 frame).
47
+ - **Bounds** (docs/WEBGPU.md, "Display view (Quality) on the GPU"): the AHD kernels and the colour matrix mix signs, so the kernels carry every quantity as a pair (value, absolute bound) with first-order rules from the WGSL accuracy table (E = 2⁻²³ per + − ×, division 2.5 ULP, sqrt 4.5 ULP, log2 2⁻²¹ or 3 ULP), the clipped-normal moments propagate their inputs with their exact first-order derivatives and add the Speed clip constants, and the host doubles the per-pixel bound and adds the output quantization term's dependence on the mean (dq/dm ≤ 6.04·Δ²/12). Reported as `relative-to-reference` with the relative term 2·2⁻⁴⁰ and the largest per-pixel absolute term.
48
+ - **Measured agreement** (float32 emulation in every CI run, SwiftShader in `test:webgpu`; nine small fixtures with diffraction, the four-spot filter and AHD, 82 215 components each): every component inside its bound using at most 3.0 % of the expected-value bound and 9.2 % of the variance bound (the white-clip fixture; below 1 % elsewhere except Reinhard 1.7 % and AHD without diffraction 2.3 %); ΔE2000 at most 4.1 × 10⁻⁴, at most 7.6 × 10⁻⁴ noise standard deviations, σ within 3 × 10⁻³ relative at the white clip and 8 × 10⁻⁵ elsewhere; at most 7 samples of 82 215 differ, by one code; the corrected-pixel count equals the reference's. The whole 24 MP 6 µm frame at 2028 × 1352 (checkerboard, f/8; emulation, run once): ΔE2000 at most 3.3 × 10⁻⁴, 5.4 × 10⁻⁴ σ, 1 035 of 8.2 million samples differ by one code, 0.43 % / 0.53 % of the bounds used; CI runs a full-width 32-row band of it.
49
+ - **AHD placement, measured; inconclusive without hardware** (docs/WEBGPU.md, the per-stage table): on SwiftShader, Google's Vulkan implementation on the CPU (not GPU performance), every pass is slower than one CPU thread running the reference in the same browser (the expected signal 1.0–1.1 s against 72–274 ms; the AHD pixel pass 2.0–2.4 s against 0.32–0.81 s per 27 405-pixel view), so only hardware can decide. The CPU numbers alone bound the alternative: the stage after the expected signal is 57–85 % of the warm CPU render (81 % on the app's Quality request), so putting only the expected signal on the GPU caps the speed-up at 1.2–1.75×. The AHD stage therefore runs on the GPU; the "signal on the GPU, AHD on the CPU" path is not built (it needs a bound for the reference loop's sensitivity to the GPU's electrons) and is the next step if the hardware run (#475) shows the pixel pass slower than the CPU. The correction states, the finishing and flagged pixels stay on the CPU. **Hardware timing: TBD-TIMING (#475).**
50
+ - **Known Quality limits are inherited:** the GPU reproduces the estimate, including its excess colour noise at small footprints (#379) and the star-centre AHD colour miss (#457).
51
+ - Tests: `test/display-view-quality-gpu.test.ts` (emulation: the nine fixtures, the states kept across renders, flagged pixels, banding and regions, the support check, the kernels' assembly, the combined renderer through `prepareDisplayView({ gpu: "auto" })`, the self-check), `test/display-view-quality-gpu-full.test.ts` (a full-width 32-row band of the 24 MP frame at 2028 × 1352; the whole view with `PHOTIVRA_DISPLAY_VIEW_WHOLE=1`), `test/webgpu/display-view.webgpu.ts` (SwiftShader: the nine fixtures with the CPU's per-stage times in the same realm, banding and regions, the self-check). Internal additions: `displayViewSignalTables` (the expected signal's read-only tables), the AHD grid constants and the Speed host's `queryGeometry`, `gaussLegendre16` and `axisRecord` are exported for the Quality host; `DisplayViewGpuMaterialModule` is unchanged.
52
+ - **The #480 preview acceptance harness runs the app's request on the GPU (`--gpu`; `npm run accept:gpu`, `npm run accept:gpu:hardware`).** Tooling only; no result changes. A GPU producer renders the request in headless Chromium (the WebGPU suite's driver, SwiftShader by default, the hardware adapter with `--hardware`) through `prepareDisplayView(request, { gpu: "require", gpuDevice })` on the built engine, records the execution label (backend, adapter, conformance, fallback reason) and fails the case loudly when the GPU did not render it. The GPU is judged against the CPU reference rendered in the same browser page (`--reference browser`, the default with `--gpu`), because Chromium's and Node's `Math.atan2` differ in the last bit (#471) and move lattice points on a Siemens star's sector boundaries; Node's reference is still rendered and its difference reported per case. The app's request is given in `material-layers` form (`--scene material-layers`, the default) with a generic engine-authored stand-in label layer (`standInLabelLayer`; not the app's artwork), and the cases now include the app's composite chart and the same request out of focus (`app-defocus`, reported as an expected fallback until #492 step 2c), next to the five fixtures, in Speed and Quality; `--region` compares one region of every view. Internal: `renderPreparedDisplayViewWithInternals` (`src/facade/simulate-display-view.ts`, not exported from the package) returns a prepared view's render with its expected values and noise σ; the suite's loopback server also serves `dist/` and `scripts/lib/`. Docs: `docs/DISPLAY_VIEW.md` and `docs/WEBGPU.md`, "Preview acceptance" (the owner's command for a Mac). Tests: `test/display-view-acceptance.test.mjs`, `test/display-view-prepared-internals.test.ts`.
53
+ - **Display view: `lens.defocus` out of focus is rendered (#492, step 2b, the CPU display view).** No result changes without the option or in focus: every display-view hash lock holds. The display view and the capture (step 2a) apply the same per-node kernels.
54
+ - **How:** the disc enters the expected-signal tables as a site kernel (`buildExpectedSignalInput`'s new `defocus` input, the kernels built as the capture builds them), so every query stays on the site lattice and both estimators run unchanged on the blurred lattice signal. Without diffraction every spectral node takes the disc itself (the view then reads the scene with the lattice formula, as with diffraction); with `lens.diffraction` node λ takes its diffraction kernel convolved with the disc (`convolveSiteKernels`, radius R_diff + R_defocus), a further labelled approximation. The new `view.defocus` assumption records the kernel and its radii. Prepared views build the combined kernels and tables once per request; regions stay byte-identical to the whole view.
55
+ - **Limits:** those of the capture: the disc's cap of 64 sites (a larger disc fails, naming the cap and the radius) and the diffraction cap of 32, so a combined radius of at most 96 sites. Lattice points beyond the 64 columns the lattice rings keep are looked up directly (same values, higher cost).
56
+ - **GPU:** the Speed-only renderer refuses a view whose expected signal carries site kernels at render time (`gpu: "auto"` then renders on the CPU, labelled `unsupported-on-backend`); the engine's combined renderer gathers the kernels since step 2c (above).
57
+ - **Validation** (`test/display-view-defocus.test.mjs`, `scripts/accept-display-view-defocus.mjs`, shared cases in `scripts/lib/display-view-defocus-cases.mjs`): against the disc's closed-form edge-spread function (axis-aligned edge on a cell boundary within 7.3e-15 linear at 1, 3 and 8 real pixels per display pixel; a 5° edge within the crossed-cell bound); against the in-focus view of the scene blurred beforehand by the capture's lattice convolution, 24 cases (focus 0.8× and 1.25×; f/2.8, f/8, f/16; 6 µm and 1.4 µm; Speed and Quality) at three scales, every one inside the #480 thresholds (72 renders): ΔE2000 at most 6.6e-11 on the exact area mean and 1.6e-7 on the stratified estimate, |Δ|/σ at most 1.3e-7. The capture with the option equals the capture of the pre-blurred scene at the same seed (every RAW code identical in 8 cases, Speed and Quality).
58
+ - **Cost:** `scripts/benchmark-display-view.mjs --app` takes `DEFOCUS_RADII` (and opt-in f/2.8 rows 4 and 5); measured costs per radius are in `docs/DISPLAY_VIEW.md`, "Cost".
59
+ - Docs: `docs/DISPLAY_VIEW.md` ("Defocus", limits, cost) and `docs/QUICK_START.md`.
60
+ - **`simulateCapture` renders `lens.defocus` out of focus (#492, step 2a).** No result changes without the option or in focus; every existing hash lock and fingerprint is unchanged.
61
+ - **How:** an object-space convolution of the scene radiance on the site lattice with the geometric defocus disc, beside the diffraction convolution. The capture keeps its tabulated fast path, and regions, strips and DNG parts stay byte-identical to the whole frame. The `lens.defocus` assumption records `execution: "object-space-convolution"` and the geometric approximation.
62
+ - **With `lens.diffraction`:** each spectral node's diffraction kernel is convolved with the disc, up to 32 + 64 = 96 sites. This is labelled as a further approximation, because the defocused wave PSF is not the Airy pattern convolved with the disc. Neither path uses the public 32-site limit of `calculateIrradianceConvolution`: the provider gathers only each band's own values (#419). The combined kernels cost at most 90 ms per spectral node (measured).
63
+ - **Refused out of focus** with camera rotation, `lens.projection`, `lens.distortion` and `lens.vignetting`, which move the scene points off the lattice.
64
+ - **Diagnostics:** `lens.defocus` joins `SIMULATE_CAPTURE_COST_OPTIONS` after `lens.diffraction`. `diagnostics.timingsMs.defocus` times the convolution; with diffraction the combined convolution is reported there, not under `diffraction`.
65
+ - **Cap:** `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES` stays at 64, now set from measured cost. On a 1024 × 683 tabulated capture (Node 24, one thread) a Siemens star took 0.37 s in focus, 2.9 s at R 32, 4.7 s at R 41 and 11.6 s at R 64. Dead leaves took 17.7 s at R 64, and Quality 52.9 s. The table is in `docs/QUICK_START.md`, "Optics".
66
+ - **Validation** (`test/simulate-capture-defocus-render.test.ts`):
67
+ - A vertical edge between lattice columns gives the exact circular-segment fraction (1e-12).
68
+ - A slanted-edge capture's averaged profile follows the disc's closed-form edge-spread function, within 0.0093 of the step.
69
+ - Against `lens.pupilSampling` at 4 × 8, 8 × 16 and 16 × 16 rays, the rays converge toward the continuous disc integral (RMS 0.021, 0.0049, 0.0035 for a 6-site disc). The lattice convolution differs from that integral by its point sampling of the edge: 0.112 max with point sampling, 0.035 with the 4 × 4 box.
70
+ - The tabulated and environment paths give the same RAW.
71
+ - Parts equal the whole frame, including `material-layers` scenes.
72
+ - Docs: `docs/QUICK_START.md` (inputs table and Optics) and `docs/USAGE.md` (an example).
73
+
74
+ - **`lens.defocus: { model: "geometric-disc" }` and the geometric defocus disc on the site lattice (#492, step 1: the kernel and the option; no rendering yet).** No result changes without the option.
75
+ - **The kernel** (internal, `src/optics/defocus-disc-kernel.ts`):
76
+ - It takes the thin-lens blur circle c from `calculateDefocusCircle` and models it as a uniform disc of c/pitch sites.
77
+ - Each site's weight is the exact area of the disc inside the site's cell, divided by the disc's area. The area is a closed-form circle–rectangle area, found by inclusion–exclusion over the cell's corners.
78
+ - The weights are computed for one octant and mirrored, so the 8-fold symmetry holds bit for bit.
79
+ - The weights are renormalized in float64, and the centre weight is set so that the exact sum is 1 to its last bit.
80
+ - A disc inside one site gives the identity, and c = 0 gives no kernel.
81
+ - The radius cap `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES` = 64 is provisional, pending the cost measured in step 2. Past it the call fails and names the cap and the radius asked for.
82
+ - **Portable arcsine:** `portableAsin` (internal, `src/core/portable-math.ts`) uses the Maclaurin series, with the half-angle identity above ½. It is within 2 ulps of `Math.asin` and bit-identical in every engine, so the weights are too.
83
+ - **The option:** `simulateCapture`, `simulateDisplayView` and `simulateIdealReference` accept `lens.defocus`.
84
+ - It resolves the disc at the resolved f-number and site pitch. The new `lens.defocus` assumption records the model, `scientificStatus: "approximation"`, the diameter in µm and in sites, and the kernel radius.
85
+ - In focus, the image is byte-identical to the same request without the option.
86
+ - Out of focus, the capture and the display view refuse the request with "does not yet support lens.defocus out of focus", naming the blur circle, rather than render it sharp. The ideal reference only records the disc.
87
+ - It is refused together with `lens.pupilSampling` or `lens.psf`, because defocus would be counted twice. It can be combined with `lens.diffraction`.
88
+ - With the option, the `lens.pupilSampling` default note points to it.
89
+ - **Tests:** `test/defocus-disc-kernel.test.ts` and `test/simulate-capture-defocus.test.ts`.
90
+ - A sampled straight edge (10 diameters from 0.3 to 128.9 sites, 4 angles, 4 offsets) is checked against the disc's closed-form edge-spread function, the circular-segment area fraction. The tolerance is the summed weight of the kernel cells the edge crosses, plus 1e-12.
91
+ - Measured: 2.2e-13 for an edge on cell boundaries. Elsewhere the error was 28–33 % of that bound, 0.0040 at 128.9 sites.
92
+ - Also tested: normalization, symmetry, the sub-site case, the cap, determinism (digest locks), the option's parse errors and refusals, and in-focus byte identity for the capture, display view and ideal reference.
93
+ - `test/portable-math.test.ts` covers `portableAsin`.
94
+ - Docs: `docs/QUICK_START.md` (inputs table and Optics).
95
+
96
+ - **Display view on the GPU for composite charts, the 2.3 elements and declared `material-layers` scenes (#491): the composite chart kernel's material lookup joined to the Speed kernel.** No result changes with `gpu: "off"`, and no CPU byte changes anywhere (hash locks unchanged). `prepareDisplayView(request, { gpu: "auto" })` now renders on the GPU, labelled `webgpu-f32`, a request whose scene is a composite chart, a 2.3 element, the sine Siemens star, a fixed-size chart (`chartSize`) or a declared `material-layers` scene (a chart under shape-mask layers: the form for an application's printed label); caller scene functions stay refused (`unsupported-on-backend`).
97
+ - **Scenes:** `simulateCapture`, `simulateDisplayView`, `prepareDisplayView` and `createPlanarSceneRadianceProvider` accept `scene: { kind: "material-layers", sceneId, chart, layers }` directly; it is evaluated as its `callerScene` (`resolveChartMaterialLayers`), so every result, digest and byte equals passing that `callerScene` (tested). `DisplayViewGpuRequest.scene` gains `"material-layers"`; `DisplayViewGpuSetup` gains `layers` and `placement` (internal).
98
+ - **The module** (`src/gpu/display-view-composite-material-module.ts`): the composite kernel's `material()` with its bound arithmetic and double-float32 as the Speed kernel's `material_at`, the scene's packed records at `@group(1)`, uploaded once per renderer and reused across a view's parts (four scenes kept, released by the new `DisplayViewGpuRenderer.dispose` when the prepared view is disposed). It recovers the exact query point from the kernel's float32 point (the site lattice: p / g picks the offset and integer site, then (x + c)·g in double-float32 from float64 constants), so the chirp decisions see the reference's point to about 2⁻⁴⁶; the host admits only lattices the recovery decides unambiguously (model checked at the corners within 2⁻⁴⁸, offsets told apart mod one site). The composite kernel's bounds and SAFETY factor apply unchanged and the Speed kernel's point contract (≤ 2E + 10⁻¹⁴) holds; no Speed bound changes (the argument and the measured 5.6·10⁻¹⁶ distance of the reference's points from the lattice: docs/WEBGPU.md, "The composite material module (#491)"). The 2.0 presets filling the frame keep the preset module.
99
+ - **Device probe:** the composite kernel's probe (now `probeChartCompositeDevice`) gates the module once per renderer; on a device that fails it a render throws `unsupported-on-backend`, which the GPU session now turns into a CPU render with that label (or an error under `"require"`), retrying the probe on the next render; the preset module keeps serving the 2.0 presets there. A renderer's render-time `unsupported-on-backend` (coarse view, probe) falls back the same way.
100
+ - **Measured** (float32 emulation and SwiftShader; 600 × 400 frame of 6 µm sites, 2 × 2 box, 203 × 135 view; one composite element of every kind under a label layer as `material-layers`, frame-filling and as a 0.042 m fixed-size chart at 0.63 m, plus 2.3 elements as plain charts): every component inside the a-priori bounds (at most 4.6 % of the expected-value bound and 29 % of the variance bound used), samples within one code, ΔE2000 at most 4.7e-5, at most 4.3e-4 noise σ, every #480 acceptance threshold met (100 % of pixels within ΔE2000 1); SwiftShader agrees with the emulation to every digit reported, flagged counts included. At the app's size (24 MP 6 µm, 2028 × 1352, the app's composite chart under a label layer as `material-layers`, emulation): ΔE2000 at most 4.2e-5, 87 of 8.2 million samples differ by one code, 1.3 % of display pixels flagged, every threshold met. Flagged display pixels (recomputed on the host, then exact): 4.2 % in the frame-filling composite (zone plate 59 %, label layer 51 %, grey ramp 16 %, bar star 9 %, others ≤ 4 %), 0.1 % in the fixed-size placement, 22.5 % for a 200-cycle zone plate alone, 0.5–2.1 % for dead leaves, the sine star and bar groups; the per-point flagged fraction is #477's (0.69 % over 197 544 points), a display pixel being flagged when any of its ~36–100 points is. Finding: the frame-filling lattice meets the label's integer and third-unit glyph coordinates exactly on some rows (3.8 % of the label's points flag); a cost, not an error. Details and the SwiftShader table in docs/WEBGPU.md.
101
+ - **Timing:** unmeasured on a GPU (#475). Tests: `test/display-view-composite-gpu.test.ts` (emulation: the fixtures, flagged fraction per element, the #480 thresholds, the point recovery against the composite kernel at 197 544 points, records uploaded once, the probe gate, `prepareDisplayView` with `"auto"`/`"require"`, `"off"` byte-identical to the caller-scene form, `supports()`, lattice refusals, the WGSL), `test/display-view-gpu-option.test.ts` (render-time fallback, dispose), `test/webgpu/display-view.webgpu.ts` (SwiftShader), and an opt-in app-size case in `test/display-view-speed-gpu-full.test.ts` (`PHOTIVRA_DISPLAY_VIEW_APP_SCENE=1`). Docs: `docs/WEBGPU.md`, `docs/DISPLAY_VIEW.md`, `docs/QUICK_START.md`. Also removes a leftover merge-conflict marker from this changelog.
102
+ - **Display view: the Speed kernel on the GPU (#478), the renderer behind `prepareDisplayView(request, { gpu: "auto" | "require" })` for the chart presets.** No result changes with the default `gpu: "off"`, and none for requests the kernel does not cover (AHD, diffraction, caller scenes, coarser views: the CPU reference, labelled `unsupported-on-backend`); the GPU session registers the renderer when it loads unless a caller registered one. `src/gpu/wgsl-display-view-speed-kernel.ts` renders the display view's exact area mean without diffraction (the 2 × 2 box pixel aperture, bilinear demosaic, chart materials, the identity or positive Reinhard tone curve) one display pixel per invocation: the footprint's real pixels and overlap weights, every native site's expected electrons from the material lookup at the four query points and a float64 table, the readout moments and white clip, white balance, highlight clip, colour matrix, tone curve and clip to [0, 1] with the skewness term, the expected value, the Stein covariance with the closed-form B_c and the residual; the whole region in one dispatch when it fits the band. The host (`src/gpu/display-view-speed-gpu.ts`) computes every constant quotient in float64 (overlaps over the scale, axis sums over the squared scale, 1/Σw²) and finishes each pixel with the reference's own float64 functions: the quantization term, **the seeded noise draw (bit-identical to the CPU: the same stream, seed and portable normals)**, the Cholesky factor, the clipped centre, the sRGB encode and the rounding; a sample equals the CPU's except where the moments' bounded difference crosses a code boundary (at most one code). Clipped-normal moments are evaluated in standardized, cancellation-free forms (algebraically the reference's). The material lookup is a module (`DisplayViewGpuMaterialModule`, the seam of #477); `display-view-chart-material-module.ts` provides it for the eight 2.0 chart presets from the chart kernel's classification with a doubled safety factor, and flagged display pixels are recomputed with the reference. The renderer implements #481's `DisplayViewGpuRenderer` (`createDisplayViewSpeedGpuRenderer`, `displayViewSpeedGpuRendererFactory`, the self-check case `display-view:speed:checkerboard-13x9`) and refuses AHD, diffraction, caller scenes, coarse (stratified) views and footprints above 6 real pixels per axis with `unsupported-on-backend`. `DisplayViewSetup` gains an optional `gpu` field (the expected signal's input and the resolved chart) that the CPU renderer ignores.
103
+ - **Bounds** (docs/WEBGPU.md, "Display view (Speed) on the GPU"): expected value 2·(γ_64 + 2^-40) relative plus a running absolute term for the clip steps (1 400E per clipped standard deviation per clip side), variance 2·(γ_80 + 2^-40) plus 36 000E per clipped variance, with E = 2^-23 (one WGSL rounding in either direction, #471); Reinhard adds 256E and 512E. Measured per pixel against the CPU reference on nine small fixtures and the 24 MP 6 µm frame at 2028 × 1352 (float32 emulation, and SwiftShader for the small ones): every component inside its bound using at most 4.1 % of it; ΔE2000 at most 4.3e-5, at most 3.3e-4 noise standard deviations, σ within 2.9e-4 relative at the white clip and 6e-6 elsewhere.
104
+ - **Timing: TBD-TIMING.** No GPU time is measured (SwiftShader is not a GPU); the owner's hardware run (#475) records it. Measured on this host: the host finishing costs 1.22 µs per display pixel (3.4 s for a whole 2028 × 1352 view, one thread; the quantization term's `portablePow` 0.56 µs, the draws 0.28 µs, the encodes 0.22 µs), a floor of this design that the doc discusses.
105
+ - Tests: `test/display-view-speed-gpu.test.ts` (emulation: bounds, ΔE2000 and σ units, flagged pixels, banding and regions, the draw, the support check, the self-check, `prepareDisplayView({ gpu: "auto" })` with the renderer registered), `test/display-view-speed-gpu-full.test.ts` (2028 × 1352), `test/webgpu/display-view.webgpu.ts` (SwiftShader, 10 tests). The display-view hash locks are unchanged.
106
+ - **Preview acceptance harness for the GPU display view, built against the CPU path (#480; the gate of #478 and #479 in the #473 epic).** Measurement tooling only; no result changes.
107
+ - **The script:** `node scripts/accept-display-view-gpu.mjs` renders the app's display-view request (24 MP 6 µm, the 2 × 2 box, a caller material scene with the stand-in label, f/8; the five fixture charts at 2028 × 1352 and 2800 × 1867, Speed and Quality: 20 cases) with a "reference" producer and a "candidate" producer and compares the two noise-free expected images per pixel: ΔE2000 between the sRGB display colours (the engine's own CIEDE2000 and CIELAB with the sRGB space of `resolveRgbColorSpace("srgb")`) and the expected-value difference of each component in units of the reference's noise σ. It reports the fraction within ΔE2000 1, the maximum and mean ΔE2000, the maximum |Δ|/σ, the maximum difference in codes and the identical-pixel fraction, writes a difference image per case (the engine's PNG encoder, |Δ| in codes × 16, "Difference image, not a capture" in its text metadata) and prints pass or fail per threshold (parameters; defaults from #473: 99 % within ΔE2000 1, none above 2.3, no pixel beyond 1 σ). The "no structured difference at 1:1" check is the owner's visual review of the listed images. `--timing` adds the candidate's whole-view time and the reference timed as the benchmark does (prepare, sampled 96 × 96 parts, extrapolated whole view); the CPU pool's wall time is measured in the app, not here. `--record <name>` writes `docs/validation/<name>/` in the format of the October 26 record. The reference is always the CPU display view; `--candidate path/to/producer.mjs` selects another producer module (contract in `scripts/lib/display-view-cpu-producer.mjs`), where a GPU producer plugs in later.
108
+ - **Self-check (the acceptance of #480):** with the CPU display view as the candidate, all 20 cases at both sizes show exact equality: every expected value, noise σ and drawn sample identical, ΔE2000 0 and |Δ|/σ 0 in every pixel, every threshold passed (`docs/validation/display-view-acceptance-self-check-2026-10-27/`; Node 24.21.0, one process per case, 4-vCPU Xeon under load from other work, 22 minutes; whole-view renders 17–23 s in Speed and 32–49 s in Quality; difference images not committed, all black, 52–100 KB each).
109
+ - **Shared code:** the app request shape, chart, stand-in label and parts of `scripts/benchmark-display-view.mjs --app` moved unchanged to `scripts/lib/display-view-app-request.mjs` (the benchmark re-exports them; its rows render the same request). The reduction, thresholds and difference image live in `scripts/lib/display-view-acceptance.mjs`, tested in `test/display-view-acceptance.test.mjs` on synthetic arrays and on colour identities computed from the definitions (no published CIEDE2000 test pairs are asserted, as `THIRD_PARTY.md` records). Docs: `docs/WEBGPU.md` and `docs/DISPLAY_VIEW.md`, "Preview acceptance".
110
+
111
+ - **Display view: the engine API through which `prepareDisplayView(...).render()` chooses the GPU or the CPU reference, and labels which ran (#481).** No result changes: the option defaults to `"off"`, and no GPU display-view renderer exists yet (#478, #479), so every render is still the CPU reference. This is the plumbing those kernels slot into.
112
+ - **The option:** `prepareDisplayView(request, { gpu, gpuDevice })`. `gpu: "off" | "auto" | "require"` (default `"off"`) in the accelerator's words: `"auto"` is `backend: "webgpu-f32"` with `fallback: "cpu-reference"`, `"require"` is `fallback: "fail"`. `gpuDevice` is a worker's `navigator.gpu` to acquire a device from, or an `ImageKernelAccelerator` whose device the view shares and leaves to its owner; without it the engine acquires one device per prepared view, and the new `prepared.dispose()` releases it. The realm's `setWebGpuEnabled(false)` vetoes both GPU modes (`webgpu-disabled`). Until a renderer exists, `"auto"` runs the CPU reference labelled `unsupported-on-backend` and `"require"` fails with it, and no device is requested. The GPU code loads on demand, so bundles on the default do not contain it.
113
+ - **Device handling:** one device per prepared view, shared by its parts; the request's `signal` aborts the acquisition and a render's `signal` its dispatch; GPU renders of one view run one at a time (concurrent renders wait); a lost device falls back to the CPU for that render and the rest of the view (`device-lost`), or fails with it in `"require"`.
114
+ - **Conformance at run time:** before a device's first display-view render a short self-check runs once: the built-in cases run the existing `webgpu-f32` convolution kernel on two small seeded images against the float64 reference within its a-priori bound, then the renderer's own cases. A failure keeps the view on the CPU (`conformance-failed`) with the adapter and the failing case recorded.
115
+ - **The label:** `diagnostics.fast` of a display view gains `execution` (`DisplayViewExecution`): `ImageKernelExecution`'s fields plus `gpu`, `adapter` (the device's `GPUAdapter.info`) and `conformance`. A CPU render keeps `backend: "cpu-reference"` and `assurance: "engine-reference"`; a GPU render reports `webgpu-f32`, its bound and adapter, `diagnostics.fast.assurance` becomes `approximation-with-a-priori-bound`, and the assumption `view.execution` is added, so an app can show "estimated on the GPU" and the comparison rule holds.
116
+ - **The renderer contract** (internal, `src/facade/display-view-gpu.ts`, for #478): `DisplayViewGpuRenderer { id, supports(request), render(part), conformanceCases? }`, registered by a factory per device through `setDisplayViewGpuRendererFactory`. The only implementation is the test fake.
117
+ - **Accelerator:** `ImageKernelAccelerator` gains `adapter` (its device's `GPUAdapter.info`, null for `cpu-reference`); new type `ImageKernelAdapterDescription`; `ImageKernelFailureReason` gains `conformance-failed`.
118
+ - **Tests** (`test/display-view-gpu-option.test.ts`, with the fake WebGPU device and a fake renderer): the choice, the veto, the handle forms, the self-check and its failure paths, the fallback on loss, disposal, single flight, the abort and the label; `"off"` equals `simulateDisplayView` byte for byte and the display-view hash locks are unchanged. No hardware run yet (the development host has no GPU).
119
+ - New root exports (types): `DisplayViewGpuMode`, `PrepareDisplayViewOptions`, `DisplayViewGpuFallbackReason`, `DisplayViewGpuConformance`, `DisplayViewExecution`, `DisplayViewFastReport`, `ImageKernelAdapterDescription`. Docs: `docs/DISPLAY_VIEW.md` ("Rendering on the GPU"), `docs/WEBGPU.md` ("The three GPU entry points"), `docs/USAGE.md`, `docs/QUICK_START.md`; API reference regenerated.
120
+
121
+ - **GPU chart radiance for every composite-chart element and for mask layers (#477; prerequisite of the GPU preview, #473).** No result changes: the float64 reference is untouched, and every GPU value is still the reference rounded once to float32.
122
+ - **The kernel:** a second `webgpu-f32` chart-radiance kernel classifies `composite` charts, the 2.3 elements as plain charts (`dead-leaves`, `bar-groups` with numerals, `hyperbolic-wedge`, `zone-plate`, `slanted-squares`, `line-grid`, `fiducial`), the sine-profile Siemens star, and declared `material-layers` scenes; the eight 2.0 presets as plain charts keep their kernel. Every decision carries an a-priori bound (E = 2^-23 per WGSL rounding, 2.5 ULP per division, the point's low float32 part, 4× safety, 10^-14 slack) and flags the point for host recomputation when it is not decided. The zone plate's phase and the stars' sector tests run in double-float32 on hi + lo points; a star is a table of sector boundary directions with each sector's level read from the reference; cos is a Taylor polynomial in bound arithmetic. No WGSL `sin`, `cos` or `atan2` decides a material. A device probe checks round-to-nearest, unreassociated arithmetic once per accelerator and refuses the kernel otherwise (`unsupported-on-backend`). A scene's records are uploaded once per accelerator (keyed by the scene's data and aspect, four kept) and reused by later calls. `evaluateChartRadiance` consults `getWebGpuEnabled()` on every call (off: the reference runs, labelled `webgpu-disabled`).
123
+ - **Declared material scenes:** `ChartMaterialLayerScene` (`kind: "material-layers"`: a chart under up to 16 shape-mask layers of polygons and annuli in the mask's own units, each with a 64 × 16 cell index) and `resolveChartMaterialLayers({ scene, aspect })`, which gives the float64 `materialAt`, the material list and `callerScene` (the same scene as a caller material scene for `simulateCapture`). `ChartRadianceBatchInput.scene` accepts it; the plan gains `layers` and `materials`. An application describes its printed label as a layer; the engine holds no label.
124
+ - **Measured** (float32 emulation and SwiftShader, one element of every kind at an application-like placement, pixel centres of a 2028 × 1352 view): every unflagged value equalled the reference rounded once; flagged fractions 0 for twelve element kinds, 0.02 % for dead leaves and the hyperbolic wedge, 0.46 % for the zone plate and 0.50 % for the grey ramp, 0.013 % inside a label layer; on SwiftShader 0.21 % for the bar star (pixel centres on its diagonal sector boundaries) and 0.39 % for the zone plate, the rest at or below 0.31 % (the 1 % finding threshold is not reached). Details in `docs/WEBGPU.md`, "Composite charts, chart elements and mask layers". Finding (#471, not this kernel): at 8 of 198 recomputed bar-star points, Node's and Chromium's `Math.atan2` differ by one unit in the last place, so the two realms' float64 references disagree at points within that rounding of a sector boundary; the stage test records these as `realmDisagreements`. The per-fixture rollout extends to an all-elements composite (pinned method error measured on float64).
125
+ - **Tests:** `test/chart-composite-gpu.test.ts` (per-element conformance and flagged fractions, the all-elements composite per element, plain 2.3 presets, layers resolution against the full shape test, malformed scenes, record packing and star tables, upload reuse and release, the probe refusal and retry, domain and limit refusals), `test/webgpu/stages.webgpu.ts` and `test/webgpu/rollout.webgpu.ts` (SwiftShader), `test/webgpu-rollout.test.ts` (CI subset). `test/chart-elements.test.ts` now expects the 2.3 presets to run on `webgpu-f32`.
126
+ - New root exports: `resolveChartMaterialLayers`, `CHART_MASK_INDEX_GRAIN`, `CHART_MATERIAL_LAYER_LIMITS` and the types `ChartMaterialLayerScene`, `ChartMaskLayer`, `ChartMaskShape`, `ChartMaskPolygon`, `ChartMaskAnnulus`, `ResolvedChartMaterialLayers`, `ResolvedChartMaskLayer`, `ResolvedChartMaskShape`. Not done in this change: the hardware-device runs of #475 for the new kernel.
127
+ - **One boolean to turn the engine's GPU use on or off (#474).** No result changes: the GPU was already opt-in per call, and this adds the simple form of the request and one switch for the whole realm.
128
+ - **The option:** `createImageKernelAccelerator({ webgpu: true })` is `backend: "webgpu-f32"` with `fallback: "cpu-reference"` (add `fallback: "fail"` to fail instead of falling back), and `{ webgpu: false }` is `backend: "cpu-reference"`. `webgpu` and `backend` exclude each other; `backend` is now optional when `webgpu` is given. The explicit form is unchanged.
129
+ - **The switch:** `setWebGpuEnabled(false)` keeps every accelerator in the JavaScript realm on the CPU reference whatever callers ask. A request for `webgpu-f32` then runs the reference with the new `fallbackReason` `webgpu-disabled`, or throws `ImageKernelExecutionError` with that reason in fail mode, and no adapter is requested. `setWebGpuEnabled(true)` is the default; `getWebGpuEnabled()` reads it; anything but a boolean is refused with `InvalidConfigurationError`. A Web Worker has its own switch. An accelerator created while the switch was on keeps its device. Every GPU path added later (the preview and fast-capture work of #474) consults the same switch.
130
+ - **Tests** (`test/image-kernel-accelerator.test.ts`): the boolean form resolves to the explicit form and rejects the mixed and non-boolean forms; with the switch off a working (fake) GPU is never asked for, the label and fail reason are `webgpu-disabled`, and the reference's bytes are returned; an accelerator created while on keeps its device after the switch is turned off.
131
+ - New root exports: `getWebGpuEnabled` and `setWebGpuEnabled`; `ImageKernelAcceleratorOptions` gains `webgpu`; `ImageKernelFallbackReason` gains `webgpu-disabled`. Docs: `docs/WEBGPU.md` (Contract) and `docs/USAGE.md`.
132
+
133
+ - **WebGPU conformance suite in CI, a hardware record and per-stage timing summaries (#476, items 2–4).** Tooling and documentation only; no engine result changes.
134
+ - **CI:** a new workflow, `.github/workflows/webgpu.yml` (job `webgpu conformance (SwiftShader)`), runs `npm run test:webgpu` with `PHOTIVRA_WEBGPU_REQUIRED=1` in Playwright's Chromium (pinned `playwright@1.63.0`, Chromium build 1243, installed in the job; no npm dependency added) on Google SwiftShader. It runs on pull requests that change `src/gpu/`, `test/webgpu/`, `vitest.webgpu*.config.ts` or the workflow, on every push to `main` and on manual dispatch, so its cost stays bounded (about four minutes a run). It checks the kernels' arithmetic against the float64 reference; it measures no GPU speed.
135
+ - **Hardware record:** `docs/WEBGPU_DEVICES.md`, one row per hardware run (device, adapter, driver, browser, OS, engine commit, suite, result, timings file), seeded with the owner's Apple M5 Pro runs from #277 and #471 (6–24 MP timings of October 26 and 27, 2026, and the conformance run that passed 82 of 89 tests); the October 27 timing report is kept as `docs/validation/webgpu-devices/2026-10-27-m5-pro-bench.json`. `docs/WEBGPU.md` ("Running on hardware", "Timings at 6–24 MP", "Browser support and running the suite") and `docs/DEVELOPERS.md` link it.
136
+ - **Timing summaries:** `npm run bench:webgpu` and `npm run bench:webgpu:hardware` print, after the JSON report, one line per frame and stage with the GPU milliseconds, the CPU milliseconds (with `PHOTIVRA_WEBGPU_TIMING_CPU=1`) and their ratio CPU ÷ GPU (`test/webgpu/timing-summary.ts`, computed from the report; the measurements are unchanged). Tested in `test/webgpu-timing-summary.test.ts` against the values posted on #277. Both WebGPU Vitest configurations now use the verbose reporter, because Vitest 5's default reporter hides a passing test's console output, which is where the suites print the adapter, the measured maxima and these summaries.
137
+
138
+ ### Fixed
139
+
140
+ - **GPU: the composite chart kernel's device probe names what differs, and a device that fuses multiply-adds runs an fma product path (the owner's Apple M5 Pro run of October 27, 2026).** The first hardware run of the preview acceptance harness (`npm run accept:gpu:hardware`, Apple M5 Pro, Chrome, WebGPU on Metal, engine `main` at 14f9032) refused all 26 cases: every case is a `material-layers` scene, and the composite kernel's double-float32 probe failed ("does not evaluate the composite chart kernel's double-float32 arithmetic as written (reassociated or fused)"); 5 of the 26 were reported as the harness's cleanup error instead (below). The same day's `test:webgpu:hardware` run failed its 27 composite tests with the same refusal and passed the other 138. WGSL lets an implementation fuse a multiply and an add into one rounding and reassociate (W3C WGSL § 15.7.5 "Reassociation and Fusion"), and lets `fma` be unfused (§ 15.7.2, § 17.5.32); which of these Metal does is **not known until the owner's re-run** (a hypothesis, not a finding).
141
+ - **Diagnostics.** The probe now also evaluates an fma TwoProduct and an fma double-word product, compares every component with its exact value (the double-word products with the written-order evaluation for each allowed contraction of their cross-term sum), and returns a `ChartCompositeProbeRecord` (new root types `ChartCompositeProbeRecord`, `ChartCompositeProbeComponent`, `ChartCompositeProbeMismatch`): the device class (`exact`, `contracted`, `reassociated`, `other`), the product path, whether `fma` is fused, and per component (TwoSum `s.l`, TwoProduct `m.l`, double-word add `a.l`, double-word multiply `q`, and the fma path's `mf.l`, `qf`) the count and input indices that differ, with the first eight differences' float32 bits. The refusal's message carries the same in one line. `DisplayViewExecution` gains `compositeProbe` (the record, or null when the render did not run the probe), on GPU renders and on the CPU fallback of `"auto"`; the error of `"require"` carries it as `probe`.
142
+ - **The fma path.** TwoProduct as fma(a, b, −p) and a double-word product that never adds a rounded product (hi = fma(a.h, b.h, t), lo = fma(a.h, b.h, −hi) + t), so a fusion cannot change it; within 9u² of the exact product to first order (measured at most 5.3u²), inside the declared 2^-40. The composite kernel and the display view's composite material module are compiled with Dekker's path when its probe passes (so an unfused device keeps its results and flags bit for bit: no CPU byte changes, every hash lock holds), else with the fma path when it passes, else the device is refused as before. Every other place that might rely on unfused evaluation is shown unaffected or covered (docs/WEBGPU.md); the bound arithmetic stays valid under fusion, since a fused operation rounds once.
143
+ - **Evidence** (emulation; no hardware yet): the fake device gains a fused-contraction mode (every multiplication an addition or subtraction consumes, through `let` bindings and calls, fused into one rounding, with `fma` fused or unfused) and a reassociation mode. Under contraction the old probe's criterion fails as on the M5 Pro, the new one reports `contracted` and picks the fma path, and composite radiance (44 800 queries per scene) and the Speed (8 fixtures; the six frame-filling ones on a 48 × 36 region) and Quality (2 regions) display views of the #491 fixtures render with no more flagged pixels than unfused: ΔE2000 at most 4.7 × 10⁻⁵ (Speed) and 1.9 × 10⁻⁴ (Quality), every component inside its bound, the #480 thresholds met. With an unfused `fma` the device is refused as `contracted`, under reassociation as `reassociated`. On SwiftShader the probe reports `exact` and keeps the Dekker path; SwiftShader's `fma` is not fused (`fmaFused: false`), so there the fma path is compiled (kernel and both display-view shaders) but its values cannot be checked. Tests: `test/chart-composite-contraction.test.ts`, `test/display-view-composite-contraction.test.ts`, `test/webgpu/stages.webgpu.ts` (the probe record and both paths on the device). The emulation's `bdiv` bound now uses the kernel's 5·E term (it had 2.5·E); no emulation test changed.
144
+ - **The re-run found reassociation**, which the fma path does not survive: on the probe-fix commit the M5 Pro's probe reported class `reassociated` (TwoSum `s.l` 15 of 16 inputs, `m.l` 12, `a.l` 15, `q` 13, every differing low part zero; the fma components matched). See the next entry.
145
+ - **GPU: a rounding barrier keeps Metal's compiler from rewriting the composite chart arithmetic; composite charts and `material-layers` scenes now render on the GPU on the Apple M5 Pro.** Every operation of the double-float32 error-free transforms and every value of the bound arithmetic is wrapped in `ob(x) = bitcast<f32>(bitcast<u32>(x) ^ zero)`, `zero` a uniform word the host sets to 0 (`params.zero`, `lattice.zero`, the probe's `probe_zero`), which a compiler cannot see through, so it can neither regroup nor fuse across it (W3C WGSL § 15.7.5). XOR with 0 changes no bit: on a device that already evaluates as written (SwiftShader, the emulation) values, flags and bytes are unchanged, and no hash lock changes. **Measured on the M5 Pro** (one run each, engine `9ce630b` with the barrier): the probe reports `exact` on the Dekker path, every component bit-exact; both composite scenes equal the reference at every unflagged query (77 640 each; 75 and 81 flagged); `npm run accept:gpu:hardware` PASS, 26 of 26 on `webgpu-f32` (100.00 % within ΔE2000 1, largest ΔE2000 0.0010, largest |Δ|/σ 0.002, 8-bit codes equal to the reference's in every pixel); `npm run test:webgpu:hardware` 167 of 167. Render times of that run (informational, one run): GPU 2.2–16.3 s per view against the CPU reference's 9.1–27.3 s, except dead leaves in Quality (21.7 s against 23.6 s at 2028 wide, 28.4 s against 25.1 s at 2800 wide). Tests: `test/chart-composite-barrier.test.ts` (every shader variant reads its zero word, no operation of the transforms is unwrapped, the host writes 0). Another device is still judged by its own probe.
146
+ - **Tests: the composite emulation fits the coverage shards again.** The arithmetic models of `test/helpers/fake-webgpu-composite.ts` had made the default (round-to-nearest) emulation about 5.5 times slower under V8 coverage (`test/display-view-composite-gpu.test.ts` 95 s → 519 s), and the new contraction tests rendered full views (1 370 s), so two coverage shards hit their 15-minute limit. The nearest model now runs its bound arithmetic and double-word blocks without a call per operation (519 s → 111 s; the same arithmetic, operation for operation), and `test/display-view-composite-contraction.test.ts` drops the unfused renders that `test/display-view-composite-gpu.test.ts` already makes and renders the six frame-filling Speed cases on a fixed 48 × 36 region, where the contracted render must flag no more pixels than the unfused one (measured: equal), keeping the full view for the fixed-size charts (1 426 s → 192 s for both contraction files).
147
+ - **`npm run test:webgpu` and the acceptance harness: the browser cleanup no longer fails a case (`ENOTEMPTY` on macOS, the same M5 Pro runs).** `test/webgpu/chromium.ts` removed the Chromium profile right after SIGKILL, while the browser's processes could still write to it. The cleanup (`cleanupBrowser`) now waits for the process to exit (up to 10 s), removes the profile with five retries 200 ms apart, and logs a failure instead of throwing, so a cleanup problem can no longer turn a case's result into an error. Tests: `test/webgpu-chromium-cleanup.test.ts` (a fake child that exits late, a removal that fails, a process that never exits).
148
+ - **The acceptance harness prints the composite probe per case**: its class, the product path the composite kernel ran with, and the differing components with their inputs, on a GPU render and on a refusal alike, and records it with each case's execution label.
149
+ - **GPU display view (Speed): the variance bound names the output quantization term (#496).** The host evaluates the quantization variance q(m) = s(e)²·Δ²/12 at the GPU's mean, so it moves by up to 6.04·Δ²/12 per unit of mean error; the Speed host now adds Σ(w/A)²·(6.04·Δm + the segment-join jump)·Δ²/12, doubled, to each display pixel's absolute variance term, as the Quality host does (#479). Before, this was covered only incidentally by the γ_80 relative term. No computed value, CPU result or hash lock changes; only `varianceBound.absolute` and the per-pixel variance bounds grow. On the Speed fixtures every component stays inside its bound; the largest fraction of the variance bound used falls from 4.0 % to 3.9 % on the chart presets and from 4.1 % to 4.0 % on the composite fixtures (Reinhard at the white clip: 28.7 % before and after). Docs: `docs/WEBGPU.md`, the Speed derivation table. Test: `test/display-view-speed-gpu-quantization.test.ts`.
150
+ - `npm run test:webgpu`: the Chromium driver now polls the DevTools target list for the first page instead of reading it once; Chromium can print its DevTools endpoint before `about:blank` is registered, which failed one CI run of the suite with "Chromium exposed no page target" (#488).
151
+ - **WebGPU sensor stage and Siemens star: the `webgpu-f32` kernels no longer depend on correctly rounded division or on the order of float32 sums, and their margins are derived from the WGSL accuracy table (#471).** The first hardware run (Apple M5 Pro, Metal) found unflagged sites that differed from the reference in every regime with a non-unit gain, and one Siemens-star boundary point in the wrong sector. The CPU reference, its hashes and every `cpu-reference` result are unchanged.
152
+ - **Assumptions, restated.** `docs/WEBGPU.md` now states the W3C WGSL "Floating Point Accuracy" table (§15.7.4, with citation) as the only assumption: WGSL fixes no rounding direction, so one `+ − ×` is within one ULP (ε = 2^-23 relative, not 2^-24); `/` is 2.5 ULP; `sqrt` inherits `1/inverseSqrt` (4.5 ULP); `exp`, `log` have their table bounds; and an implementation may reassociate. The doc derives every sensor-stage margin from it in a new section, "Sensor-stage margins (derivation)".
153
+ - **Sensor kernel.** The integer part of signal/gain and of the digital-gain product is now formed in 32-bit integer arithmetic from host-computed 12-bit pieces (mantissa, exponent, exact scale), so no exact floating-point split depends on the evaluation order; the INL offset uses host-computed segment slopes instead of a division; the Box–Muller and PTRS quotients carry the 2.5 ULP bound; every multi-term sum carries the order-independent bound (n − 1)·ε·Σ|terms|; and the margins are written in ε. The flagged share rises modestly: on the SwiftShader conformance corpora (256 × 128 sites) from 95–117 to 109–144 flagged sites per regime (0.3–0.4 % → 0.3–0.4 %), and the inexact digital gain from 804 to 823 (its flags are the exact half-code ties of 1.3 × integer); the fixed-pattern variants from 104–129 to 131–154 and 1443 to 1466. No unflagged site differs.
154
+ - **Siemens star.** The sector is found by a binary search on cross-product signs against the host's float64 boundary directions, in the half-plane chosen by the sign of v; the kernel calls no transcendental function. The failing point itself turned out to be a realm difference, not a GPU error: ECMAScript does not fix the last bit of `Math.atan2`, Node 24 and Chrome 153/155 differ by one ulp there, and the suite's point placed exactly on a sector boundary classifies light in Node and dark in the browser (SwiftShader and Metal alike). The GPU had flagged it and recomputed it with the browser's reference. The conformance suite now compares against the reference of the browser realm and reports realm differences, which must be flagged boundary points; the doc records the dependence.
155
+ - **Evidence.** `test/webgpu-adversarial-arithmetic.test.ts`: the fake device's float32 emulation gains an adversarial arithmetic (random rounding direction for every `+ − ×` and conversion, division off by up to 2.5 ULP, `sqrt`, `log`, `exp` at their table bounds, random summation order and a reassociated INL step); on the six sensor regimes, their fixed-pattern variants (128 × 64 sites, three seeds) and all eight chart presets no unflagged site or point changes, and a dense Siemens-star boundary sweep (every boundary at twelve radii, exact, one float32 ulp off, 10^-9 and 10^-5 off, the u-axis, the ±π cut and signed zeros) equals the reference. The SwiftShader suite (`npm run test:webgpu`) passes (89 tests). **The hardware re-run on the M5 Pro is pending** (#475); the sensor failures do not reproduce on SwiftShader, which divides exactly, so for them the emulation is the evidence until then.
156
+ - Internal layout change only: the sensor kernel's auxiliary table (`SENSOR_AUX`, three words per piece, INL slopes) and one more uniform (the largest INL knot code). A digital gain outside [2^-60, 2^60] is now `unsupported-float32-domain`.
157
+
158
+ ## 2.10.0 — published October 27, 2026
4
159
 
5
160
  A minor release: one exact-or-fast flag for every slow option (no option has a fast method yet, so every result is still exact), export and import of the display view's AHD correction states so workers can share them, and faster display-view prepares and parts and faster Quality captures. Samples, RAW codes and files are the same as 2.9.0; captures and display views gain the `diagnostics.fast` report.
6
161
  - **New:** `fast`, `SIMULATE_CAPTURE_FAST_OPTIONS` and `diagnostics.fast` (#395, stage 2). `serializeDisplayViewCorrectionStates`, `bindDisplayViewCorrectionStates` and three `PreparedDisplayView` methods share the AHD correction states between workers (#447); no speed-up is claimed until the app measures it.
package/CITATION.cff CHANGED
@@ -9,7 +9,7 @@ abstract: "An open TypeScript engine for the science and mathematics of photogra
9
9
  repository-code: "https://github.com/photivra/photivra"
10
10
  url: "https://photivra.com"
11
11
  license: "Apache-2.0"
12
- version: "2.10.0"
12
+ version: "2.12.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/NODE_SUPPORT.md CHANGED
@@ -27,7 +27,7 @@ Snapshot, reverified against the official schedule on 2026-10-02 (original polic
27
27
  - Node 22 is Maintenance LTS.
28
28
  - Node 26 is Current and is in the support set.
29
29
 
30
- This is a dated snapshot. Review and test future lines as the official lifecycle changes. Node 26 stays supported when it enters LTS; from that date its runtime must carry the LTS marker.
30
+ This is a dated snapshot. Review and test future lines as the official lifecycle changes. Node 26 stays supported when it enters LTS; from that date its runtime must carry the LTS marker, after the grace period described under "CI and dependency enforcement".
31
31
 
32
32
  ## Repository baselines
33
33
 
@@ -41,7 +41,9 @@ CI picks the patched lines with setup-node aliases that move by themselves:
41
41
  | --- | --- | --- |
42
42
  | `lts/*` | newest LTS | Primary leg. Also runs the static, coverage, documentation and package-surface gates. |
43
43
  | `lts/-1` | the LTS line before it | Test leg. |
44
- | `node` | newest Current | Test leg. |
44
+ | `node` | newest stable release (Current, or the newest LTS while no Current line has a stable build) | Test leg. |
45
+
46
+ CI resolves `node` itself to the newest stable version in `https://nodejs.org/dist/index.json` (`.github/node-lts-lookahead.mjs --newest-stable`), because setup-node's alias takes the index's first entry, which can be a prerelease: `v27.0.0-alpha.0.0.0` was listed first on 2026-10-29 and every `node` job failed the guard with "Use a stable, exact Node/typings version" ([#511](https://github.com/photivra/photivra/pull/511)).
45
47
 
46
48
  Every leg runs the test suite and the packed-consumer acceptance check.
47
49
 
@@ -66,7 +68,7 @@ A broad `engines.node` range is a compatibility hint. It is not evidence of supp
66
68
  - reviewed upstream LTS and Current date windows;
67
69
  - the approved pinned typings major.
68
70
 
69
- `.github/test-node-lts-policy.mjs` tests the acceptance and rejection boundaries. Both scripts run before dependency installation in every Node CI job. That includes each verification matrix leg and the `native-performance.yml` benchmark job (path-filtered and nightly).
71
+ `.github/test-node-lts-policy.mjs` tests the acceptance and rejection boundaries, the grace period and the look-ahead against recorded release data. Both scripts run before dependency installation in every Node CI job. That includes each verification matrix leg and the `native-performance.yml` benchmark job (path-filtered and nightly).
70
72
 
71
73
  The lifecycle snapshot is local and reviewed. Ordinary CI does not fetch it from the network. It uses UTC dates and fails closed on the recorded end-of-life date.
72
74
 
@@ -78,6 +80,20 @@ Reverify the official schedule:
78
80
 
79
81
  Do not extend an expired window, or admit an unreviewed line, just to pass CI. The Current window ends when the line enters LTS; from then the runtime must carry an LTS marker. A new Current line needs a coordinated review of the snapshot and the CI matrix.
80
82
 
83
+ ### LTS marker grace period
84
+
85
+ An LTS window opens at 00:00 UTC on the scheduled date, but Node publishes the line's first LTS build during that day, and setup-node can offer it only after that. In `https://nodejs.org/dist/index.json`, the first LTS build of every line from Node 4 to Node 24 is dated on its scheduled LTS date: 11 of 11 lines, none late. Node 26 had no LTS build at 02:48 UTC on 2026-10-28, its LTS date, so every job on the `node` alias failed with "An LTS Node build must carry its LTS marker."
86
+
87
+ For **7 days** from a line's LTS start, the guard therefore accepts a build of that line without the LTS marker. Seven days is a margin over that record, enough for a release that slips by a week. It is not a measured delay. After the grace period the marker is required again. The grace period changes nothing else:
88
+
89
+ - A Current build in its Current window still must not claim an LTS marker.
90
+ - Other majors, prereleases and end-of-life dates are checked as before.
91
+ - A build that carries the LTS marker passes as before.
92
+
93
+ ### Checking ahead
94
+
95
+ No scheduled job checks the policy ahead of time; the guard in every CI job is the check, so a date boundary first shows as a failing required check on the first run after it. To see a boundary coming, for example before a release, run `node .github/node-lts-lookahead.mjs`. It fetches `https://nodejs.org/dist/index.json`, takes the newest stable release of each supported major, and runs the policy guard against it at 00:00 UTC on today and each of the next 14 days. A failing date names the major, the first failing date, the build and the reason. Do not widen a window just to clear it.
96
+
81
97
  Routine minor and patch updates within an approved major may follow normal green-PR rules. Do not auto-merge a major upgrade of the Node runtime, a Node image, or `@types/node` on its own. Where Dependabot is configured, keep Node typings major upgrades out of its routine queue, while still allowing updates within supported majors.
82
98
 
83
99
  ## Lifecycle changes
package/README.md CHANGED
@@ -32,10 +32,10 @@ The source is licensed under Apache-2.0.
32
32
 
33
33
  ## Quick start
34
34
 
35
- Install the published ESM package below. The [2.10.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_10_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
35
+ Install the published ESM package below. The [2.12.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_12_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
36
36
 
37
37
  ```sh
38
- npm install @photivra/engine@2.10.0
38
+ npm install @photivra/engine@2.12.0
39
39
  ```
40
40
 
41
41
  Simulate a photograph of a test chart with a generic camera, then read the developed sRGB pixels:
@@ -75,7 +75,7 @@ console.log(horizontal.provenance);
75
75
 
76
76
  Start with the [Developer Guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Then use the [Usage Guide](https://github.com/Photivra/photivra/blob/main/docs/USAGE.md) and the [API reference](https://github.com/Photivra/photivra/blob/main/docs/API_REFERENCE.md), which is generated from the package exports.
77
77
 
78
- Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.10.0` tag.
78
+ Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.12.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,13 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.10.0` (release candidate prepared October 27, 2026; publication is separate)
367
- - Engine API contract: `2.10.0`
366
+ - Repository package version: `2.12.0` (release candidate prepared October 31, 2026; publication is separate)
367
+ - Engine API contract: `2.12.0`
368
368
  - Versioned generic tier reference assets: [GENERIC_TIER_PRESETS.md](https://github.com/Photivra/photivra/blob/main/docs/GENERIC_TIER_PRESETS.md).
369
369
  - Composed POC simulation API contract: `1.0.0`
370
370
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
371
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.10.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
372
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.12.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
373
 
374
374
  A breaking change to the root contracts now needs a new package major version. Additive and corrective changes follow [compatibility and migration](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_1_3_0.md). A 1.0 package is not a claim of calibrated physical accuracy.
375
375
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ Please do not open a public issue that contains vulnerability details.
4
4
 
5
5
  ## Supported versions
6
6
 
7
- The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.9.0**, published on October 26, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
7
+ The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.11.1**, published on October 31, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
8
8
 
9
9
  Security fixes target the current maintained development and release line. Do not assume that older 1.x releases, snapshots or pre-1.0 releases will receive fixes.
10
10
 
@@ -59,3 +59,9 @@ export declare function portableCbrt(x: number): number;
59
59
  export declare function portableCosTwoPi(u: number): number;
60
60
  /** sin(2πu) for finite u (u in turns), bit-identical in every JavaScript engine (within 2 ulps). */
61
61
  export declare function portableSinTwoPi(u: number): number;
62
+ /**
63
+ * arcsin x in radians for x in [−1, 1], bit-identical in every JavaScript engine (within 2 ulps; `Math.asin` is
64
+ * implementation-approximated). Outside [−1, 1], or NaN → NaN. For |x| ≤ ½ the Maclaurin series; above,
65
+ * arcsin x = π/2 − 2·arcsin √((1 − x)/2) (the half-angle identity; 1 − x and the halving are exact there).
66
+ */
67
+ export declare function portableAsin(x: number): number;
@@ -438,4 +438,43 @@ export function portableSinTwoPi(u) {
438
438
  const t = 4 * u, q = Math.round(t), r = (t - q) / 4, quadrant = ((q % 4) + 4) % 4;
439
439
  return quadrant === 0 ? sinTwoPiReduced(r) : quadrant === 1 ? cosTwoPiReduced(r) : quadrant === 2 ? -sinTwoPiReduced(r) : -cosTwoPiReduced(r);
440
440
  }
441
+ /**
442
+ * Maclaurin coefficients of arcsin x = Σ a_n·x^(2n+1)/(2n + 1), a_n = (2n)!/(4^n·(n!)²), a_n = a_(n−1)·(2n − 1)/(2n)
443
+ * (the binomial series of 1/√(1 − t²) integrated term by term; NIST DLMF 4.24.1, free), for n = 1 … 24. Built from
444
+ * correctly rounded operations only, so the table is the same in every engine. For |x| ≤ ½ the first omitted term is
445
+ * below 2^-56·|x|.
446
+ */
447
+ const ASIN_SERIES = /* @__PURE__ */ (() => {
448
+ const table = new Float64Array(24);
449
+ let a = 1;
450
+ for (let n = 1; n <= 24; n++) {
451
+ a = a * (2 * n - 1) / (2 * n);
452
+ table[n - 1] = a / (2 * n + 1);
453
+ }
454
+ return table;
455
+ })();
456
+ /** π/2 = PIO2_HI + PIO2_LO (PIO2_HI the double nearest π/2, PIO2_LO the double nearest the remainder). */
457
+ const PIO2_HI = 1.5707963267948966, PIO2_LO = 6.123233995736766e-17;
458
+ /** arcsin x for |x| ≤ ½ (Horner on the table above). */
459
+ function asinReduced(x) {
460
+ const z = x * x;
461
+ let p = ASIN_SERIES[23];
462
+ for (let n = 22; n >= 0; n--)
463
+ p = ASIN_SERIES[n] + z * p;
464
+ return x + x * (z * p);
465
+ }
466
+ /**
467
+ * arcsin x in radians for x in [−1, 1], bit-identical in every JavaScript engine (within 2 ulps; `Math.asin` is
468
+ * implementation-approximated). Outside [−1, 1], or NaN → NaN. For |x| ≤ ½ the Maclaurin series; above,
469
+ * arcsin x = π/2 − 2·arcsin √((1 − x)/2) (the half-angle identity; 1 − x and the halving are exact there).
470
+ */
471
+ export function portableAsin(x) {
472
+ if (!(x >= -1 && x <= 1))
473
+ return NaN;
474
+ const ax = x < 0 ? -x : x;
475
+ if (ax <= 0.5)
476
+ return asinReduced(x);
477
+ const result = (PIO2_HI - 2 * asinReduced(Math.sqrt((1 - ax) / 2))) + PIO2_LO;
478
+ return x < 0 ? -result : result;
479
+ }
441
480
  //# sourceMappingURL=portable-math.js.map