Commit Graph
133 Commits
Author SHA1 Message Date
ScottW514 533ae7e46d Move the build, release, and acceptance docs to the documentation site
The documentation site, docs.forgefirm.org, is the one home for the
developer documentation. BUILD.md, kas/README.md, and docs/ACCEPTANCE.md
are on the site under Developers, and they are deleted here. Every
reference points at the site: README.md, BRINGUP.md, UPDATE-SYSTEM.md,
the kas configuration comments, release.sh, the CI workflow comments, and
the releases, forgetest, and bench READMEs.

BRINGUP.md: "Next work" item 16, step timing under CPU contention, is
closed; the video work resolved it. The items after it are renumbered.
CAMPAIGN-LOG.md records the closure.

Documentation and comments only. No layer content changes, so the
manifest identity of the next image does not change. No catalog
consequence.
2026-08-26 10:00:42 -04:00
ScottW514 fff0980079 Laser: the rapids after an M5 ship dark and the next job fires; the performance-curve drill
Stream harness rules 16 and 17 with their sessions: an M5 executed with
the planner drained and the kernel run over must darken the rapids that
follow it (m5-idle), and a job whose M3 runs at the level the previous
job ended at must still fire its first cut (next-job). The second rule
is the core's contract: set_state records the rpm and the per-segment
update is skipped while it is unchanged, so the driver's set_state is
the only thing that can light that move. Both sessions run under both
dose models; the bench build that went dark on its second job fails
next-job with one fire span.

Bench drills: pcurve (a per-level ladder of 100 mm lines read from the
HV current and the head thermopile at 25 Hz, with the instrument checks
and the JSON record) and m5dark (one line, M5, two rapids, judged on the
current trace and laser_on_sampled until the armed window closes).

Catalog: laser.m5-rapid-dark, a live test of the M5 case (46 tests; the
counts in BRINGUP follow). CAMPAIGN-LOG carries the day's record: the
two curve ladders, the defect pair, the root cause, the host and bench
proof.
2026-08-25 17:41:45 -04:00
ScottW514 243320f65d ulfius: client_address carries the whole peer sockaddr; forgectrl.auth asserts the loopback report is accepted
The dual-stack listener reports every peer as a sockaddr_in6; ulfius
2.7.15 copied sixteen bytes of it, so forgectrl's loopback-only cooling
channel refused the controller's every report (403 loopback only) and
the engine never saw a run or an armed window. The recipe carries the
patch: a sockaddr_storage allocation and a copy of the family's length,
in the dispatcher and in ulfius_copy_request.

forgetest: forgectrl.auth asserts POST /cool/state from loopback -> 200
beside the LAN 403, and covers src/peer.*. BRINGUP item 21 and the
campaign log record how the campaign on dev 20260824215906 found it.
2026-08-24 19:02:52 -04:00
ScottW514 4d3d173445 forgetest: image.health asserts the SDMA clock enable count; the gated engine recorded
The check reads /sys/kernel/debug/clk/sdma/clk_enable_count and fails
below 1, naming the fault directly instead of the free-space mismatch it
surfaced as after the 150 s settle. BRINGUP item 21 describes the fault
and the fix; CAMPAIGN-LOG carries the record of how it was found.

Covers: image.health already maps kernel-module-glowforge/** and
linux-fslc/**.
2026-08-24 17:51:18 -04:00
ScottW514 326c3e758b IPv6 on, forgetest dual-stack, the release rootfs sheds 22 MB
The distro keeps its ipv6 feature (busybox networking and ifupdown
inet6, openssh, ntp and rsyslog follow); forgetest binds "::" by
default, one dual-stack socket that still serves IPv4. The release
image drops nano (with it file and the libmagic database, 8.7 MB; the
dev image keeps nano), the udev hardware database (7.7 MB of USB and
PCI identities for buses the board lacks, via BAD_RECOMMENDATIONS) and
urllib3's pyOpenSSL/cryptography recommendation (6 MB; nothing imports
them, TLS goes through the standard library). BRINGUP item 21 records
the round and the bench checks it owes; item 16 gets its re-measure
plan on the UP kernel with the performance governor.

Coverage: image.* covers the rootfs composition and the distro conf
(platform); forgetest's own suite covers the server.
2026-08-24 15:40:19 -04:00
ScottW514 1e059bb90c forgetest: the camera tests cover the IPU stride-fix crop
forgectrl's src/ipu_copy.{c,h} sit on the GPU render path that feeds
both the JPEG grab and the H.264 encoder, so every camera.* test already
exercises them; the coverage lint reported both files uncovered. Added
to _CAM_COVERS. Lint on the tree manifest: 0 uncovered paths, 45 tests.
2026-08-24 14:53:08 -04:00
ScottW514 00ded74189 forgetest: the fixture spaces its presses; an unattended refusal is an error; the baseline resets out of a hold
The first campaign with the bench actuator wired failed
motion.button-hold-resume on the tool, not the machine: the second press
was asked while the first 200 ms pulse was still on, the fixture answered
409, the runner handed the step to an operator who was not in the room,
and the post pass could not jog a controller left in Hold.

- fixture.py: a press waits for the last pulse to end (the fixture's
  pulse_ms) plus a 300 ms release, so the controller sees the edge; a
  409 for a pulse in progress is waited out against button_pulsing and
  retried once.
- runner.py: in an unattended run a fixture refusal ends the test at
  once as ERROR naming the refusal; the operator fallback stays for
  attended runs.
- baseline.py: a controller in Hold or Door gets a soft reset before the
  return jog, position kept.
- tests: the fake fixture refuses a press while one is in progress and
  reports button_pulsing; FakeGrbl records ^X and can land a reset in a
  chosen state; five new tests.
- docs: ACCEPTANCE.md fixture rules, fixture/README.md tool's side.

No catalog consequence: tool-side change, no covers map moves.
Bench: campaign c-20260824174545-0bdc 25/25 with every action by the
fixture; the hold reset proven by a dry drill.
2026-08-24 14:02:59 -04:00
ScottW514 e99888753f forgetest's page moves onto Bootstrap with forgectrl's theme; forgectrl pinned at the panel overhaul
The acceptance page is assembled by page.py from forgetest/forgetest/ui/
(index.html, page.css, help.js, app.js) plus theme.css and the vendored
Bootstrap files, which are byte for byte the ones forgectrl's panel
carries, so the two pages look like one product and share the light and
dark themes (same localStorage key). A plain file is read in a checkout;
on the dev image the recipe installs ui/ gzipped and page.py reads the
.gz sibling, inflating once at first request: the rootfs is raw ext4, so
bytes in the package are bytes on the image. The explanatory prose
(campaign rules, the queues, the campaign actions, the prerequisites
switch, the bench intro) is a "?" popover with a link into the
documentation site; operator steps, prompts, notices and the live-laser
acknowledgment stay in the page, and confirmLive() stays a blocking
dialog. The page's own rules hold: rows, prompt buttons and tool entries
are built once and updated in place, and the popovers sit on static
markup only, so no rebuild orphans one. On a phone the Run pane goes to
the top for the duration of a run.

scripts/check-ui-vendor.py compares the shared files against forgectrl
at its pinned revision (or a local checkout with --forgectrl); it runs
in forgetest-ci.yml, so the copies cannot drift.

Tests: test_page.py (the gzipped install assembles to the same bytes as
a checkout, one self-contained response, the token placeholder once, a
missing marker refused); test_server asserts the served page's
invariants; test_responsiveness keeps its rules with needles pointed at
the new files, its ASCII rule applied to our own sources (Bootstrap's
CSS carries an em dash of its own), and its self-contained rule testing
asset tags rather than the presence of https:// (the documentation links
are meant to be there). forgectrl.panel-serves gains two needles for the
panel's theme attribute and save bar. Proof: the unit suite, and the
page in Chrome against a fake catalog (both themes, popovers, the bench
tab, a full operator run with its prompt, abort).

forgectrl pinned at 9d1f6f2 (the panel on Bootstrap, one save bar, help
popovers, themes, the gzipped page); PV unchanged. The pin moves only
forgectrl's fingerprint. The forgetest changes are the harness's own and
have no catalog consequence.
2026-08-24 12:13:53 -04:00
ScottW514 a47c0c7f6d Video offload: Mesa etnaviv in the image, forgectrl pinned, the H.264 stream in the catalog
The stream conversion now runs on the GC880 when the image carries the
GL stack, and /cam/h264 serves the same picture as fragmented MP4 in a
fraction of MJPEG's bytes (forgectrl 6573abd). This side supplies what
that needs and holds it to account:

- Distro: opengl stays a feature, with mesa trimmed to exactly the
  path used (gallium etnaviv, GLES/EGL/GBM, no GLX, no display
  platforms). Image: libegl-mesa, libgles2-mesa, libgbm,
  mesa-megadriver. forgectrl dlopens them, so an image without them
  still streams on NEON. Platform change: the next campaign is full.
- forgetest: camera.h264-stream exercises the endpoint end to end
  (codec header, init segment, media fragments, /cam/status encoder
  state) and accepts a clean 503 on a machine without the stack;
  the camera covers name the new forgectrl files.
- VIDEO.md 5.6 now describes both streams and why H.264 earns its
  place (the WiFi transmit path measures about 7 percent of the core
  per MB/s sent); BRINGUP records the bench-validation checklist as
  Next work item 20, including the release-size watch: mesa must fit
  the 200 MiB slot gate.
2026-08-24 08:10:33 -04:00
ScottW514 a2b391b8f6 forgectrl pinned at the status-page sys block; panel-serves reads it
The pin picks up /status carrying CPU and memory utilization (sys:
cpu_pct over the interval since the previous read, mem_pct from
MemTotal against MemAvailable) with the panel rows under the board
temperatures. forgectrl.panel-serves now re-reads /status after a
beat and asserts both percents are numbers in range, so the catalog
exercises the new telemetry on the bench.
2026-08-24 07:07:49 -04:00
ScottW514 f54d086625 forgetest: the fixture's resolver on a many-homed host; a missing box said once
The mDNS lookup sends its query from every local IPv4 interface (a
host whose default route is a VPN or a VM adapter never reached the
bench network), binds the mDNS port and joins the group as two
separate steps (a group join that fails left the socket half bound),
and a failed send is no answer rather than an error. The runner says
"running without it" once per reason, not on every 30 s probe.
2026-08-23 14:53:20 -04:00
ScottW514 b54b94e8ae forgetest: the bench actuator plugs into the action seam
fixture.py: the bench's /data/forgetest/fixture.json (hostname, key,
optional ip, the channels wired, arm_press), a resolver for
<hostname>.local asked of the network directly (the image has no mDNS
resolver), and the client. The runner probes it before every run and
at most every 30 s otherwise; ctx.act asks it for a channel it covers
and still waits for the machine's own reading, falling back to the
operator's notice when the box fails. A test declares with hands=(...)
what it asks of a person beyond its typed actions; an operator test
with none, whose actions the fixture covers, is routed into the
unattended queue, its Ready gates pass, and a prompt it raises anyway
is a FAIL naming the undeclared step. Live tests never move; their arm
press stays a person's unless the bench opted in, in which case the
fixture presses when the button lights. Whatever the box still holds
after a run is released before the baseline's post pass and recorded.
The page shows what the fixture covers. Contract in ACCEPTANCE.md; the
wiring facts, with the interlock connector left to the bench to settle
(SAFETY.md and the sister map differ), in BRINGUP.

Catalog unchanged in its definitions; the cloud and laser suites'
shared code moved, so their implementation hashes move with it.
2026-08-23 14:15:53 -04:00
ScottW514 335c6dea9d forgetest: non-behavioral paths outside every fingerprint; hollow covers fail the lint
The coverage lint already allowed docs, CI, unit tests and licenses to
go uncovered; the same list now keeps them out of every fingerprint,
so a README edit in any component re-requires nothing. The list moves
to the manifest module as NON_BEHAVIORAL, the one place both uses read
it. And a coverage entry that selects no file of its component (a glob
without the recipe's subdirectory, a component the manifest lacks, a
glob naming docs only) fails the lint: such an entry covers nothing and
the test's fingerprint ignores the file it meant. The contract says
both. Every test whose maps reached a doc or a test file gets a new
fingerprint once.
2026-08-23 12:54:55 -04:00
ScottW514 53e4fa20e9 forgetest: the cloud tests' coverage maps follow the split
The six job tests covered all three cloud components whole, so a
one-line change anywhere re-required one real print and five attended
tests. The maps now say what each test proves: the protocol test the
web session, the emulator and its fixtures; the offline tests the run
loop, the hardware it drives, the offline dispatch and the pulse path;
cloud.mode-switch the homing path; every one of them the client's
common ground. The one real print keeps the coarse maps as the
integration and the lint's floor. Two entries of the protocol test
named app files without the recipe's subdirectory and selected nothing;
the paths are spelled out now.
2026-08-23 12:54:55 -04:00
ScottW514 ab0a515a85 forgetest: the supervisor's levers get a timeout above the daemon's own waits
POST /mode, /controller/start and /controller/stop answer only when the
switch is done: the old controller gone, the new one started after any
pending liveness probe, and its first job-state report in (15 s without
one). The client's 10 s timeout read a slow but honest switch as a dead
daemon and errored cloud.service-protocol on the bench; those three
paths now get 120 s. No catalog consequence: the tests and their covers
are unchanged, the client only waits longer.
2026-08-23 12:10:20 -04:00
ScottW514 969bac6013 forgetest: the service's hunt paid only where it is the subject
A cloud client the tool starts for anything but homing comes up under
the /run/gfcloud-nohunt marker: the real client back after the
emulator, a mode the runner switches to or hands back, a controller it
restarts. The service keeps the head position it has. cloud.mode-switch
and cloud.service-protocol keep their hunts, and so does the one real
print: enter_cloud reuses a running session only when that client has
hunted the machine itself (session_hunted: never the emulator's, never
a no-hunt start), otherwise it restarts the client with the hunt, since
a print placed on a head position the service only believes can run the
gantry into a rail. The markers are one start, taken down by the client
that read them first thing; the tool's own removal stays for a start
that never happened. Catalog unchanged; the cloud tests' shared code
moved, so their implementation hashes move with it.
2026-08-23 11:27:36 -04:00
ScottW514 1c8197faa3 forgetest: cloud.service-protocol, the service answered by the emulator in this machine's identity
The service-protocol half of the cloud catalog on its own test: the cloud
client restarted as gfutilities' emulator under the /run/gfcloud-emulate
marker signs in, passes the firmware check, opens the WebSocket, answers
the connect-time hunt and the image requests with the dev image's canned
frames, and runs a print from the app through the real download path to
':completed' - nothing moves, nothing arms, and only the app has to be
driven, by a person or an agent through the prompt API. The real client
is restarted afterward and its hunt waited out. session_live now knows
the emulator's session is not the machine's, so enter_cloud restarts it
rather than reusing it; restart_client is the one restart the offline
and emulator entries share.

The dev image adds python3-gfutilities-emulator (the fixtures, packaged
on their own in meta-openglow); forgefirm-app moves to 12ad3b1 (gfcloud
--emulate). Catalog: 44 tests, 27 auto / 9 operator / 8 live; the new
test covers the gfutilities service layer and examples/, which step 4
will take off the other cloud tests. Replays over the prompt script;
contract and BRINGUP updated. A layer change (the dev image recipe):
everything re-requires on the next image.
2026-08-22 20:19:46 -04:00
ScottW514 0cb9044e1d forgetest: the offline jobs removed after each test; the record of the first offline campaign
Every offline test now removes the jobs it wrote under /tmp/forgetest
on its way out (the bench rule: nothing left behind in the session that
put it there). CAMPAIGN-LOG gets the 2026-08-22 entry for dev image
20260822232347: the offline service dry-checked, then campaign
c-20260822233344-08de, 13 run and 43 of 43 with the four offline tests in
5.5 minutes, and what the machine said under it. BRINGUP: the offline
service is done and bench-validated.
2026-08-22 19:59:36 -04:00
ScottW514 9cc2e4eef4 forgetest: the machine's print behavior under the offline service
Four cloud tests no longer need the app, an account, a network, or
anything on the bed: cloud.lid-interlock-abort, lid-during-button-wait,
paused-lid-cancel and oversize-stream run under the offline service
(enter_offline restarts the cloud client with the /run marker for that
one start; Offline is the socket; offline_job writes the job). The jobs
come from forgetest/puls.py: the header of a factory print of this
machine type (134 tags, MCsn 0, so the client's limits and settings come
from where a service job's do) over a square traced at a steady feed
with a leading power byte of zero and no LASER bit anywhere - the arm
unlocks the latch, the beam is never commanded, so the tests stay live
and need no scrap. A job longer than the ring (33 MiB of ticks, an hour
of squares) is an 87 kB gzip written in a tenth of a second, in place of
a full-bed raster designed in the app.

session_live reads the offline mark as "no web session"; enter_cloud
restarts an offline client with the service, so cloud.pause-resume (the
one real print left, with cloud.mode-switch the service-protocol half of
the catalog) follows the offline tests without the operator's hand.
Replays over a fake socket; the contract and BRINGUP say how the cloud
catalog splits. Catalog consequence: the four re-ported tests move;
nothing else is invalidated.
2026-08-22 19:19:50 -04:00
ScottW514 2547a8eb68 forgetest: a test's implementation hash is its own function and its module's shared code
The implementation half of a domain fingerprint was the whole suite
file, so a two-line witness fix in laser.py re-required every laser
test and a rename in cloud.py every cloud test: sixty attended minutes
for changes that touched two test bodies. Now the hash is the test's
own function (its decorator included) together with the module's text
outside every @test function. A body edit moves that test alone; a
helper edit moves the tests of its module, which is what a helper does;
a file that defines no test in the @test form hashes whole. The gate
computes it the same way, from the same code.

Every recorded fingerprint moves once with this, so the next campaign is
a full one: the price of every later fix costing one test.

Also carries the re-targeted cloud replays that the previous commit
left in the working tree (the CI failure on 296fd68).
2026-08-22 18:06:27 -04:00
ScottW514 296fd686e1 forgetest: the button's commanded level, and one app cancel instead of two
laser.arm-wait-lid failed its first bench run on the witness, not the
machine: the cancel relocked, disarmed and emitted nothing, but the
"button dark" check read the LEDs' brightness the instant after, and
the smooth trigger fades it. The controller writes `target`, so that is
what hw.button_leds() reads now (brightness where no target exists),
and check_button_dark waits a few seconds for the command to land.

The operator's campaign showed cloud.oversize-stream and
cloud.pause-cancel-paths both cancelling a print from the app. A print
longer than the ring has to be ended that way, and the operator is at
the app for it anyway, so the app cancel lives there and is judged in
full (the abort tail: stop, park to the job start, relock, disarm, the
button dark, ':cancelled'); cloud.pause-cancel-paths loses its second
print and becomes cloud.paused-lid-cancel - one print, one arm press.
Replays follow. Catalog consequence: the two cloud tests and the laser
module are re-required; the count stays 43.
2026-08-22 17:47:26 -04:00
ScottW514 9139e923b1 forgetest: the jog witness judged over the sequence, as the sampler can see it
On the bench the head accelerometer's sysfs read lands two or three
samples in a one-second jog leg, and two samples on the constant-velocity
stretch read near idle with the head in full flight: the accelerometer
sees the ramps, not the travel. The first unattended queue on dev image
20260822204234 failed motion.jog-roundtrip on that (2 of 8 legs judged)
with the head plainly moving where a ramp was caught (p2p 3019, 1330,
1698, 1663). The verdict is now over the sequence: p2p across all the
legs at or above the liveness threshold, and motion on at least two
distinct legs (one jolt is not a gantry moving on every jog); the
sampler must have landed samples at all. Rerun: p2p 2897 over 17
samples, motion on 3 legs, PASS; the whole unattended queue passed (15,
then 9 re-required by the module hash) on that image.

camera.snapshot's PASS line names the half-res frame it compared (the
full-res size had been printed in its place). AccelSampler gets host
tests over a fake iio tree.
2026-08-22 17:04:12 -04:00
ScottW514 de324cc32f forgetest: the operator's part asked for by name, and fewer hands in a campaign
A campaign asked a person for about eighty things: lid, button and
interlock actions, app jobs, and sixteen confirmations by eye, most of
them as popups to read and answer while the head was already moving.
This is the forgetest-only step of cutting that down.

The operator channel. A test asks for its operator's part in four ways:
ctx.ready() pre-announces a timed step and waits for the click that
starts it; ctx.notice() is a standing instruction with no button, the
test watching the machine for the result; ctx.act(channel, state) is a
machine action by name (lid, interlock, button) - a notice for the
operator today, proven done by the switch reading or an `until`
condition, recorded in evidence.actions with who performed it, and the
seam a bench actuator plugs into through runner.fixture; ctx.confirm()
stays for the yes/no the evidence cannot answer. Tests declare
`actions`; a `precheck` refuses a start the machine cannot honor (a
reason, no result, a queue skips it and carries on).

The page shows what you will do before it is asked: the running test's
steps, a queue's attended tests still waiting, or the test whose title
you clicked while idle; notices and prompts sit under it. The campaign
card no longer carries baseline, queue and leftover notes: those go to
the runner journal (daemon.log, syslog as `forgetest`, and the run in
progress), with a Runner journal button in the footer.

The catalog, 43 tests (27 auto, 8 operator, 8 live; was 45: 25/12/8):
cloud.mode-switch absorbs cloud.hunt-lid-open and cloud.gfhome-homing
(the connect made with the lid open, the hunt judged lid-open with its
Z cycle, the re-hunt waited out after the close, the switch back, then
$H judged by gfhome's own "homing complete" line with its motion
windows; precheck homing_mode = gfcloud). kernel.fire-line is auto with
the HV-not-good precheck, camera.snapshot is auto (a second frame with
the lid lamp off differs and is smaller), motion.jog-roundtrip is auto
(the head accelerometer per leg, the supervisor's own witness). The
remaining attended tests use Ready gates and act(); the head's beam
detector and the button LEDs replace the eye, leaving two confirms: the
emission witness's mark and the app's display in cloud.pause-resume.

Proof: tests/test_operator.py (the channel, the precheck, the journal),
the cloud replays re-targeted to the merged round trip over a fake grbl
and the bench excerpts, 196 host tests green, coverage lint 0 uncovered.
Every re-ported attended test is owed one bench run on the next dev
image (BRINGUP). Catalog consequence: the merged and reclassified
tests' implementation hashes move; nothing else is invalidated.
2026-08-22 16:36:03 -04:00
ScottW514 4e0c90b5a5 The pulse-header envelope settled; the SoC die watched; item 19 closed
forgectrl pin b27398a: the SoC die joins the watched board temperatures
(/status temps soc_c and the kernel's CPU cooling state soc_throttle,
the Status tab, the per-job range line naming a throttle), a throttle
starting or ending is logged, and the supply sensor stays a raw count by
decision (its heatsink cannot be reached with a thermometer while the
machine runs). forgefirm-app pin e65cfc2 (0.1.16+git): every pulse-header
key without an applier is declared with its reason or counted as
undecided in the job log, and CLOUD.md carries one disposition table for
the whole header.

BRINGUP: the pulse-header envelope item is closed. Its durable content is
in the facts bank ("The factory's envelope, decoded": the mandatory tags,
the empty tach windows, the factory's pause and fail tiers, the
per-sensor units, the unarmed flow controller, and ForgeFIRM's answer
with its catalog proofs) and in the "Deliberately not gated" paragraph,
which names every declared family. Item 19 is now the bench-measured
head crash and rail-contact detector; the fire-watch item holds the
header's lid IR thresholds as its prior. The facts bank also records the
SoC's own thermal guard (85 C passive, 90 C critical, no heatsink on the
factory board) and the board temperatures at idle.

Catalog: cooling.gate-off checks the die field, the unthrottled state at
idle and the widened run-end line (the unit fake mirrors it). COOLING
section 9 and the SERVICES verification status describe the present.
2026-08-22 14:24:50 -04:00
ScottW514 6458f6aec0 The ceiling at its off end stands with the default critical line
forgectrl pin 368fd0c: the critical-line cross-check binds only while the
ceiling is a gate. cooling.critical-tier pins that a POST setting the
ceiling to its off end is accepted with the default line (the unit fake
mirrors the exemption); CAMPAIGN-LOG records the gate-off failure on dev
image 20260822165832 that found it, and the critical-tier pass.
2026-08-22 13:43:36 -04:00
ScottW514 9fae47cf14 The critical tier proven on a rising loop; the board temperatures watched
forgectrl pin 76115fd: the chassis LM75 and the supply sensor ride /status
as temps (degrees and a raw count), the engine ranges them over every run
session into one run-end line, and a critical fault that clears with its
session yields the reason to the standing hold.

Bench: critical_tier_drill.py (a bench tool now, registered as
critical-tier) sets the ceiling, the resume gate and the critical line a
few tenths above the live upstream reading and lets the engine's own
flow-check heater warm the loop through them inside one M8 session;
temp_calibrate.py gains supply-watch, supply-point and supply-fit for the
supply sensor against a thermometer on its heatsink, the fit shown beside
UAPI.md's unverified guess.

Catalog: cooling.gate-off checks the /status temps fields and the run-end
board-temperature line (the unit fake mirrors both, one new failure
case); cooling.critical-tier checks the reason after a faulted session.

Docs: CAMPAIGN-LOG entries for cooling.critical-tier on dev image
20260822154257 and the warm-loop drill (OVERTEMP at 10 s, CRITICAL at
14 s, the fault ending with the session); BRINGUP item 19, the facts bank
(board temperatures at idle), COOLING section 9, the bench README.
2026-08-22 12:56:40 -04:00
ScottW514 f51140e528 The coolant critical tier, with its catalog test
forgectrl pin a1875a8: cool_temp_critical_c (default 38 C) is the fail
tier above the coolant ceiling's pause: at or over it in a run session the
verdict is CRITICAL (fire blocked, hold, no resume this job), the fault
ends with the session, the settings API keeps it above the ceiling, and
its top turns the gate off.

Catalog: cooling.critical-tier (auto, 45 tests), driven through the
settings API like cooling.gate-off: a critical line at the ceiling is
refused (and undone should it ever be accepted); with the ceiling, the
resume gate and the critical line all under the coolant's temperature a
session reads CRITICAL rather than OVERTEMP with no resume and the reason
naming the tier and the coolant; after the session the ceiling alone
holds (OVERTEMP); with the critical line at its top the gate is off
(gates_off, the run-start log line) and the ceiling alone pauses;
restored, OK with nothing off. Four unit cases against a scripted engine.
_after_session takes the condition to wait for.

Docs: COOLING (verdict table, section 5, the settings table, the quick
reference), BRINGUP item 19 and the catalog count, the CAMPAIGN-LOG
paragraph for the fan-fault session rule on dev image 20260822145201.
2026-08-22 11:41:18 -04:00
ScottW514 c1591e4f69 The fan floors proven on a pinned image; a fan fault ends with its session
The dated record of dev image 20260822135848: the campaign of every
non-operator, non-live test at 18 of 18 PASS with the measured floors and
the operating-point rule (cooling.fan-gate-trips and the hunt leg of
cloud.mode-switch as recorded), and the unplugged-exhaust-fan drill:
AIRFLOW at the grace plus three ticks with the exhaust dead, the other
fans held, the reason relayed on the Grbl port, the replugged fan ok
inside the next session's grace.

The drill showed the fault riding into idle, where the hold canceled
jogs and would have refused the cloud print that re-proves the fan.
forgectrl pin d51dbdb: the fault ends with its run session, and the next
session judges every fan afresh. cooling.fan-gate-trips checks the
verdict is OK with no hold once the tripped session is over (the unit
fake mirrors it); its covers, and the cooling tests' shared covers, gain
src/coolfmt.* (the tree manifest carries the new files at the bumped pin,
and the lint was right to ask). cloud.mode-switch samples the hunt's gate
rows twice a second: a hunt's run phase is a few seconds long.

Docs: COOLING 3a, BRINGUP item 19, CAMPAIGN-LOG.
2026-08-22 10:46:41 -04:00
ScottW514 adcd1adb9a Fan floors from the measurement; a hunt is measured, not judged
forgectrl pin 47e4256: the airflow floors set from the bench measurement
(exhaust 6400, intake 2290, air assist 6000 rpm, purge current 300, grace
15 s) and the operating-point rule: a fan is judged while the laser is
armed, when a job's profile may raise it but never lower it below the run
duty, or whenever it is commanded at the run duty; the service's hunts,
sent with the extraction fans off, are measured and published unjudged.

Catalog: cloud.mode-switch now waits out the connect-time hunt sampling
/cool/status and requires no AIRFLOW, the exhaust row unjudged and the
exhaust actually off; its covers gain forgectrl src/cool.* and
src/airflow.*. cooling.fan-gate-trips uses an 8 s test grace (the
intakes take 7 s to 90 percent and tripped under the old 2 s at the new
floor) and its off leg waits for the row state as well as gates_off.
fan_floor_measure.py names a reply that is not JSON and calls the purge
readings idle and run (the pump is always on).

Docs: COOLING 3a and the settings table, BRINGUP item 19 and a facts-bank
entry with the measured fan speeds, the bench README, and a CAMPAIGN-LOG
entry for the measurement, the two status-document finds, the hunt find
and the hot-deployed bench runs of both tests.
2026-08-22 09:56:53 -04:00
ScottW514 0bddc9eeab cooling.fan-gate-trips: a fan under its floor is a fault; pin the airflow gates
The acceptance catalog gains the test behind forgectrl 5a31d66: an
exhaust floor at the legal maximum must trip AIRFLOW after the grace and
three ticks (hold, fire blocked, no resume, the exhaust gate TRIPPED and
named in the reason); a purge current floor at the ADC rail must trip
the same way; an exhaust floor of zero must read off in gates_off and
trip nothing; restored, the next session must run OK with every fan at
or above its floor. Three host cases against a scripted engine that
models the grace and the readings. The covers map names src/airflow.*.

COOLING.md gains 3a (the airflow gates) and the five settings with their
ranges and bands; SERVICES.md the gate rule, the rows, the AIRFLOW
verdict and fan_gates; SAFETY.md names the floors among the verdict's
inputs; BRINGUP item 19 records the gates as in with provisional floors
and the catalog at 44. Pins: forgectrl 5a31d66, forgefirm-app 81027ff
(0.1.15+git); fetch-verified.
2026-08-21 19:00:06 -04:00
ScottW514 a8a02596cd Bench: fan_floor_measure.py, the numbers the airflow gates ship with
Two modes. spinup opens a run session with M8 from the engine's idle
posture, samples the four tachometers and the purge-air current once a
second, ends the session with M9 and waits for the engine to leave run;
per fan it reports the steady speed at run duty, the time to 90 percent
of it and the spread over the steady window, the purge current off and
on, and candidate floors at 55 percent of steady. cut sends nothing and
samples while the operator runs a real cut, for the spread the debounce
has to ride over under the only load that matters. Registered on the
bench page as fan-floor. Tooling only; no catalog consequence.
2026-08-21 18:44:07 -04:00
ScottW514 5249c1ecf8 CAMPAIGN-LOG: the job's limits pass through on a live session
cloud.pause-resume passed on dev image 20260821220926 with the service's
hunt windows (10 to 50 C) ignored as looser and the print's window (33 C,
5 C floor, 116 rpm air-assist floor) matched. The test now quotes the
print's job-limits line rather than the session's first (a hunt's), and
keeps the engine line that carries the header beside the last one.
forgectrl pin moves to e0b41b3 (the "not stricter" notice once per value).
2026-08-21 18:39:43 -04:00
ScottW514 d530f773e2 cloud.pause-resume reads the job's limits through; pin the pass-through
The pulse header's envelope now reaches the cooling engine: the cloud
client (python3-gfhardware c34faa1) derives the coolant window and the
fans' minimum speeds from the header and rides them on every report,
and the engine (forgectrl 57f6064) resolves each as the stricter of its
setting and the job's, never looser and never overruling an off gate.
cloud.pause-resume, which runs a real print, now also reads the client's
"job limits from the header:" line and the engine's "effective limits:"
line from the two logs; two host cases cover the failure paths. The
needle guard lists the engine's phrases as not the app's.

COOLING.md section 2 explains what a cloud job brings with it; BRINGUP
item 19 records the pass-through as landed. Pins: forgectrl 57f6064,
forgefirm-app c34faa1 (0.1.14+git); fetch-verified.
2026-08-21 18:07:41 -04:00
ScottW514 f274eb1137 cooling.gate-off: let each run session end before opening the next
On the bench the test tripped the gate and then failed its off leg: its
M9 and the next M8 were 300 ms apart, the GRBL client reports at 1 Hz
and the engine samples at 1 Hz, so the engine never saw the session
end, the next M8 was not a new run start, and the ceiling was never
re-read. Every M9 now waits for the engine's phase to leave run before
the next M8, and an M8 refuses to open on a session still running.

The failure path restores the settings and then cycles a run session
too: the engine reads settings at run start only, so restoring the file
alone left the bench holding OVERTEMP against the test's 6 C ceiling
until the operator's next job. The scripted engine in the host cases
models the report period, and two cases pin both rules.
2026-08-21 17:26:14 -04:00
ScottW514 bf3483e994 cooling.gate-off: a gate setting trips in range and is off at its far end
The acceptance catalog gains the test behind forgectrl 9e44fdc: the
coolant ceiling set just over its legal minimum must trip OVERTEMP with
a hold and fire blocked at the next run start; set to its top the engine
must skip the gate (verdict OK), report it in gates_off on /status and
/cool/status, and log the run-start line; the original values are
restored, on failure too, and proven restored. Five host cases against a
scripted engine. The cooling covers map now names the files that carry
gate state (gates, settings, status, the panel) and corrects a glob that
matched nothing: the GRBL cooling client is src/glowforge_cooling.c, not
src/gfcool*. The fake forgectrl serves /logs/tail and keeps blank form
values as "clear", which is what the daemon does with them.

Docs: COOLING.md section 8 carries each setting's legal range and
recommended band and a new 8a on turning a gate off; SAFETY.md names
what no setting can reach; ACCEPTANCE.md records that gates are
exercised through the settings API, never GFCOOL_* env overrides;
BRINGUP item 19 records the pattern as landed and the catalog is 43.

forgectrl pin moves to 9e44fdc; fetch-verified.
2026-08-21 17:06:49 -04:00
ScottW514 6c1d68f2c3 Judge the cloud resume on the lines the app logs; guard every needle against the pinned app
cloud.pause-resume failed a print that paused, resumed with its laser
lead, completed and parked: the test waited for the single line "button
pressed while paused; resuming", and the app has logged that as two
lines since its feeder work ("button pressed while paused", then
"resuming (laser lead N ticks)" from _resume_retraced). The replay
fixture carried the old wording, so the host test kept passing.

The pause and resume are now judged on PAUSE_LINES + RESUME_LINES
through one checker shared by the three tests that drive a pause
(cloud.pause-resume, the streamed pause, the pause-then-lid test), which
also fails on the app's "resume refused" line with the reason. The
fixture carries the app's two lines.

So the wording cannot drift silently again: tests/test_cloud_needles.py
reads every log phrase the cloud suite greps for out of cloud.py (the
left side of each `x in ln`, every wait_log needle, the mark tuples, and
the phrases it builds) and checks each against the logger calls in the
app sources at the revisions the recipes pin, read from the manifest
cache the tree manifest builds (the sibling checkouts locally),
placeholder-aware under a rule that never lets a placeholder stand for
the phrase itself. CI now builds the tree manifest before the unit
tests so the cache is there. The old needle fails that check.

Replays added: the second press seen but no retraced restart, and a
refused resume. 159 unit tests pass; coverage lint clean. No catalog
consequence beyond the suite module's own hash.
2026-08-21 14:09:51 -04:00
ScottW514 9585febb7c Take the fan idle reference from a settled machine, and log the cooldown
cooling.fans-quiet-after-motion sat silent for four minutes on the bench
and then failed. Its idle reference was the tachs one second after the
baseline saw the engine go idle, while the previous test's fans were
still coasting at the cooldown level (exhaust 5030 rpm against a true
idle of 0), so it then waited for the fans to come back UP to a level
that was never idle. The cooldown wait printed nothing while it waited.

The reference now needs the engine idle, the idle duty applied to both
fan channels, and three consecutive tach samples that agree; the pass
condition is the idle duty back and the tachs at or below that reference
(lower is quieter, never a fault); M8 must visibly raise the duty; and
every sample of both waits is logged with the phase and the duty, so the
run pane shows the fans coasting down rather than a hang.

Proof: tests/test_cooling_suite.py replays the test under the real
Context against a scripted machine (fake forgectrl, fake sysfs duties,
fake Grbl port): an idle machine passes, the bench case (fans coasting
when the test starts) passes with the reference taken after the coast,
fans left on fail with the reference in the message, and a reference
that never settles fails before anything is jogged. No catalog
consequence beyond the suite module's own hash.
2026-08-21 13:23:23 -04:00
ScottW514 54e1689889 Let a test declare the controller mode it needs; the runner switches to it
The cloud job tests enter cloud mode and stay there, by design, so a
queue (or an operator) that goes on to a motion test reaches it with
gfcloud as the controller and no grblHAL process to find:
motion.step-timing-under-load failed on exactly that, before it touched
the machine. Nothing in the runner put the machine into the mode a test
needed; the baseline only preserved the mode it found.

A test now declares `mode="grbl"` (or "cloud") in @test. The runner's pre
pass, after the leftovers are handled and before the preserved state is
captured, switches through POST /mode, waits for the supervisor to settle
(controller running, motion verified) and for the Grbl port to answer,
and fails the test with the reason when the mode cannot be established.
Capturing after the switch means the post pass keeps the mode the test
asked for, so the machine changes mode only where the next test asks for
it and never between tests of the same mode. The cloud job tests keep
managing their own entry (enter_cloud also waits for the service
session) and declare nothing.

Tagged: every motion.* test but the mode-agnostic liveness probe, the six
laser.* tests, cooling.fans-quiet-after-motion, cloud.mode-switch and
cloud.gfhome-homing (both start in GRBL mode). controller_pid() now says
what mode forgectrl reports when the process is missing. The page shows
the declared mode as a badge; the Grbl port probe moved to hw.

Proof: tests/test_mode.py (switch_mode against the fake forgectrl,
including a refused switch, a controller that never comes up and a port
that never opens; the runner end to end from cloud mode, from grbl mode,
an undeclared test, and a failed switch). 151 unit tests pass; the
coverage lint is clean. No catalog consequence beyond the suite modules'
own source hashes: the change is to how a test is started, not to what
it proves.
2026-08-21 12:34:03 -04:00
ScottW514 e810d52c9d Put every test group on the same column grid
The subsystems are separate tables, so each sized its own columns from
its own content and no two lined up. Reading down the page meant reading
down six different layouts, which is what made it look busy. They now
share one colgroup and a fixed table layout: Kind, Status, Last result
and the Start button are the same width in every group, and the Test
column takes the remainder.

Two things had to come out of the columns first, because both are long
enough to stretch a cell and drag one group's grid out of step with the
rest. The details block moves to a full-width row of its own, where the
prose and the operator steps have room and opening one no longer widens
the Test column. The requires note moves from under the Start button to
under Status, which is sized for it: the catalog's longest names three
prerequisites in 73 characters, and beneath a button it wrapped into a
ragged stack. It reads better there anyway, next to the reason the test
is required rather than beneath the control it disables.

Also gives a test's description its own block in the details. Without
operator steps to separate them it ran straight into the Requires line.
2026-08-21 10:44:46 -04:00
ScottW514 0258268aae Let the page run a campaign's remaining tests as a queue
A campaign is mostly waiting for the next Start. The page now offers two
queues, and each takes every test of its kinds the campaign does not
already count as satisfied: Unattended for the auto tests, which need
nobody in the room, and Operator and live for the ones that need somebody
at the machine, since they prompt and they fire the laser. The buttons
say how many they would run and ask before starting; the live queue names
the tests that fire and takes the acknowledgment once, for all of them.

A queue runs one test at a time through the runner's single slot, in
prerequisite order. Registration order otherwise, so a run reads down the
page, but a prerequisite inside the queue always goes first. It stops on
the first result that is not a PASS: a FAIL closes the campaign, and
carrying on would only open a second one behind the operator's back. A
test the runner refuses to start is skipped with the reason on the page
and the rest carry on, which is what happens to an auto test waiting on an
operator one: run the attended queue, then the unattended one again.

The queue lives in the runner, not in the tab, so reloading the page or
closing it leaves the run alone. While one is up it holds the machine
between its tests as well as during them, so a single Start and the bench
tools are refused rather than cutting in. Stop the queue cancels what is
still waiting and lets the run in progress finish; Abort ends that one
too, and lands as the non-PASS that stops the queue. Every run a queue
starts records which one put it there.

Also moves the /state ETag test to the end of its class. It invalidates,
timestamps are whole seconds, and a PASS stamped in the same second as an
invalidate is deliberately not inheritable, so on a fast run it decided
the inheritance an earlier test was checking.

forgetest is a dev-only component and can never appear in a coverage map,
so this has no acceptance catalog consequence.
2026-08-21 10:27:50 -04:00
ScottW514 474e14e0e8 Make the acceptance page answer the operator, not the timer
Presses on Start and Continue were being swallowed. Every poll rebuilt
the whole test table and the prompt buttons with innerHTML, and a button
destroyed between mousedown and mouseup raises no click event at all:
the press simply vanished. Measured on the page as it stood, a Start
button node was replaced 13 times in 40 seconds, and with a poll landing
mid-press 8 presses out of 8 were lost. Rows, prompt buttons and tool
entries are now built once and afterwards only updated in place through
setters that skip the write when the value has not changed; under the
same test 8 presses out of 8 land.

The page also felt slow because each poll re-read and re-parsed the whole
result log and recomputed all 42 domain fingerprints. A result record
carries its run log, so the file reaches megabytes over a campaign and
the poll cost grew with it. The log now parses each line once and reads
only the bytes appended since, and a fingerprint is memoized against the
manifest's content hash. On the same manifest, catalog and log, one
/state goes from 8.89 ms to 0.10 ms at 0.89 MB and from 41.79 ms to
0.23 ms at 10.62 MB, and no longer grows with the log.

Actions now report on the press instead of on the next poll: Start greys
every Start and marks the row, Continue and Abort grey themselves, and
each pulls the next poll forward. /state carries an ETag so an idle page
polls for a 304, the poll ticks faster during a run, and the connection
is kept alive rather than handshaking per request.

Keeping the connection alive exposed a hazard worth naming: a POST
refused before its body was read left that body in the socket, where the
next read took it for a request line. A refusal now ends the connection.

forgetest is a dev-only component and can never appear in a coverage map,
so this has no acceptance catalog consequence; the host tests carry the
proof, including one that fails if a per-poll innerHTML rebuild ever
comes back.
2026-08-21 09:49:35 -04:00
ScottW514 aaabfdf9b9 Check the progress a print reports, where a print already runs
Two tests already run a print end to end, and progress is a property of a
running print, so the checks go there rather than into a test of their own
that would cost the operator another job.

cloud.pause-resume takes the job that fits the ring: the client names the
length it is reporting against, and the operator is asked the question only a
person can answer, whether the bar actually moved.

cloud.oversize-stream takes the job that does not fit, which is where a moving
denominator would show: the kernel's program total grows all run long under a
live feed, and the test already samples it growing, so the check is that the
figure progress divides by is larger than that - the job, not the count the
ring had swallowed when the run started.

The forgetest replay plays a captured log from a build that predates the line,
so it carries the line where the current build emits it, as it already does
for the warm-up and the rest.

BRINGUP's cloud item now says a print reports itself again, and what is left
on it is a print watched from the app.
2026-08-20 14:16:27 -04:00
ScottW514 d122a6ff1d Check a print's warm-up and rest where a print already runs
cloud.pause-resume runs a print end to end, which is exactly what the job
lifecycle needs to be seen: a non-zero hold before the first fire, a non-zero
rest after the park, and neither on the connect-time hunt in the same
session. Folding the checks in there costs the operator nothing, where a
test of its own would cost another print.

The forgetest replay noticed first: it plays a real captured log from a build
that predates those lines. Rather than editing what the machine said that
day, the replay carries the two lines where the current build emits them.

BRINGUP's cloud item now names what is actually open on the header keys, the
park and the two periods, and records that the pause constants were looked
for in the wrong place.
2026-08-20 10:05:38 -04:00
ScottW514 e3d23b8099 Record the download guards and the feed watchdog
BRINGUP's cloud item said a print is capped by the ring and listed the two
gaps that closed when it stopped being. It now says what bounds a print
(memory, through pulse_reject_threshold_bytes) and what catches a feed that
wedges (progress against room, not ring depth), and it names the two new
cases that cannot be induced from the bench: a body past the memory guard,
because the service has no such job to send, and a wedged feed, because a
healthy machine will not stall on request.

forgectrl.settings-bounds gains a probe at the far end of the new byte
range, so the validator behind those keys is exercised rather than assumed.
2026-08-20 09:23:10 -04:00
ScottW514 942d9dde99 Drill the backtrack boundary, and pause a streamed print
kernel.backtrack-bounds plays a program, stops it, and holds the readback
to the bytes it played less the deceleration tail: a step past the boundary
has to be refused rather than quietly shortened, and the run at the boundary
has to play out and come back idle. Motors locked, latch locked, duty zero,
so nothing moves and nothing fires.

cloud.oversize-stream pauses and resumes the live-fed print it already has
running. That is the pause the kernel change makes possible, and it costs a
minute of a job that is on the bed either way.

BRINGUP's pause bullet, its ring facts and item 18 all said a ring under a
live feed has nothing left to back into. The gap the writer keeps clear says
otherwise; what is still open for GRBL mode is the bookkeeping above the
ring, not the kernel below it.
2026-08-20 07:41:41 -04:00
ScottW514 7bd9c45fd3 Accept a print longer than the ring
cloud.oversize-stream drives a job the ring cannot hold and checks the
signature of a live feed: the load reporting the job as longer than the
ring, the device in live-feed mode, the kernel's program total growing
during the run, no underrun, and a clean cancel afterwards.

read_program_total gives the catalog the counter that growth is read
from.
2026-08-19 21:21:31 -04:00
ScottW514 4300f7f4a8 Follow the pulse ring to 32 MiB
The ring and the device-tree pool that backs it are now the size the
factory firmware runs. Docs follow, and the hardware facts bank gains the
reserved-memory map: 511 MiB usable, 96 MiB reserved for DMA, ~454 MiB
left to Linux.

image.health reads the pool size and ring_mb back and fails if they
disagree, so a pool the parameter does not use cannot ship unnoticed, and
checks that free never exceeds the ring less its gap.
2026-08-19 21:21:30 -04:00
ScottW514 0c2dd37b2f Cover both dose models explicitly now that density is the default
grblHAL-glowforge 03d70e0, pinned here, makes density the shipped model
and $35 the density floor at 10. Every analog session in the stream
harness now selects its model rather than inheriting it: without that the
flip would have quietly turned them into density runs and taken the
analog fallback's coverage with them. laser.power-floor carries the new
floor and its PWMSAR minimum, and its description no longer describes
$35 as a duty floor.

All ten stream sessions pass on the new defaults, with the analog duties
shifting exactly as the floor predicts - min_value 12, gradient 0.115 -
along with both C harnesses and the lifecycle harness.

Recorded in BRINGUP and the campaign log, including what the defaults
rest on and what they do not: the seven ladders ran at F300 and F100 at
constant power, so production feeds, M4 into corners and the raster path
remain unproven on hardware.
2026-08-17 22:03:56 -04:00
ScottW514 c2c627d9b6 Prove the density dose model host-side; close the idle-gap level loss
Four new sessions in the stream harness cover the model (grblHAL-glowforge
2bca017, pinned here). Density renders the commanded level exactly -
levels 2, 3, 7, 15, 25 and 38 came back as 0.0158, 0.0237, 0.0551,
0.1182, 0.1969 and 0.2993 against level/127 of 0.01575, 0.02362, 0.05512,
0.11811, 0.19685 and 0.29921 - S1000 renders 1.0000 and still ends dark,
every power byte carries full duty, and a level change inside a run costs
no stream byte where analog ships one per level.

Rule 13 is the one worth having: the same job run under both models
produces an identical motion grid tick for tick, and all 20051 density
FIRE ticks fall inside the 169776 the analog run fired. The model masks
the core's fire state and never sources one, measured rather than argued.

Rule 14 covers the idle-gap fix: a standalone S between moves, from a
sender slow enough to drain the planner, now fires each move at its own
level (28338 ticks each at duties 30, 52 and 84). Before the fix duty 30
held all 85014 and the other two levels never appeared. That closes
"Next work" item 18, which this work opened earlier today.

The harness now derives its expectations from the board's floor and
chains two launches over one settings file, because the core precomputes
the S to duty mapping once when the spindle is enabled: $35 written at
runtime persists and reports immediately but only enters force at the
next controller start. That is recorded in BRINGUP beside the existing
defaults note, and laser.power-floor's failure message now says so.

Acceptance: the density path shipping off by default is inert until
laser_power_model is set, and the laser tests' covers already name
grblhal-glowforge src/**; the model's own acceptance test waits on the
bench drill that picks the base period.
2026-08-17 20:24:43 -04:00
ScottW514 cb41a6030c Commission the laser duty floor; record how the factory sets power
The pthresh ladder on scrap puts the tube's two thresholds far apart: the
discharge strikes between 2 and 3 percent duty, but nothing lases usefully
below 16 percent (PWMSAR 20), and the rungs between show only a spot at
each line start. $35 ships at 16 (grblhal-glowforge 9466f76, pinned here).

The drill said current lift-off and first mark share a rung; this run
falsifies that, so its docstring and read-the-material text now name both
thresholds and warn that a start-of-line spot is below the threshold, not
at it.

A start-of-line spot is also what a full-power leak at a kernel run start
would look like, so laser_stream_test gains a ladder session (rule 10):
every FIRE tick must ride a commanded duty, and the fire ticks must divide
evenly across rungs. Both hold exactly - six commanded duties, no others,
and 28296 fire ticks on every rung - so the spots are the tube, not the
stream. The harness now derives its expectations from the floor, which
moves the M4 session's S500 plateau from 63 to 73.

laser.power-floor is a new auto acceptance test, the suite's only
non-firing one: a machine must actually carry the commissioned floor,
since stored settings beat freshly baked defaults.

Three cloud cuts of one square at Precision Power 1, 100 and Full Power
show what the analog path is competing with: the power byte is pinned at
127 in all three, dose is FIRE-bit density on a fixed 7-tick period with
the on-count dithered between adjacent integers, and the power setting
never reaches the machine at all. Facts bank and item 17 carry the
numbers; CAMPAIGN-LOG carries both sessions.
2026-08-17 19:45:19 -04:00