Commit Graph
819 Commits
Author SHA1 Message Date
ScottW514 5036517e22 Pin grblHAL 0.1.22, forgeext 0.1.5, forgectrl 0.1.34 and forgefirm-app 0.1.33 on the pushed heads, and meta-openglow in the kas lock
- grblhal-glowforge badc832 -> e159720 (0.1.21 -> 0.1.22): a soft reset
  during motion gets the kernel's ramp and the position is read back
  from the kernel's step counters, a reset that lands after the core's
  own idle still gets the stop, the owed step is taken only between
  callbacks, a position that stays lost pins the lens and tells the
  sender, the homing cycle masks are pinned so a write cannot drop the
  lens hold, the test hooks (GF_SWITCH_DEV included) are honored only in
  a null-sink run through one gate, the underrun fail-safe log line
  names its entry, and the latch_test and serial_test cases that hold
  each.
- forgeext bb75b23 -> ebd9592 (0.1.4 -> 0.1.5): the storage quota counts
  entries (one per 4 KiB) and the unlinked files a service holds open or
  mapped, bounded, every 10 s; a quarantine the host could not write is
  kept under the run directory, taken up at the next start or the first
  turn the state reads, and dropped by the write or the lift; the root's
  prepare turns current back to the state's version, drops only a
  versioned tree, and leaves a package it cannot turn refused while the
  host runs the others; a manifest that cannot be read holds its package
  out by name; the required-holds marker directory is one literal.
- forgectrl 6c2bb38 -> b235be3 (0.1.33 -> 0.1.34): a diagnostic start
  the cooling engine refuses goes once more a second later, canceled by
  a later press or the Abort, which clears its text; the sysfs root, the
  switch device and the run directory are honored only in a host-test
  run through one gate (src/testhook.h, with its HOST_TESTED row here);
  test_routes holds the panel's retry text to the daemon's and the
  environment scan reads secure_getenv; the operator's texts say Grbl
  sender; holds_wire_test holds the required-holds directory to
  forgeext's and a marker alone holds.
- forgefirm-app 0f59c54 -> 6fb146a (0.1.32 -> 0.1.33): the shared
  AGENTS.md block names a Grbl sender; nothing it installs changes.
- kas lock: meta-openglow b7626be -> c542ce6, the head that pins the
  kernel module dab378d, python3-gfhardware 6fb146a and gfutilities
  b9e74b8.

Proof: the four repositories' CI runs on the pushed heads are green
(grblHAL-glowforge 39170662884, forgeext 39170677776, forgectrl
39168946865 on its second attempt, python3-gfhardware 39168949407), and forgefirm's own
forgetest-ci on 0de9051 (39168937632). bitbake -c fetch of each pinned
recipe from these pins succeeds. The tree manifest from the pins holds
forgectrl at b235be3 (232 files), forgeext at ebd9592 (90), grblhal at
e159720 (178) and the app at 6fb146a (46); the coverage lint over it
finds no uncovered path and no row problem (43 tests), with the row for
forgectrl src/testhook.h selecting host_start.sh in build.yml;
check-ui-vendor finds the shared UI files identical; test_tree_manifest
passes (7); the gate self-check refuses the empty artifact (rc 1).

Acceptance: the pins move the fingerprints of every test whose covers
name the four components, and the whole catalog runs on the next image.
2026-10-18 18:20:47 -04:00
ScottW514 0de9051820 forgetest: a pull_request narrowed by types: is a filter too
The coverage lint counts a HOST_TESTED row's workflow on a push or a
pull_request with no filter under it, and it read a `types:` under
the pull_request as nothing. A pull_request under `types:` runs on
the activity types it names and no other, so a workflow on
`pull_request: types: [closed]` never runs on a pull request's open
or its new commits, and a row on it counted as running on every
change, with the host test behind it keeping its paths covered. No
workflow a row names writes a `types:`.

workflow_filters reads a `types:` key at the pull_request's own level
as a filter, by name, as it reads a `paths:`: the lint reads no type,
so any `types:` is one, fail closed. A `types:` is read under the
pull_request alone: a push has none, and the lint counts no other
event. host_row_problems lists a workflow whose pull_request carries
one and whose push carries a filter, or is absent, as "every push or
pull_request in its own on: carries a filter (pull_request: types)";
a push with no filter beside it keeps the workflow counting. The
HOST_TESTED comment and the docstrings name the types filter.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace.
test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change
gains: workflow_filters reads a `types:` under the pull_request with
a plain push beside it as the pull_request's filter alone; a
`types:` block list with a `paths-ignore:` after it as both; and a
`types:` under a push as none. Through host_row_problems, a plain
push with a pull_request under `types: [closed]` passes, and the
pull_request under `types: [closed]` alone is listed with
"pull_request: types". Control: the parent, 9e7b065, with the new
test file fails the case (failures=1) at the first `types:`, and
against the parent's manifest.py, workflow_filters reads both
`types:` fixtures as no filter and host_row_problems passes both
rows. The coverage lint on the tree manifest from the recipe pins,
with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports nothing.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 14:34:10 -04:00
ScottW514 9e7b065343 forgetest: the lint reads a needs: list at the key's indentation and across lines, and an empty one fails closed
The coverage lint counts a step of a HOST_TESTED row's workflow only
in a job whose `needs:` are met on every run, and _needs read two
valid forms of the key as an empty list, which _needs_run takes as
needs met. A block list whose items sit at the key's own indentation
(`needs:` over `- build`) ended at its first item, which the loop took
for the next key. A flow list continued on the lines under the key
(`needs: [` over `build` over `]`) ended at the key's line, with no
item, and one split after a comma (`needs: [lint,` over `build]`)
kept the first line's names and dropped the rest. So a job that
needed a conditional job, written either way, counted as running on
every run, and a host test behind a conditional build kept its row
green and its paths covered. No workflow a row names writes its
`needs:` in these forms.

_needs now takes a `- ` item at the key's own indentation as part of
the block list, and ends the list at a key at the key's indentation
or less; items at two indentations are a form it does not read. A
flow list whose `]` is not on the key's line is joined with the lines
under it up to the `]`: a key, or a line at the key's indentation
that is not the `]`, before it ends the join as a form not read.
Every form not read gives None, a name no job has, so the need is
never met: fail closed. An empty `needs:` (`[]`, or nothing under the
key) gives None too: GitHub refuses an empty needs, and a job that
names one does not run. The docstring names every form that fails
closed, so its claim is true of each.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace.
test_a_job_runs_only_when_every_job_it_needs_runs gains the block
list at the key's indentation and the flow list across three lines
among the forms that run the test with the needed job unconditional
and do not with it under `if: false` or `if: github.event_name ==
'push'`; a flow list split after its comma in the chain of three,
which runs the test, and does not with the second-line job under
`if: false`; and, each failing closed with the needed job
unconditional, `needs: []`, a bare `needs:`, a flow list a key
interrupts before its `]`, and items at two indentations. Through
host_row_problems, a workflow on `[push]` whose test job needs, in a
block list at the key's indentation, a build job under `if: false`
is listed as "does not run it". Control: the parent, 8fc411f, with
the new test file fails the case (failures=1) at the block list at
the key's indentation, and run one by one against the parent's
manifest.py, each of the seven new forms reads as run and the row
passes. The coverage lint on the tree manifest from the recipe pins,
with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports nothing.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 14:31:59 -04:00
ScottW514 8fc411f50f AGENTS.md: the shared rules name the Grbl sender, not one product
The "Project-wide rules" block is the same text in every repository
of the project, and this copy's "What ForgeFIRM is" said "controller
for LightBurn and other senders on TCP port 23", naming one sender as
the controller's counterpart. The project's term is "a Grbl sender",
the generic client of the controller on port 23, and no sender is
named as a synonym for it anywhere. The sentence now reads
"controller for a Grbl sender on TCP port 23", at the same wrap,
and nothing else in the block changes.

Proof: the block from "## Project-wide rules" to the end of the file
diffs empty against forgectrl's AGENTS.md; before the change the two
differed in that one line. pyflakes, the unit tests and the coverage
lint do not read AGENTS.md.

Acceptance: none. AGENTS.md is in no fingerprint and no catalog
test's covers; a text that guides contributors changes no behavior.
2026-10-18 13:45:31 -04:00
ScottW514 4fa03f7e2b forgetest: the lint counts a push or pull request only when no filter is under it
A HOST_TESTED row's workflow has to run on push or pull_request, and
the lint read the events of its `on:` and nothing under them. A
`paths:` or `paths-ignore:` under the event keeps the workflow from
the pushes that change the other files, a `branches:` or
`branches-ignore:` from the pushes to the other branches, and a push
with only a `tags:` or `tags-ignore:` runs on no branch push at all.
So a workflow whose push and pull_request each carried such a filter
counted as running on every change, and a host test behind one kept
its row green and its paths covered.

workflow_filters reads what is under each event of the `on:` block
mapping: each `branches:`, `branches-ignore:`, `tags:`, `tags-ignore:`,
`paths:` or `paths-ignore:` key at the event's own level, by name; a
value on the event's line (`push: {branches: [main]}`); and a key
with one stray quote under the event. Each is a filter the lint does
not read: the simple rule, fail closed. The manifest carries no
branch (a component's record is its srcrev, source, files and
recipes), so a `branches:` naming the default branch is read as any
other filter, and a `paths:` is not matched against the row's covered
paths. host_row_problems counts a push or a pull_request only with no
filter under it, and lists a workflow whose every push and
pull_request carries one as "every push or pull_request in its own
on: carries a filter (push: paths-ignore)", with each event's filters
named. One push or pull_request with no filter is enough: the
workflow then runs on every change, through that event. "Runs" for
a row's workflow now means: on a push or a pull_request with no
filter under it, in a step on every run of the workflow, in a job
whose needs are met on every run, on a line of run text whose
failure ends the step. The HOST_TESTED comment and the docstrings
say so. A key deeper than the event's own level (a dispatch's
inputs) is no filter, and an `on:` list or scalar carries none.

The workflows the rows name, forgectrl's and forgeext's build.yml,
run on `push: branches: [main]` with a plain `pull_request:`, as
grblhal-glowforge's does: the pull request keeps each row counting,
and the push to main is the push that moves a pin.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace. The new
case test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change:
workflow_filters reads a `branches:` on the push with a plain
pull_request beside it; a `branches-ignore:` and a `paths-ignore:` on
the push with a `paths:` on the pull_request; `tags:`; `tags-ignore:`;
a value on the event's line and a stray-quoted key under the event as
filters the lint does not read; a dispatch's `inputs: paths:` as
none; and an `on:` flow list, scalar and block list as none. Through
host_row_problems, a push to main with every pull request passes, a
push with `paths-ignore:` alone is listed, and a push under
`branches:` with a pull_request under `paths:` is listed, each with
its filters named. The CI_YML fixture of the two older cases moves
its `paths:` from the push to the pull_request, so the workflow
still runs on every push and still names the second harness in a
filter alone. Controls: the parent, 9c97f05, with the new test file
errors the new case at workflow_filters (errors=1); host_row_problems
on the parent, and on e1d1c1b, passes a fixture whose push carries
`paths-ignore:` alone, which the change lists with "push:
paths-ignore". The coverage lint on the tree manifest from the recipe
pins, with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 13:44:59 -04:00
ScottW514 9c97f05db9 forgetest: the lint counts a step as run only when every job its job needs runs
The coverage lint judges a HOST_TESTED row by a step of its workflow
that runs on every run of the workflow. It read the `if:` and the
`continue-on-error:` of a step and of its job, and not the job's
`needs:`. GitHub skips a job whose needed job did not run (an `if:`
that held it back, or a failure), unless the job says `always()`, so
a test step in a job that needs a conditional job runs on not every
push, and counted all the same: a host test behind a conditional
build keeps its row green and its paths covered. No workflow a row
names uses `needs:`.

workflow_steps now reads each job's `needs:` (_needs: a flow list,
one scalar, or a block list), keeps the jobs by name, and resolves
them when the workflow is read (_needs_run): a job's needs are met
on every run when every job it names runs on every run, that is,
carries no `if:` other than the literal true and has its own needs
met, transitively. A job the workflow does not have, a cycle, and a
`needs:` in a form the lint does not read each fail closed. A needed
job's `continue-on-error:` does not bear: its failure fails neither
the run nor the jobs after it. The job's dict carries the result as
"needs_run", and _on_every_run counts no step of a job whose needs
are not met, so workflow_runs and checkout_path refuse it as they
refuse a conditional step. The reason stays "does not run it". The
lint evaluates no condition, so a dependent job under `always()`
counts as no job, as a step under `always()` counts as no step. The
HOST_TESTED comment and the docstrings say what the lint reads.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 40 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace. The new
case test_a_job_runs_only_when_every_job_it_needs_runs: a job that
needs an unconditional job runs its test, with the `needs:` as a
scalar, a flow list, a quoted flow list and a block list; the same
four with the needed job under `if: false` or `if: github.event_name
== 'push'` do not; a needed job under `continue-on-error: true`
runs it; a chain of three runs it, and does not with the first job
under `if: false`; a needed job the workflow does not have, a cycle
of two, a self-need and a `needs:` holding a mapping each do not;
`if: always()` on the dependent job, and on the middle job of the
chain, does not. test_the_workflow_has_to_run_the_host_test gains a
workflow whose test job needs a job under `if: false`, listed as
"does not run it". Control: the parent, e1d1c1b, with the new test
file fails both cases (failures=2). The coverage lint on the tree
manifest from the recipe pins, with meta-openglow at 1e163bd on
scarthgap, reports 0 uncovered paths, 0 empty entries, 0 host-tested
row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 13:40:54 -04:00
ScottW514 e1d1c1b84e forgetest: motion.jog-roundtrip's ramp comment follows the kernel's stepped ramp
The soft-reset check's comment described the kernel's ramp with the
continuous formula: (f0^2 - 900^2) / (2 a) ticks, 12687 over 223 ms
at the x32 tick, and a part-way run-out at f0 * sqrt(1 - 2 (a / f0)
Q). The kernel's ramp is stepped (cnc.c, ramp_update_tasklet_fn): the
tick holds for the first 10 ms after the stop, then every 10 ms drops
by ramp_rate / 100 until it reaches the 900 Hz floor, which plays
10 ms before the stop. At 112640 Hz and 500000 Hz/s that is 5000 Hz
off every 10 ms: 24 intervals, 240 ms, about 13270 ticks, 118 ms of
the tick. The comment now says so, with the figures that follow: the
rate at a run-out is 24% of the tick with 110 ms of queue left, 38%
with 100 ms, 51% with 90 ms; the tail's mean over the 25 to 30 ms the
poll samples is 32%, 45% and 56% (61% over the 50 ms window bound),
not 21% to 56%; the 70% bound is 14 points above the slowest ramped
tail and 30 below a run-out; the time to leave running is the 100 ms
hold plus the run's end on the ramp, about 240 ms with 100 ms left,
270 ms with 110 ms, 340 ms at the floor. The evidence's ramp_ticks and
ramp_s stay the continuous figures, and the comment says they are,
about 4% and 7% under the stepped ramp's. No judge changes: the 70%
bound, the 25 ms window and the 50 ms gap stay.

The Alarm wait's failure text names a sampling stall: when the 5 ms
sampling loop runs past 2 s after the ^X, the wait has no time to
read a state, and the failure read "not in Alarm within 2 s (state
None)". It now says the state is unread because the sampling loop ran
past the 2 s. The condition is the same; the text is the change.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. Host dry
runs of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), with the machine side
stood in for as before: with a model kernel that ramps from the
controller's cnc/stop write, the test passes, MPos X 3502.07 steps
against 3502, tail 22% of the tick; with a model kernel that ramps as
cnc.c does, 5000 Hz off every 10 ms from 110 ms of queue, the test
passes, MPos X 3496.95 steps against 3497, tail 33% of the tick, the
figure the comment gives for a run-out from 110 ms of queue.

Acceptance: the comment and the message text are inside the test's
own function, so the shared text of suite/motion.py does not change.
The fingerprint of motion.jog-roundtrip moves, and no other test's:
of the implementation hashes of the 43 catalog tests, before and
after, only that one differs.
2026-10-17 19:10:40 -04:00
ScottW514 b4493f5052 forgetest: motion.jog-roundtrip's covers name the forgectrl route its homed-mask judge reads
The soft-reset check's reference verdict is the homed mask, read before
and after the reset through forgectrl's GET /motion/state
(cb_motion_state in src/main.c, through motion_port to the controller
port's state record), because $# prints the mask only with $22 on,
which the driver leaves off. A test that depends on a forgectrl route
names ("forgectrl", "src/main.c") in its covers, as microstep_modes
and deadman do, so that a change to the route invalidates the test's
inherited PASS. motion.jog-roundtrip's covers are now _MOTION_COVERS
plus that entry.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests; the
catalog loads motion.jog-roundtrip with the five covers entries. A
host dry run of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), with the machine side
stood in for as before and a model kernel that ramps from the
controller's cnc/stop write, passes: MPos X 3534.93 steps against
3535, tail 23% of the tick, mask 4 kept, drift 0.000 mm.

Acceptance: the covers are part of the test's own text, so the
fingerprint of motion.jog-roundtrip moves, and no other test's: of
the implementation hashes of the 43 catalog tests, before and after,
only that one differs. From here a change under forgectrl's
src/main.c invalidates motion.jog-roundtrip's inherited PASS as well.
2026-10-17 19:08:02 -04:00
ScottW514 a3211ba1ea forgetest: motion.jog-roundtrip's read-back judge allows half a step, so a one-step miss always fails
The read-back judge of the soft-reset check compared MPos X in steps
with the kernel's X count to one step, and the Y and Z judges allowed
one step of MPos motion. One step is the smallest miss the read-back
can make: the core's kill step for the pulse start that never came,
which a position kept as the core counted it carries. MPos is
reported to 0.001 mm, so the difference of two readings carries up to
0.21 step at x32 (0.001 mm at $100 = 213.333; 0.05 step at x8), and
a one-step miss lands between 0.79 and 1.21 steps: on either side of
a one-step bound, at random. The bound is now half a step for X, Y
and Z: a correct read-back lands within 0.21 step, a one-step miss at
0.79 or more. The comment and the description say so.

With the committed judges the parent of the read-back work fails the
check on four counts, not two: the kernel is still running 1.0 s after
the ^X (the parent writes no cnc/stop), the tail rate is 100% of the
tick (the run ends at speed), the position is not read back (MPos X
one step past the kernel), and the homed mask goes 4 -> 0.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. Host dry
runs of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), configured as its CI
configures it (-DGFMCODE_WAIT_S=5.0), with the head accelerometer,
the kernel's state and bytes-played counter, and forgectrl stood in
for and the step counters taken from the shipped stream: with a model
kernel that ramps from the controller's cnc/stop write, the test
passes, MPos X 3489.06 steps against 3489, tail 23% of the tick, mask
4 kept, drift 0.000 mm; with a model kernel that ramps as cnc.c does
(the tick held 10 ms, then 5000 Hz off every 10 ms to the 900 Hz
floor), the test passes, MPos X 3509.97 steps against 3510, tail 32%.
Against the null-sink build of the parent of "Stream engine: a soft
reset during motion gets the kernel's ramp, and the position is read
back from the kernel's counters" (the commit "Test hooks: honored only
in a null-sink run, through one gate"), under the ramp model, the
read-back judge fails 5 runs in 5, MPos X one step past the kernel
each time (1.06, 1.06, 1.09, 0.99 and 1.10 steps); with the one-step
judge the same parent passed the read-back 3 runs in 5 (misses of
1.09, 0.95, 0.90, 0.98 and 1.09 steps). Every parent run also fails
on the kernel still running 1.0 s after the ^X, the tail at 100%, and
the mask 4 -> 0. The real counters and the machine's timing are read
on the machine only.

Acceptance: the change is inside the test's own function and its
decorator, so the shared text of suite/motion.py does not change. The
fingerprint of motion.jog-roundtrip moves, and no other test's: of
the implementation hashes of the 43 catalog tests, before and after,
only that one differs. The check runs on the bench in the next
campaign as part of motion.jog-roundtrip.
2026-10-17 19:06:48 -04:00
ScottW514 84a231163a laser_lifecycle_test: a ^X during the web-service homing keeps the lens hold
Rule 18 (home-reset) sends a ^X during the web-service homing session
and reads what the reset leaves. The session runs with homing_mode =
gfcloud and a session command that sleeps (sleep 47.25, a command no
other process on the host runs), so the device is the session's for
as long as the rule wants it; Session drops GFSINK from the inherited
environment, which is what lets gfcloud_home_cmd run.

A reset during the session marks the position lost: the device is
the session's, and nothing is read back. Left set, the core's re-init
clears the homed mask, and the driver's Z-only hold with it, so a jog
after $X would move an unreferenced lens. The rule reads:

- before the session, a 1 mm Z jog is refused with error 15: the lens
  has no reference in this session, so it is held where it stands;
- $H reports Home and the session's command is running;
- the ^X 0.5 s later lands in Alarm with ALARM:6, the session's
  command is gone within 3 s, and the attribute log holds no new
  "cnc/stop 1": the device was the session's, so the stream owed the
  kernel no stop;
- $X recovers to Idle; the Z jog is refused with error 15 again and
  MPos Z has not moved;
- a program move G91 G1 Z1 F100 raises ALARM:2, the soft limit of the
  pinned lens; a ^X and $X then leave the controller Idle.

The verdicts are the error code, the alarm codes, MPos Z, the
attribute count and the process count; no log wording is judged.
Session gets mpos_z(), the Z of a status report, beside mpos_x().

The soft-limit alarm asks for a reset before $X unlocks it, and the
rule sends that ^X only once a status report has come from the
alarm's wait: a ^X on the heels of the ALARM line lands while the
soft limit's own reset is still pending and is folded into it.
Measured on the null sink, a ^X 0 ms or 20 ms after the ALARM line is
lost (the alarm's wait goes on, $X answers error 79) and one 100 ms
after it is taken. The '?' polls that wait for the reset banner keep
the controller's output flowing: in Alarm its protocol thread waits
up to 10 s for traffic before it drains its output.

Proof: python3 -m py_compile and pyflakes 4.0.3 are clean on
scripts/bench/laser_lifecycle_test.py. Against the null-sink build of
the grblHAL-glowforge default branch head, which carries "Stream
engine: a reset whose position stays lost pins the lens before the
core's re-init", configured as its CI configures it
(-DGFMCODE_WAIT_S=5.0), rule 18 passes 10 runs in 10 and the harness
passes whole, with no session command left running. Against the
build of that change's parent ("Stream engine: the step a reset owes
is taken only when the idle lands between interrupt callbacks") rule
18 fails 3 runs in 3, each at the Z jog after $X: the parent answers
ok and the lens moves to Z 1.027. The harness runs in
grblHAL-glowforge CI, step "Armed-window lifecycle" (python3
forgefirm/scripts/bench/laser_lifecycle_test.py
build/grblHAL_glowforge), from the head of this repository's default
branch: this commit is pushed before that driver change.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 18:53:44 -04:00
ScottW514 c978e76b4c laser_lifecycle_test: rule 17's abort lands in a move above the step-smoothing cutoff
Rule 17's ^X mid-move goes out during G1 X120 F3000 instead of
G1 X30 F600, and the plausibility bound on the distance the abort
kept widens to the new move's length. Every other line of the rule
stays.

At $100 = 213.333 the F600 move cruises at about 2133 steps/s. The
core's step smoothing puts a rate between 2000 and 4000 steps/s at
level 2: four interrupt callbacks per step period, and only one in
four carries an X step. The core counts a step one interrupt callback
ahead of the pulse start that outputs it, so a reset leaves a pending
step only when its idle lands on a callback that carries one, one run
in four at F600. On the null sink the shipped backlog is empty by the
time the reset runs and the residue is zero, so that pending step is
all the position check can see, and the check is the harness's only
proof of the driver's hand-over of the step at the idle and of the
read-back correction. At F3000 the move cruises at 10667 steps/s,
above the 8000 steps/s cutoff of level 0, where every callback
carries a step and every reset owes one. The ^X goes out 0.5 s after
the move reports Run and lands near 56 mm of the 120, inside the
move.

Proof: python3 -m py_compile and pyflakes 4.0.3 are clean on
scripts/bench/laser_lifecycle_test.py. Against the null-sink build of
the grblHAL-glowforge default branch head, configured as its CI
configures it (-DGFMCODE_WAIT_S=5.0), rule 17 passes 10 runs in 10
and the harness passes whole. Two scratch mutants of that tree's
driver.c, each built the same way: (a) readback_position keeps
sys.position as the core counted it, with the position_lost handling
kept; (b) stepperGoIdle hands the engine no pending step, the step
and direction bits passed to gf_stream_go_idle forced to 0. With this
change each fails rule 17 in 5 runs of 5, every time on the position
check with MPos one step past the stream (17104.0 steps against
17103 for (a), 17197.0 against 17196 for (b), in their first runs).
With the parent's F600 the same mutants pass rule 17 15 runs in 20
(a) and 14 runs in 20 (b). The harness runs in grblHAL-glowforge CI,
step "Armed-window lifecycle" (python3
forgefirm/scripts/bench/laser_lifecycle_test.py
build/grblHAL_glowforge), from the head of this repository's default
branch.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 18:51:58 -04:00
ScottW514 18cba1daa2 forgetest: motion.jog-roundtrip sends a soft reset during a move and judges the kernel's stop and the position read back
motion.jog-roundtrip gets a check after its hold/resume segment, with
the head at the start and the machine at rest: a 60 mm +X G1 at F3000,
and a ^X 0.3 s after the kernel starts to play it. At a soft reset
during motion the grblHAL driver stops the kernel with its ramp, reads
the position back from the kernel's step counters, and keeps the core's
reference. No other catalog test sends a ^X while the head moves, and
the null-sink harness cannot read real counters: its residue is zero by
construction.

The check fails when:
- the controller is not in Alarm within 2 s of the ^X, or is Idle with
  no alarm (the move had ended before the ^X);
- cnc/state does not leave running within 1.0 s, or leaves it for a
  state other than idle, or cnc/underruns or cnc/faults change across
  the check;
- the kernel did not decelerate: over the run's last 25 to 50 ms, the
  bytes played (the position record, polled at 5 ms) are more than 70%
  of cnc/step_freq;
- the ring holds bytes after the reset;
- after $X, MPos X in steps ($100) and the kernel's X count differ by
  more than one step, or Y or Z moved;
- the homed mask changes, or the sender gets a [MSG:] line that the
  position cannot be read back;
- the jog back by the MPos distance leaves the kernel's X counter more
  than one step from its start.
The head is jogged back before these judgments, so a failure of one of
them leaves the head at the start.

The ramp witness is the tail rate because of the arithmetic. At the
stop the ring holds about 110 ms of ticks: the 200 ms queue and the
10 ms lead, less the 100 ms that the core's alarm hold plays. The ramp
to the kernel's 900 Hz floor uses 112.6 ms of ticks, in every mode,
because the ramp scales with the tick. So the run ends at the floor or
runs out of data part of the way down the ramp, and the steps that
never played can be zero. A minimum of unplayed steps in the driver's
read-back log line, the bytes played after the ^X, and the time to
leave running cannot tell the ramp from a run-out with a 20% margin.
The log line is also an INFO line, which the log level setting can
remove. The tail rate is 21% to 56% of the tick for 90 to 110 ms of
queue at the stop, and 100% for a run-out. The other readings and the
log line go to the evidence.

The homed mask comes from the controller port's state record (forgectrl
/motion/state): $# prints [HOME:...:mask] only with $22 on, and the
driver's default is off. Before the reset the mask must hold the Z bit
(the lens hold), because a mask without it cannot show a dropped
reference.

The test also requires kernel.k1-k2, the kernel's controlled stop on
the SoC. The title and the description say what the test proves now.
est_min stays 3.

Proof: compile and pyflakes 4.0.3 give no message on suite/motion.py
and no undefined name in forgetest or tests. forgetest unit tests,
python3 -B -m unittest discover -s tests -p test_<name>.py, in a
private PID namespace: test_manifest.py 39 OK, test_queue.py 15 OK,
test_campaign.py 13 OK. Coverage lint on the tree manifest from
scripts/manifest-from-tree.py (python3 -B -m forgetest.coverage
--manifest ../tree-manifest.json --enforce): 0 uncovered, 0 empty,
0 host-tested row problems, 43 tests. A host dry run of the whole test
against the null-sink build of grblHAL-glowforge f3158d3: the head
accelerometer, the kernel's state and bytes-played counter, and
forgectrl are stand-ins; the step counters are the steps of the shipped
stream; /motion/state is the controller's own control port. With a
model kernel that ramps from the controller's cnc/stop write, the test
passes: tail 23% of the tick, MPos X 3496.1 steps against 3496, mask 4
kept, the read-back log line found, the X counter back at its start,
drift 0.000 mm. With a model kernel that plays its queue to the end at
the tick, the ramp check fails at 100%. Against the null-sink build of
the parent (eb8eb61), the check fails on the mask (4 -> 0) and on the
read-back (MPos X 3483.1 steps against 3482). The real counters, the
ramp, and the machine's timing are read on the machine only.

Acceptance: the change is inside the test's own function, so the
shared text of suite/motion.py does not change. The fingerprint of
motion.jog-roundtrip moves, and no other test's: of the implementation
hashes of the 43 catalog tests, before and after, only that one
differs. The new check runs on the bench in the next campaign as part
of motion.jog-roundtrip.
2026-10-17 17:21:30 -04:00
ScottW514 9c768619f8 laser_lifecycle_test: a ^X during a move asks for the kernel's stop, and keeps the position and the lens hold
Rule 17 (cancel-abort) reads three more witnesses of a soft reset
during motion. Its session runs with GFSINK_ATTR_LOG, the sysfs writes
that the hardware would get, beside GFSINK_DUMP.

- After the ^X, the attribute log holds one more "cnc/stop 1" than
  before it: the controller asked the kernel to stop with its ramp.
- MPos X, in steps at $100, is the net X of the shipped stream within
  half a step: the position kept is where the stream left the head, so
  a step that the controller counted and never shipped fails the rule.
- After $X, a 1 mm Z jog is refused with error 15: the lens has no
  reference in this session, so it is held where it stands, and the
  abort keeps that hold.

The three new checks read no log wording: the attribute write, the
stream's bytes, MPos, and the jog's error code. The header's rule 17 says the same.
Session gets an attr_log option (GFSINK_ATTR_LOG, taken out of the
inherited environment otherwise) and three readers: setting(n) from a
$$ dump, attr_count(write) from the attribute log, and
shipped_x_steps() from the dump.

Proof: python3 -m py_compile and pyflakes 3.4.0 are clean on
scripts/bench/laser_lifecycle_test.py. The harness passes whole, on its
first attempt, against the null-sink build of grblHAL-glowforge with
the change that stops the kernel at a soft reset during motion and
reads the position back ("Stream engine: a soft reset during motion
gets the kernel's ramp, and the position is read back from the
kernel's counters", on top of eb8eb61), configured as its CI configures
it (-DGFMCODE_WAIT_S=5.0), and rule 17's session passes 10 runs in 10
there. Against the build of eb8eb61 the harness fails at rule 17, on
each of three attempts: "^X mid-move did not ask the kernel to stop
with its ramp (no cnc/stop write)". That build also accepts the Z jog
after the abort: a probe of it answered ok and then reported Z 1.027.
The position check, on scratch copies of the new driver: with the
core's pending step left out of the read-back, rule 17 fails 2 runs in
10, and with the read-back unapplied 3 runs in 10, each time with MPos
one step past the stream. The backlog is empty by the time the reset
runs, so a pending step is all that is left to miss, and a reset
leaves one only now and then. The harness runs in grblHAL-glowforge CI
(python3 forgefirm/scripts/bench/laser_lifecycle_test.py
build/grblHAL_glowforge) from the head of this repository's default
branch: push this commit first, then the driver change, as that
repository's AGENTS.md asks.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 16:44:26 -04:00
ScottW514 79796b305b forgetest: the workflow_steps docstring says which list items the stray-quote rule reads
The docstring said a key with one stray quote as the first key of any
list item makes every step count as no step. The parser reads a step's
first key on its dash line and a list item outside a step, such as a
matrix entry, and does not read a list inside a step's body, so a
stray quote there leaves the step counted. The docstring now says so,
as the Testing page does.

Proof: forgetest-ci "Unit tests" step, test_manifest.py: 39 tests OK
on the committed tree (python -B -m unittest discover -s tests -p
test_manifest.py). pyflakes: clean. Coverage lint on the tree manifest:
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. The change
is a docstring; no case changes.

Acceptance: none; forgetest is dev-only and manifest.py is in no
catalog fingerprint.
2026-10-17 09:27:43 -04:00
ScottW514 7a71dd80b5 forgetest: the implementation hash walks a sibling imported as from . import x too
_imported walked a relative import only when it names a module (`from
.x import y`): a level-1 ImportFrom with no module, `from . import x`,
was passed over, so the suite module x joined no hash through it. The
guard on the suite's package imports reads levels 2 and 0 alone, so it
passed that spelling, and its own message sends an absolute import
there: "import it relatively (from .. or from .)". So the guard and the
walk did not read the same spellings: on a scratch copy of the catalog,
forgectrl.auth's import of setup.py written `from . import setup`
passed the guard, and an edit to setup.py's decode(), which the test
calls, left forgectrl.auth's implementation hash where it was.
homeoff.py imports cloud.py so (`from . import cloud as logs`); its
`from .cloud import log_lines_since, log_size` on the next line keeps
cloud.py in the walk, so no hash misses a module.

_imported now walks every level-1 relative import: one with no module
walks each name it imports as <suite>/<name>.py, the name and not its
alias (a name that is no module of the suite is passed over, as a
module was). The docstrings of _imported and sibling_imports, and the
comment of the guard's case, name both spellings.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
gains a leg in which m.two imports judge.py as `from . import judge as
j`: an edit to judge.py moves m.two and not m.one. Negative controls on
scratch copies: catalog.py as it was fails that leg (the two hashes are
equal), and so does the walk that reads the alias (j) in place of the
name. On a scratch copy of the catalog with forgectrl.auth's import
written `from . import setup`, an edit to setup.py's decode() now moves
forgectrl.auth's hash. The coverage lint on the tree manifest from the
recipe pins reports 0 uncovered paths, 0 host-tested row problems, 43
tests. pyflakes reports no undefined name.

Acceptance: none. The source_sha of 43 of 43 tests is unchanged: the
one module-less sibling import outside the registry (suite/__init__.py,
which has no tests) is homeoff.py's, and cloud.py is in its walk
already. catalog.py is the registry and no shared judge, forgetest is
in no fingerprint, and tests/ are in no fingerprint.
2026-10-17 07:17:14 -04:00
ScottW514 614efb23e4 forgetest: the lint counts a host test only on a line of run text whose failure ends the step
workflow_runs counted any simple command in a step's run text as
running the test: _SPLIT_RX split the text on &&, ||, ;, |, newlines,
then, do and else, and every part was matched. So a step that hid the
test's failure, or never ran it, kept a HOST_TESTED row green, against
the HOST_TESTED comment's "a step ... whose failure fails it":
`./build/tray_test || true`, `| tee log`, `&`, `true ||
./build/tray_test`, an `exit 0` or a `set +e` line before the test,
`if false; then ./build/tray_test; fi`, a here-document's lines, a
folded block (`run: >`) whose next line is `|| true`, and a step
under `shell: bash {0}` or a `defaults: run: shell:` of pwsh each
counted. The workflow_runs docstring said "Only what a step's run
commands execute counts", which a command the shell skips, or whose
status it drops, does not meet. No workflow of the tree uses these
forms at a row's test.

workflow_runs now reads the run text line by line as bash -e or sh -e
runs it (_run_commands), a backslash at the end of a line joining the
next. _shell_words reads a line's words and operators, with quotes and
expansions ($(...), ${...}, `...`) inside the words. A command counts
on a line that holds it alone, with its redirections, or in a pipeline
under pipefail (shell: bash, or `set -o pipefail` on an earlier line),
and only while errexit holds: a `set` of errexit or pipefail is
followed. A pipeline with no pipefail does not count. The lint stops at
a line with any other operator (`||`, `&&`, `;`, `&`, a subshell, a
here-document), at a command that opens a compound command or negates a
status (if, case, while, until, a group, a function, `!`), at a command
after which it cannot tell what a later line runs (exit, exec, trap,
eval, source, alias, shopt, hash and the like), and at a `set` of an
option it does not follow (noexec): nothing on that line or after it
counts. A for loop counts in one form: `for <name> in <words>; do` on
its own line, body lines that count (a pipeline under pipefail only),
and `done` on its own line. A step counts only when its own shell:,
and the `defaults: run: shell:` of its job and of the workflow, are
each bash, sh or absent: workflow_steps now reads those defaults, and
reads a folded block, or a scalar continued on the lines under its key,
as one line, as YAML folds it. A prefix is taken off only with the
flags with which it runs its command (`sudo -E`, `env -i`), so `command
-v ./build/tray_test`, which prints the path, does not count; exec is a
stop, no longer a prefix. The HOST_TESTED comment and the docstrings of
workflow_steps and workflow_runs say what the lint reads.

The lint does not read, and counts as no step: `./build/x; true`
(bash -e stops at the test, and the lint refuses the `;` all the
same), `exec ./build/x`, a loop on one line or with `do` on a line of
its own, a pipeline with the test last and no pipefail, a test under
`set +e` whose status a later line checks, a test after a guard line
with `||` or `&&`, and a `set` of any other option (`set -E`). It
counts without reading: what a loop's body does with the loop's name
(its list counts), a `cd` before the test, a `defaults: run:
working-directory:`, what PATH or BASH_ENV change, and the runner's
operating system (it does not read runs-on; a Windows runner's default
shell is pwsh).

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains these legs, none of which runs the test: `|| true`, `true ||`,
`; true`, `&` and a pipe with no pipefail on the test's line; an `exit
0`, `set +e`, `set -n` or here-document line before it; a `{` group
that `} || true` ends; a loop whose body is `python $t || true`, or a
pipe with no pipefail; a folded block and a continued scalar with `||
true` under the test; a step under `shell: bash {0}`; a job's and a
workflow's `defaults: run: shell: pwsh`. A pipe under `set -o
pipefail` and under shell: bash, and a step under shell: sh, run it.
test_what_running_a_host_test_looks_like gains `command -v
./build/tray_test`, which does not run it; its loop case is the loop as
python3-gfhardware's CI writes it (an assignment from `$(basename "$t"
.py)` in the body), and its glob case is a loop on lines of its own.
Negative controls on scratch copies: manifest.py as it was fails the
first new leg (True is not false), and each new leg reads as running
the test there; 19 mutations of the new reading each fail a case: exit
no stop, errexit not followed, an unknown `set` option followed, a
here-document read as a redirection, no reserved words, pipefail always
on, shell: bash with no pipefail, `set -o pipefail` not followed, the
loop's body not read, a body pipeline with no pipefail read, a folded
block read line by line, a continued scalar's lines dropped, the job's
defaults not read, the workflow's defaults not read, any shell read,
sh not read, `$(` read as an operator, every flag of a prefix taken
off, and `sudo -E` kept. Under bash -e with a failing stand-in test,
the step exits 0 for `|| true`, `true ||`, `&`, a pipe with no
pipefail, an earlier `exit 0`, `set +e` or `set -n`, the `{ } || true`
group, both loop bodies and the folded block; `; true` exits 1. A
here-document's lines are its command's input (`cat <<'EOF'` exits 0),
and `bash {0}` runs with no errexit, so a line after the test decides
the step (exit 0). The 17 workflows of the tree's repositories (211
steps) read to the same events and steps; of their 161 run steps, 20
count fewer commands and 4 the same commands with a redirection taken
off or an expansion kept as written, none of them a step that runs a
row's test. Each
of the 46 HOST_TESTED rows runs its test under the new reading, in the
workflows at the recipe pins and at the heads of forgectrl and
forgeext. The coverage lint on the tree manifest from the recipe pins
reports 0 uncovered paths, 0 host-tested row problems, 43 tests.
pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 07:14:07 -04:00
ScottW514 3aecf800a5 forgetest: the lint's stray-quote rule reads a step's first key, on its dash line, too
The rule that a key with one stray quote makes every step of the
workflow count as no step never saw a step's first key, the one on its
dash line. _STEP_RX reads no key with a stray quote (`- run": make`,
`- name": Build`), and _stray_key cannot match a line that starts with
`- `. So such a step dropped out with no "unread" set, and the
workflow's other steps still counted. YAML reads the key with its quote
(PyYAML gives {'name"': 'Build', 'run': 'make'}), or refuses the file
(`- "run: make`), and GitHub refuses a workflow with a step key outside
the step schema: a HOST_TESTED row passed the lint for a workflow that
runs nothing. The HOST_TESTED comment, the workflow_steps docstring and
the Test page state the rule for a step's key, and every step's first
key is on its dash line. No workflow of the tree has such a key.

workflow_steps now reads the first key of every list item as it reads a
step's other keys: the dash and the spaces after it become spaces, and
a key with one stray quote there makes every step of the workflow carry
"unread", which _on_every_run refuses. The rule also takes in a list
item outside the steps (a matrix include entry): that only lists more.
The docstring says so.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step `- run": make` before a step that runs the test: no step
counts. Negative control on a scratch copy: manifest.py as it was fails
that case (True is not false). A probe of workflow_runs: `- name":
Build` with `run: make` under it, and `- "run: make`, each before a
step that runs the test, count as no step, as a stray quote on a
step's second key already did. The 17 workflows of the tree's
repositories (211 steps) read to the same events and steps before and
after the change. The coverage lint on the tree manifest from the
recipe pins reports 0 uncovered paths, 0 host-tested row problems, 43
tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 06:59:13 -04:00
ScottW514 609ae561bf forgetest: forgectrl's test scratch trees leave the repository, and the two still written are ignored
forgetest/cool-flow-test/, forgetest/super-test/ and
forgetest/cool-fanwrite-test/ (46 files) are the scratch trees that
forgectrl's cool_flow_test and super_test, and the fan-write test that
cool_flow_test took in, write in the directory they run from: fake
sysfs attributes, a cooling.state verdict, an empty dev/glowforge.
447f902 committed them beside a change to suite/exthost.py that needs
none of them. Nothing reads them: forgetest.bb fetches forgetest/LICENSE,
forgetest/forgetest/, forgetest/forgetest.init and scripts/bench/, no
manifest component holds them, and no other file of the repository
names them.

The three trees go. forgetest/.gitignore ignores the two that the
forgectrl tests still write, so a run from this directory cannot stage
them again.

Proof: git grep finds the three directory names nowhere else in the
repository. forgetest-ci "Unit tests" (`python -B -m unittest discover
-s tests`) on this tree with meta-openglow beside it, on Ubuntu 24.04
with python 3.12 in a private PID namespace: 357 tests OK, with
test_image.py's account-render case skipped there (it makes a namespace
through sudo -n); test_image.py on its own outside the namespace: 29
tests OK. pyflakes reports no undefined name. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests.

Acceptance: none. forgetest is dev-only, and the trees are outside
forgetest.bb's sources, every manifest component and every layer
content hash.
2026-10-16 23:56:28 -04:00
ScottW514 c849603942 bench README: the lifecycle harness row links the Test page plainly, as the bench page does
The row said that what laser_lifecycle_test.py walks is on the site,
Developers, "Testing". The Test page lists four of the fifteen sessions
main() runs, by choice: the harness header is the full list. And the
page is "Test" in the site's navigation, in its title and in the bench
page's own link; the site has no page called "Testing". The row now
says what the harness is and links the Test page by its title, as the
bench page of the site does.

Proof: README text only, no behavior change. The row names no session
and makes no claim about the page's list; its link text is the page's
title (mkdocs.yml: "Test: developers/testing.md"), and its URL does not
change.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 23:49:30 -04:00
ScottW514 6473e7d9ae forgetest-ci: the undefined-name step prints why it failed, and fails a run SIGINT stopped
The comment over the step said that a signal fails it. pyflakes ends a
SIGINT and a SIGPIPE itself, with status 1 ("... stopped" for SIGINT),
and status 1 goes on to the grep, so a run SIGINT stopped mid-tree
passed the step with nothing judged. And every failure the step gained
for a status other than 0 or 1 left no cause in the step log: the
group's 2>&1 also captures bash's own notice of a signal ("Killed"),
and nothing printed pyflakes.txt, so a usage error or a killed run
showed only the pyflakes version line before the failure.

The capture is printed when the pyflakes group fails, and the grep
also takes the "... stopped" line. The comment says what fails the
step: the grep's lines, a SIGINT stop, any status other than 0 or 1,
and a capture that cannot be written; a SIGPIPE ends with status 1 and
no line, and a write to the capture file raises none.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line a no-op: the interpreter on PATH is a shim to a
venv). The tree as it is exits 0 with both bodies, and an undefined
name planted exits 1 with its line in the log. A run that exits 2 and
a run killed by SIGKILL while it blocks on a FIFO exit 1 with both
bodies; the step log of the former body holds only the version line,
and this one prints "unrecognized arguments" and the "Killed" notice.
A run stopped by SIGINT exits 0 with the former body and 1 with this
one, its "... stopped" line in the log. A working directory that
cannot be written exits 1 with both, its "Permission denied" in the
log.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 23:49:02 -04:00
ScottW514 fc6b7797af forgetest: the shared judges' comments say why puls.py is one
The comment over SHARED_JUDGES in catalog.py said that every test runs
through all four judges without importing them, puls.py among them, and
the comment of test_a_shared_judge_edit_moves_every_test said the same.
No test runs puls.py but the three that print the dark job
(cloud.lid-interlock-abort, cloud.oversize-stream and
exthost.armed-freeze, through offline_job), and suite/cloud.py imports
it with `from .. import puls`: it is a judge because the sibling walk
does not follow that import, as the Acceptance page of the site says.

Both comments now say that every test runs through runner.py, hw.py and
baseline.py with no import to follow, and that puls.py comes in by a
package import the walk does not follow. Comments only: SHARED_JUDGES
does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. The only import
of puls in the package is suite/cloud.py's (grep). pyflakes reports no
undefined name.

Acceptance: none. catalog.py is the registry and in no fingerprint, and
tests/ are in none (source_sha of 43 of 43 unchanged).
2026-10-16 23:46:48 -04:00
ScottW514 a10fd7782d forgetest: the hash cases hold what a test's own import imports in turn, and the guard refuses an absolute package import
No case held the step of implementation_sha that adds what a test's own
import imports in turn (`sibling_imports(sib, sibs)`), nor the
recursion in sibling_imports itself: the judge.py of
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
imports nothing. Either line deleted kept test_manifest.py green, while
on the real catalog the first drops motion.py's shared text from
cloud.mode-switch's hash and setup.py's from motion.release's. The guard
on the suite's package imports read the relative spelling alone: a
`from forgetest import server`, an `import forgetest.server`, or a
`from forgetest.suite.setup import ...` in a suite module passed it,
and the sibling walk does not follow any of them, so the module they
name joined no hash.

The case's judge.py now imports limit.py, which imports base.py: an
edit to either moves m.two and not m.one. The guard refuses any import
of the package by its absolute name in a suite module, at any depth.
No suite module imports the package that way. catalog.py does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. Negative
controls on scratch copies: catalog.py without `sibling_imports(sib,
sibs)` in implementation_sha fails the case at the limit.py edit; the
walk without its recursion fails it at the base.py edit; in
suite/forgectrl.py, a `from forgetest import server` and an `import
forgetest.server` inside a function, and forgectrl.auth's import of
setup.py spelled `from forgetest.suite.setup import request, decode`,
each fail the guard. pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:45:25 -04:00
ScottW514 2af9b0277f forgetest: the lint's docstrings and its row reason say what it reads, not what a workflow runs on
host_row_problems' docstring gave the reason a row is listed as "a
dispatch, a call or a schedule runs on no change". A workflow on
workflow_call alone runs on every run of the workflow that calls it
(forgeext's package.yml is one); the lint lists it because it does not
follow a call to its caller. The reason it printed, "<ci> runs on no
push or pull request", was false the same way for such a workflow, and
for a flow mapping (`on: {push: ...}`) that names push. The docstrings
of _on_every_run and workflow_runs said that a step under an `if:`
other than true does not run on every run, where the lint evaluates no
condition: a step under `always()` or `success()` runs on every run and
still counts as no step. The Test page of the site says each of these
as the lint applies it.

The docstrings now say what the lint reads: a row is listed when the
workflow's own `on:` names neither push nor pull_request as the lint
reads it (a dispatch, a call or a schedule alone, a call not followed
to its caller, a flow mapping), and a step with a condition counts as
no step because the lint evaluates none. The row's reason reads "<ci>:
the lint reads no push or pull_request in its own on:". The comments
of the two cases that hold these rules say the same. No rule changes:
the same rows are listed, for the same workflows.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_the_workflow_has_to_run_the_host_test asserting the new reason. A
probe of host_row_problems: a workflow on workflow_call alone and one
whose flow-mapping `on:` names push are each listed with the new
reason, and a plain `on: [push]` one is not. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:43:23 -04:00
ScottW514 c717ecbcce forgetest: the lint reads a quoted key only in a matched pair, and a key with one stray quote runs nothing
_KEY_RX took an opening quote and a closing quote each on its own, so a
key with one stray quote read as the bare key: `push":` and
`pull_request':` counted as push and pull_request, where YAML reads the
keys `push"` and `pull_request'`, which are no events; a step's
`run": ...` counted as its run command, and `uses": actions/checkout@v4`
as a checkout. A HOST_TESTED row whose workflow was spelled so passed
the lint, while that workflow runs on no push and runs no such step.

_KEY_RX now reads a quote only as a matched pair. A key with one stray
quote is no key the lint reads, and where the lint reads keys it counts
as running nothing: an event key spelled so makes the `on:` name no
event, and one at the top level, at a job's name or key, or at a step's
key below its dash line makes every step of the workflow count as no
step (each carries "unread", which _on_every_run refuses). So a step's `if": false` still
keeps the step from counting, as it did under the loose reading, and
under a `jobs":` top level, where a job's `if: false` goes unread, no
step counts either. The HOST_TESTED comment says so. The lint only
lists more: a workflow with no such key reads as before.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_the_events_a_workflow_runs_on gains an `on:` with `push":`, and
one with `push:` and `'pull_request:`, each naming no event, and
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step with `if": false`, a step with `run": ./build/tray_test`,
a job with `if": false` and a `jobs":` top level, none of them running
the test. Negative controls on scratch copies: _KEY_RX as it was fails
the `run":` step and the `push":` event; the matched pairs with no
stray-quote rule fail the `if": false` step and the second `on:`; the
rule at a step's keys alone fails the job's `if": false`. The 17
workflows of the tree's repositories (211 steps) read to the same
events and steps before and after the change. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:41:24 -04:00
ScottW514 49f087f376 forgetest: a failed press is recorded as the fixture's only when its request went out
_press_taken recorded the press as the fixture's for every FixtureError
that arrived with the button out. That included failures in which the
box made no press for the request: its refusal (a 401, or the 409 of a
jumper pulled after the probe), a failed mDNS lookup, a refused
connect. When the person at the machine pressed meanwhile, or the arm
wait ended for another reason, the evidence and the exported artifact
gave the box a press it never made, and press_now returned True.

FixtureError now carries `sent`: True when the failure came after the
request went out (a timeout or a reset once the request was sent,
which urllib raises bare; a failed connect or send is a URLError), so
the box may have pressed. The one retry after a fresh lookup keeps it,
also when the lookup itself fails. A refusal and a failed lookup leave
it False. With the button out, _press_taken still puts up no notice,
and it records the press as the fixture's only for a sent error;
otherwise the record stays the operator's, and press_now returns
whether the fixture pressed. No judge reads `by`, and no energy path
changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_fixture.py runs 22 tests, OK on Ubuntu
24.04 with python 3.12, and on Windows with python 3.14 with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_a_failure_after_the_request_went_out_says_so drives the real
client against the fake box: a press whose reply comes after the
client's timeout is sent, with one press, also when the refresh lookup
after it finds nothing; the jumper-out refusal, a failed lookup and a
refused connect are not sent, with no press.
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box refuses while the button goes out: press_now returns
False with no notice and the record the operator's, and arm_press
leaves the same. test_server.py (10), test_operator.py (17),
test_queue.py (15), test_mode.py (9), test_setup_dark.py (21) and
test_setup_burns.py (24) pass on both. Negative controls on scratch
copies: _press_taken giving every failure to the box fails the refusal
leg (press_now True); `sent` never set fails the first client leg;
`sent` set for a URLError too fails the refused-connect leg; the retry
that drops the flag fails the retry leg. pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move); fixture.py
is in none. Against the campaign this series already needs, that adds
no run: 850498f moved all 43 once. tests/ are in none.
2026-10-16 23:36:59 -04:00
ScottW514 e046c534c2 setup.focus: the tube-current case plants a reading at HV_ON, so it holds the threshold
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails planted
pic/hv_current at 0. That proved the check on the test's own reads
exists, but not its threshold: the check written as `> 0`, or as
`>= HV_ON`, passed every case. The case now plants HV_ON itself (30,
which is not over 30), so with the 431 the passing cases plant, the
threshold is held between 30 and 430. setup_burns.py does not change.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. Negative controls on
scratch copies: the check as `> 0`, as `>= HV_ON`, and as no check at
all each fail the case (Failed not raised); the case as it was (a
plant of 0) passes against the `> 0` check. pyflakes reports no
undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:30:06 -04:00
ScottW514 36c1b75bb8 setup.focus: the head's place judged against the stops its manual home declared, read from the home anchor
head_at_home placed the gantry stops at minus manual_home_x and
manual_home_y as the settings read at the burn. A manual home reads
them once: it declares the head at the stops at minus the offsets, and
with an offset it moves the head to X0 Y0, that far in front of them. A
write of the offsets after the home restarts no controller and keeps
the X/Y reference. So a home made with 5 and 3, the offsets then
cleared, had the head at X0 Y0 judged home 5 and 3 mm in front of the
stops, and the burn started: the false PASS 2172ae4 set out to close.
The other way round, offsets set after a home made with none failed a
head that was at the stops. The home anchor holds the stops as the home
declared them: /status reports the anchor's X and Y plus the step
counters.

head_at_home now takes the stops from the X and Y of the anchor
(baseline.ANCHOR_PATH) when /status reports X and Y referenced by a
manual home, and judges the head against them within HOME_TOL_MM. An
anchor that is gone, or that is not a manual home of X and Y, fails the
test before any start. The settings are read for the remedy alone. With
an offset, the remedy names how the push ends: release the motors, push
the head against the stops, and end the release with Energize motors
($ME), not with Set home here, which moves the head off the stops to X0
Y0 again. The remedy before said only to release the motors and push
the head, and a release left standing makes the burn ask home-push,
which the test does not answer. Without an offset it stays "home it
there". The judge only refuses more: no energy or motion path changes.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. BurnRunTests plants
the anchor of a home with no offset beside the fake's /status, and
test_a_manual_home_offset_puts_the_stops_off_x0_y0 plants the anchor of
each home: a home made with 5 and 3 fails the head at X0 Y0 and passes
it at X-5 Y-3; the offsets cleared after that home still fail the head
at X0 Y0 before any start (no wizard start, no lever); the offsets set
after a home made with none pass the head at X0 Y0; an anchor that is
gone, or that reads as the startup's Z-only reference, fails before any
start. That last pair replaces the leg where an offset that did not
read as a number failed: the stops no longer come from the settings,
and the rule that leg held (no stops placed, nothing starts) holds on
the anchor. test_setup_dark.py (21) and test_manifest.py (39, with the
guard on the suite's package imports, which setup_burns.py's import of
baseline.py passes) pass. Negative controls on scratch copies:
head_at_home as it was fails the case at the offsets cleared after the
home (Failed not raised), and so do the stops read from the settings;
an anchor that cannot be read taken as X0 Y0 fails it at the anchor
that is gone; an anchor of any axes and any source fails it at the
startup anchor. A probe of the failure text: with the offsets 5 and 3
it names Energize motors ($ME), not Set home here; with none it says
"home it there". pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text and the test's own description); the other 42 do not move
(source_sha compared before and after). baseline.py is a shared judge
already, in every hash. Against the campaign this series already
needs, that adds no run: 850498f moved all 43 once.
2026-10-16 23:28:00 -04:00
ScottW514 ce73dbb052 forgetest: a check run_check stops following at its timeout is aborted before the test fails
run_check judged its timeout with ctx.check, which raises, ahead of the
abort meant for that case, so the abort never ran: a check still
running at the end of its follow was left running behind the FAIL. For
setup.focus that is a live burn wizard. Its put-back waits 120 s for
the burn to end and then skips the controller restart, and forgectrl's
own bound on a live run (1500 s) outlasts the test's 900 s follow. The
same driver serves the setup.check-* tests,
setup.cloud-header-capture and motion.release.

The abort now goes out before the timeout is judged, and the
unreachable return after it is gone. The change only ends a check the
test started itself: it adds no energy and no motion.

Proof: tests/test_setup_dark.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 21 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_check_that_does_not_end_in_time_is_aborted_before_the_test_fails
(a lens.travel check that never ends, followed for 0.2 s: Failed, and
/wiz/lens.travel/abort posted). test_setup_burns.py (24) and
test_setup_suite.py (15) pass. Negative control on a scratch copy: the
timeout judged before the abort, as it was, fails the case (only
/wiz/lens.travel/start posted). setup.focus run whole against
test_setup_burns.py's scripted daemon with a burn that never ends (the
follow cut to 0.5 s, the press prompt held open): before, Failed with no
abort, the wizard still running and the restart skipped ("the burn is
still ending"); after, Failed with laser.focus aborted, the wizard
stopped, and the controller stopped and started (200, 200). pyflakes
reports no undefined name.

Acceptance: the shared text of setup_dark.py moves the fingerprints of
its five tests (setup.check-switches, setup.check-airflow,
setup.check-motion, setup.check-lens, setup.cloud-header-capture), of
setup.focus (setup_burns.py imports it at its top level) and of
motion.release (its own function imports run_check); the other 36 do
not move (source_sha compared before and after). Against the campaign
this series already needs, that adds no run: 850498f moved all 43
once.
2026-10-16 23:00:54 -04:00
ScottW514 7b0f065a3f laser.emission-witness: the head is placed back only by a sample that read the window open
rapid_place searched the samples up to the first disarmed one, that one
included, and sample_to_disarm read each sample's X after its armed
flag. So the first sample to read the window closed could place the
head back with an X read after the close: a window that closed on the
return leg, up to about one sampling loop before the head was back,
placed the rapid inside the window with a disarm time of 0, and the
`0 <=` on that time could not fail.

rapid_place now searches the samples before the first disarmed one
alone, and sample_to_disarm reads the controller's report (its state
and X) before the sample, so a sample that reads the window open read
its X with the window open. The head stands back for 1 s before the M2,
not 0.5 s: the placement needs a sample whose X and armed flag both
come between the head's return and the close, and a slow sampling loop
needs that time. The judge only gets stricter. No energy or motion path
changes: the longer dwell runs dark after the M5 with the window open,
as the shorter one did.

Proof: tests/test_laser_dwell.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 13 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
gains the close at the first sample with the head back (2, None, 6),
and test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
holds the order (each sample's X read before its armed flag).
test_cooling_suite.py (3) passes. Negative controls on scratch copies:
the search with the first disarmed sample included fails the first
case with (2, 6, 6); the report read after the sample fails the second.
emission_witness run whole on a fake clock with only its I/O stubbed
(the review's sweep: the armed flag served mid-GET, X read mid status
report, 40 phases of the rapid against sampling loops of 0.20 to
0.64 s): a window closing 0 to 2 s before the head is back fails the
placement at every phase and every loop, and the normal close passes
at 40 of 40 phases at every loop. The code before this change passed
early closes at some phases up to about one loop early (0.20 s at a
0.20 s loop, 0.5 s at a 0.64 s loop). With the new order and the 0.5 s
dwell, the normal close passed 39 of 40 at a 0.38 s loop and 12 of 40
at a 0.64 s loop. pyflakes reports no undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text and
laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
placement on the bench.
2026-10-16 22:56:33 -04:00
ScottW514 d57421c71d bench README: the lifecycle harness row points at the Testing page
The row for laser_lifecycle_test.py in scripts/bench/README.md credited
the harness with M5/M3 persistence inside a job, the M2 close, and the
arm refusal under a blocking cooling verdict. The harness runs no
session for any of the three: its header gives them to laser_arm_test.c
case I, laser_stream_test session K, and laser_arm_test.c cases C, A2
and A3. The row also restated a list that the site keeps (Developers,
"Test"), and a README is an index card. The row now says what the
harness is and points at that page, as the bench page of the site does.

Proof: README text only, no behavior change. The three claims are gone
from the file, and each of the four sessions the Test page lists
is one main() runs.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 14:54:18 -04:00
ScottW514 50c56a863d laser_stream_test: rule 19 names every laser session that runs with the dose curve in force
Rule 19 of the header said that every session but the rolloff reference
run has laser_dose_curve = off. Beside that run, four laser sessions run
with the curve in force: rolloff-g1 and rolloff-g2 (rule 20) and
rolloff-two (rule 23) under DENSITY_CONF_CURVED, which carries no curve
key, and jog-dark (rule 22), which runs with no configuration at all,
so the controller's curve_default applies. Rule 20 itself says "under
M4 with the curve in force". Rule 19 now says that every laser session
but the rolloff ones (rules 20 and 23) and the jog run (rule 22) runs
with laser_dose_curve = off. The z session carries no laser key and
fires nothing, so "laser session" leaves it out.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_stream_test.py, and the
harness passes whole, every session, against the native null-sink
build of the driver at badc832 (-DGFMCODE_WAIT_S=5.0). The harness runs
in grblHAL-glowforge CI (the step "Laser stream emission rules",
python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-16 14:53:14 -04:00
ScottW514 2172ae4101 setup.focus: the head's place judged against the gantry stops, at minus the manual home offsets when they are set
head_at_home judged a head referenced by a manual home at X0 Y0, on
the premise that a manual home declares X0 Y0 at the gantry stops. That
holds only while manual_home_x and manual_home_y are blank or 0. With an
offset, the manual provider declares the head at the stops at minus the
offsets and then jogs it to X0 Y0, that far in front of them
(glowforge_homing.c manual_home; the Homing pages of the docs), and
/status reads pos X0 Y0 there. The wizard draws every card from the
head's home against the stops (wizlive.c head_home; Setup, "The head at
home"), and the test answers HOME to that prompt. So with an offset the
judge recorded judged True and "the head is home on record" for a head
the offsets in front of the stops, the burn ran from there, and its box
landed that much farther from the stops. The failure message (home it
there) rebuilt the same state.

The sources put the home of the burn at the stops, and the stops at
minus the offsets in the coordinates /status reports, so the judge now
judges the head there. head_at_home reads the two offsets from
/settings (blank is 0; a value that is not a number of 0 or more fails
the test before any start), records the stops it judges against in
head_at_start, and judges pos against them within HOME_TOL_MM. With no
offset that is X0 Y0, the rule as it was. With an offset, a head that a
manual home left at X0 Y0 fails before any wizard start, with no
controller lever touched, and the message names the offsets, where
they put the stops, and the remedy: release the motors and push the
head against the stops (the head's place then rests on the operator's
word, as for any unreferenced head, once the release ends with
Energize motors). Judging at X0 Y0 under an offset
would affirm a place the wizard does not ask for, and recording the
read without judging it would let the test answer HOME with a read in
hand that puts the head away from the stops; neither is taken. The
offsets are read as they stand at the burn: /status does not report
the offsets the home used, and the home anchor, which holds the stops
that home declared, is not read. The description and the comments say
what is judged.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 24 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_manual_home_offset_puts_the_stops_off_x0_y0 (manual_home_x 5 and
manual_home_y 3, homed_axes 7, home_source manual: at X0 Y0 the test
fails before any start, the stops on record at X-5 Y-3, no /controller/
lever posted, the settings as found; at X-5 Y-3 the burn runs, judged
True; manual_home_x "five" fails before any start), and the lit burn's
record carries the stops at X0 Y0. Negative controls on scratch copies:
setup_burns.py as it was, and the offsets taken as 0, each fail the new
case at its first run (the burn starts). pyflakes reports no undefined
name.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other test moves (source_sha compared
before and after). Against the campaign this series already needs, that
adds no run: 850498f moved all 43 once.
2026-10-16 14:48:32 -04:00
ScottW514 9451587728 setup.focus: the test's own sysfs reads go on record however the burn ends, and their tube-current check has a case
emission_seen, the two peaks the test reads from sysfs at every poll
and the count of polls that read both, was written inside emission_ok
after the three checks of the burn's result. A burn the daemon reports
dark fails at the first of them (Context.check raises), and a burn that
ends in a wizard error, a timeout or an abort never reaches emission_ok.
On each of those paths the record kept the daemon's word alone, where
the test's own reading is the one worth comparing with it. burn() now
records emission_seen, and logs it, in the finally of its run_check, so
it is on record however the burn ends. emission_ok keeps its checks, in
their order.

The check that the tube current read over HV_ON from here had no case
that could fail it: the one negative case reads both attributes at 0
and stops at the LASER_ON check, which comes first. A new case reads
the LASER_ON count nonzero and the tube current at 0, and fails on the
tube current.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 23 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12, with
tests/test_setup_dark.py (20 OK):
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails
(cnc/laser_on_sampled 118, pic/hv_current 0: Failed "the tube current
never read over 30 from here", the settings as found);
test_a_dark_burn_fails_and_still_restores finds emission_seen at 118
and 431 beside the dark result; the aborted burn's record carries
emission_seen too. run_test keeps the Run of a test that fails.
Negative controls on scratch copies: the tube-current check made
vacuous (True or ...) fails the new case (Failed not raised);
setup_burns.py as it was fails the dark case (no emission_seen) and the
aborted case. pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text); no other test moves (source_sha compared before and after).
Against the campaign this series already needs, that adds no run:
850498f moved all 43 once.
2026-10-16 14:44:40 -04:00
ScottW514 bed46e961e laser.emission-witness: the read rule ends where the sampling stopped, and the rapid is placed inside the window
The dark stretch after the M5 runs on past the close, and three of its
judgments did not hold with it.

The read rule took the last sample served as the trail's last edge, and
sample() returns None for a request the daemon does not answer. So an
outage after the served close (one request that hangs to the client's
10 s timeout is enough) left a trail that ended at the close with no
gap, and the window that latches after the close was never read: the
test passed whole with a 30 ms fire at the end of the return rapid.
The sampling could also stop as early as about 1.8 s past the close,
and with a 0.9 s unread tail allowed, the last reading could come
before the latch of the window in flight: the review's tail model
(the kernel's ~1 s window latch, the sampler's GET timing, a fire just
before the close) finds 4 runs in 20000, 21 with a wider GET spread,
where that window goes unread and the judge passes.

The placement and the disarm timing searched the whole stretch. A
window that closed before the head was back (in the dwell at +20 mm
or on the return leg, up to about 2 s early) found the head back after
the close and passed "the rapid ran inside the dark stretch", with a
negative disarm time.

The rapid's sample count in the record ran past the close while its
span stopped at the close, so the record read about 8 Hz for a 5 Hz
loop.

sample_to_disarm moves from emission_witness to module level, ends two
windows and EMISSION_READ_GAP_S past the close, and returns the time
the sampling stopped beside the samples. read_gaps takes that time as
the trail's last edge (t_end), so a tail the daemon served none of is a
gap. rapid_place judges the placement over the armed part alone, up to
the first disarmed sample, and the disarm time must be 0 or more. The
rapid block counts its samples to the close and records how long the
sampling ran past it (sampled_past_close_s), and the log line says
both. The relit, HV and read judgments still cover the whole stretch.
The description says what is judged. tests/test_laser_dwell.py drops
relit(), a copy of the judge that no change to laser.py could fail.
Every change makes the judge stricter: no energy or motion path
changes.

Proof: tests/test_laser_dwell.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 13 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_tail_the_daemon_never_served_fails (every sample served read,
then nothing for 2.9 s before the sampling stopped: gap 2.9 s, the rule
fails; served through, it holds),
test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
(sample_to_disarm on a fake clock, the daemon serving the close and
nothing after it: the sampling ends at least 2.9 s past the close and
the rule fails), and
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
(a close after the return places out, back and the close; a close in
the dwell at +20 mm leaves back None). test_cooling_suite.py (3) and
test_setup_burns.py (22) pass. Negative controls on scratch copies:
read_gaps without the end edge fails the first two cases; the end at
two windows fails the second; sample_to_disarm that returns the samples
alone errors in the second; rapid_place over the whole stretch fails
the third. emission_witness run whole on a fake clock with only its I/O
stubbed: a window closing 0.2, 1.0, 1.9 or 2.5 s before the head is
back fails the placement (the code before this change passed the first
three), and a normal close passes with 22 samples over 4.07 s to the
close in the record (32 before). Against a simulated daemon and kernel
latch with that 30 ms fire: an outage after the served close and one
request hung to the 10 s timeout fail the read rule (gaps 3.05 s and
10.32 s; both passed before); a tail served through with no fire
passes. In the review's tail model, the end edge alone and the longer
sampling alone each leave 0 of 20000 runs with the window in flight
unread and the judge passing, in both GET spreads. pyflakes reports no
undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
rapid with the end edge on the bench.
2026-10-16 14:40:53 -04:00
ScottW514 41ca7fab82 forgetest-ci: the coverage lint step fails when the lint fails, not only when tee does
The "Coverage lint (enforced)" step pipes the lint into tee for the
step summary. A step with no shell: runs under bash -e with no
pipefail, so the status of the step was tee's. An uncovered path, an
empty covers entry, a host-tested row problem (the lint exits 1) and a
manifest the lint cannot load (exit 2) each passed the job: the first
three after they printed the report, the last after one error line on
stderr and an empty step summary. The header of the workflow and the
Testing page say that an uncovered path fails the job, and this step
is the only place that runs the lint with --enforce.

The run block now starts with `set -o pipefail`, so the status of the
step is the lint's. The tee to the step summary stays. A comment above
the block says why.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e from
forgefirm/forgetest of a copy of the tree on Ubuntu 24.04 with python
3.12, GITHUB_STEP_SUMMARY a file. The tree manifest from the recipe
pins: both bodies exit 0 and write the summary (0 uncovered paths, 0
host-tested row problems, 43 tests). The same manifest with one
uncovered forgectrl path planted: the lint reports 1 uncovered path,
the former body exits 0, this one exits 1, and both write the summary.
The manifest missing: the former body exits 0, this one exits 2.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:27:06 -04:00
ScottW514 eb684d366e forgetest-ci: the undefined-name step fails on a pyflakes status other than 0 or 1
The step captured the pyflakes run with `|| true`, because pyflakes
exits 1 for any message, and judged the capture alone: "undefined
name" or "Traceback". A run that ended abnormally with no traceback
passed the step with nothing checked: a run killed by a signal, a usage
error (exit 2), or a working directory where the capture cannot be
written (grep then reads no file, and the `if` is false).

The capture is now `{ python -B -m pyflakes forgetest tests || test $?
-eq 1; } > pyflakes.txt 2>&1`. The two normal statuses of pyflakes, 0
and 1, go on to the grep; any other status fails the step under
bash -e, and so does a redirect that fails. The grep stays the gate
for an undefined name and for a Traceback. A comment above the run
block says so.

Proof: the step body, read from this workflow and, as the control,
from the workflow before this change, run under bash -e over the
forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line left out: the interpreter on PATH is a shim or a
venv). Both bodies: the tree as it is exits 0; an undefined name
planted, pyflakes missing, and a pyflakes that raises each exit 1. A
pyflakes killed by SIGKILL, one ended by SIGTERM, one that exits 2,
and a working directory that cannot be written: the former body exits
0 each time, this one exits 1. PyYAML reads the run text of the step
as written.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:24:54 -04:00
ScottW514 9ea9c5d10f forgefirm-image: the pack-licenses test says what tar does with a member it cannot read
The docstring of the case that holds the refusal said that the
manifest check alone passes a bundle truncated after the unreadable
member, and a comment on the member's directory said that it sorts
after license.manifest. Neither is the mechanism. tar reports an error
that it cannot open the member, skips it, writes every other member
and the end of the archive, and exits nonzero only at its end, a
status the former pipeline lost. Where the member sorts does not
matter. The docstring now says that tar skips the member and the
manifest check alone passes the bundle without it, and the comment is
gone.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests, 5 tests OK. With
the pack script as it was before 03146a1, the case fails with the
member under zlib/ and with it under aaa/ (exit status 0 both times);
with the script as it is, both placements pass. At the shell, on a
fixture tree with the unreadable member under aaa/ and then under
zlib/, the script as it was exits 0 both times after "Cannot open:
Permission denied", writes a bundle that holds every other member
(zzz/generic_Last, which sorts last, included), and removes the tree.
Text only, so no negative control applies to the change itself.
pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint.
2026-10-16 14:23:02 -04:00
ScottW514 5d26818946 forgetest: a press the fixture made and reported failed leaves no notice standing
press_now and arm_press hand the arm press to the operator when the
bench actuator reports that the press failed. When the box makes its
200 ms press and its reply is lost, the client raises that failure at
its 3 s timeout or later (with ip set in the config it does not
retry), and by then the controller has taken the press and put the
button out. The fallback put the notice up all the same and started
the watch, which waits for the button to light before it waits for it
to go out. The button does not light again, so "Press it now" stood
through the job and asked for a second press, and a press during the
job pauses it.

Both fallbacks now record the fixture's error and then read the
button. Out: the press was taken, it is recorded as the fixture's, and
no notice goes up. Lit, or unreadable: the notice goes up with its
watch, as before. Nothing presses for the operator, and no judge or
energy path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 and on
Windows with python 3.14: test_fixture.py's
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box puts the button out and then reports a failure: press_now
returns True with no notice and the press recorded as the fixture's,
and arm_press leaves no notice and the same record. The file's 21
tests OK on Ubuntu, and on Windows with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_setup_burns.py (22), test_setup_dark.py (20), test_server.py
(10), test_operator.py (17), test_queue.py (15) and test_mode.py (9)
OK on Ubuntu. Negative controls on scratch copies: the leg
against the runner as it was fails at press_now (False, the notice
standing); with arm_press's fallback alone as it was, the leg fails at
its record ('operator', the notice up). pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in none.
2026-10-16 14:21:24 -04:00
ScottW514 8d16b93e86 forgetest: the shared judges and a test's own imports reach the hash the gate reads
The sibling walk read the top-level statements of a module alone. Five
tests import their judging helpers inside their own functions, so that
no other test of the module moves with them: cloud.mode-switch
(homeoff.py: judge_whole_motions, camera_home_return),
cooling.flow-under-load and cooling.fail-tier-stop (laser.py's sample
and run_and_sample, motion.py's log readers), forgectrl.auth (setup.py's
request and decode) and motion.release (setup_dark.py's run_check).
None of those helpers was in the hash of its test, and homeoff.py was
in no hash at all: an edit to judge_whole_motions moved 0 of 43 tests,
and an edit to laser.sample moved the three laser.* tests and neither
cooling test, so an inherited PASS survived a change to its own judge.
The guard that every package module a suite module imports is a listed
judge read the top level alone too, and no unit test held the
production path: Test.source_sha, which the fingerprint, the campaign
and the gate read, could drop the shared judges with CI green.

implementation_sha now adds to the hash of a test the suite modules its
own @test function imports, at any depth, and what they import in turn;
no other test of the module takes them. The shared text of a module
gives the modules it imports at any depth too: a helper that imports
inside its body brings that module into every test of the module, as
the helper itself does. _test_defs finds the @test functions of a
module for module_parts and for both walks. The guard walks the whole
tree of each suite module, so a package import inside a function or a
block is held to the list as well. test_a_shared_judge_edit_moves_every_test
holds the production path on a suite test: its source_sha equals
implementation_sha with SHARED_JUDGES and differs from it with none.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
(a judge imported in the body of one test moves that test and not the
other; imported in the body of a helper, it moves both), the
source_sha assertions, and the guard on the whole tree; test_artifact.py,
test_campaign.py, test_redact.py and test_responsiveness.py pass.
Negative controls on scratch copies: implementation_sha's default
taking no judges fails the source_sha assertion, and so does
Test.source_sha passing judges=(); the new case fails against
catalog.py as it was, and against a shared-text walk of top-level
statements alone; a `from .. import server` inside a function of
suite/forgectrl.py, or in a try block at its top level, fails the
guard, and the function-local one passes the guard that reads the top
level alone. On the real catalog: an edit to judge_whole_motions moves
cloud.mode-switch alone (0 tests before); an edit to laser.sample moves
the three laser.* tests and both cooling tests (the three alone
before); an edit to run_check moves the six setup.* tests and
motion.release (the six alone before); an edit to setup.request moves
forgectrl.auth and motion.release (motion.release through setup_dark.py,
which imports setup.py) beside the 8 it moved before; an edit to
hw.sysfs_int
moves 43 of 43, as before. The coverage lint on the tree manifest
from the recipe pins reports 0 uncovered paths, 0 host-tested row
problems, 43 tests. pyflakes reports no undefined name.

Acceptance: cloud.mode-switch, cooling.flow-under-load,
cooling.fail-tier-stop, forgectrl.auth and motion.release: the
implementation hash of each moves once, its own imports joining it; the
other 38 do not move (source_sha compared before and after). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in no fingerprint.
2026-10-16 14:17:00 -04:00
ScottW514 5bcc536853 forgetest: the lint's step parser keeps a step's real key column and reads a quoted key
workflow_steps rebuilt the first line of a step with its first key two
columns past the dash, whatever followed the dash. A step written with
more than one space after its dash (`-   run: ./build/tray_test`, its
`if: false` at the column of the run key) then had its first key at one
column and the others at another: every key after the first was read as
a child of the first and dropped, `if:`, `continue-on-error:`,
`working-directory:` and `with:` among them. So such a step under
`if: false` counted as running its host test, and such a checkout under
a condition counted as a checkout. _KEY_RX did not read a quoted key,
so a step's `"if": false` was dropped the same way. Both spellings are
valid YAML that GitHub skips or tolerates.

The parser now replaces the dash with a space and realigns nothing, so
the key column of a step is the column its keys use. _KEY_RX reads a
key in single or double quotes as the same key. The HOST_TESTED comment
states the rule the lint applies: no `if:` other than `true` and no
`continue-on-error:` other than `false` on the step or its job.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 38 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
extended by a step with three spaces after its dash under `if: false`
and a step under a quoted `"if": false`, neither running the test.
Negative controls on scratch copies: the first line rebuilt as before
fails the three-space case; _KEY_RX as before fails the quoted case.
The 17 workflows of the tree's repositories (205 steps) parse to the
same steps and events before and after the change. The coverage lint
on the tree manifest from the recipe pins reports 0 uncovered paths,
0 host-tested row problems, 43 tests. pyflakes reports no undefined
name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 14:11:07 -04:00
ScottW514 c0c74d1658 laser_lifecycle_test: rule 3 names the holders of the sender-change reset
Rule 3 of the header says a sender change closes the window and a
reconnected client must re-arm. The harness does not walk the sequence
on from there, a new sender's soft reset of the held job, as a session
of its own (commit 09cf924 removed test_sender_change_then_reset), and
the header did not say where each assertion of that sequence lives.
The disarm and the latch relock at the change are laser_arm_test.c
case H (glowforge_laser.c: the client generation changing mid-cycle
enqueues the feed hold and disarms). The soft reset of the held job is
the core's reset from a completed hold, which session
hold-reset-restart walks under rule 10 with more asserted than the
removed case had: the reset banner, Idle with no alarm, and the
position kept. The hold's origin does not reach the reset path
(driver.c driverReset disarms and resets the stream whatever held the
job, and gflaser_disarm is idempotent), so the sender-change variant
is a permutation of that one branch, and this repository keeps one
proof per rule. Rule 3 now names both holders.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_lifecycle_test.py, and
the harness passes whole against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0), hold-reset-restart among
its sessions. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/laser_lifecycle_test.py build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-15 22:42:49 -04:00
ScottW514 b2a011ac08 ctlport_test: the polling sender is a Grbl sender, not a product
The NewlinePoller docstring and poll_case's named one sender product
as the sender that polls '?' with an end of line behind it, and quoted
that product's poll rate beside the harness's own. The behavior under
test is the controller port's: an empty line is not the sender
speaking, a port jog is never refused for a poll, and every poll draws
its one ok. Which sender polls, and how often, is not the contract;
the harness's own rate (four times a second, period 0.25) is the fact
that matters. Both docstrings now say a Grbl sender that polls, and
the comparative rate is gone.

Proof: docstrings only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/ctlport_test.py, and the file
names no product. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/ctlport_test.py build/grblHAL_glowforge).

Acceptance: none; docstrings of a harness outside every covers map.
2026-10-15 22:42:49 -04:00
ScottW514 68e9d3fb8e laser_stream_test: the rolloff reference pins the bench-default curve's rendering of S300 end to end
Rule 19 names laser_arm_test.c case P as the proof of the dose curve's
mapping. Case P calls curveComputeValue directly and reads its counts;
it does not reach the wiring from the S word to the stream:
spindleConfig installing the curve as the PWM compute hook, and the
level the hook returns riding the dither into FIRE density. A driver
that left the hook uninstalled passes case P and renders every cut
through the identity mapping, a different delivered dose at every S.

The rolloff reference session (rule 23) already runs one M4 line at
S300 under the shipped curve and reads its cruise density, to compare
against the two-cut run. It now also holds that density to the curve's
own point: 30 percent light through the bench-default curve lands
between its (45 percent density, 21 percent light) and (60, 37)
points, 53.44 percent density, level 68 of 127, density 0.535, held
to 0.02. The expectation is mirrored next to the curved configuration
the way the floor is, so a change to the curve changes it. The
header's rule 19 names the point. No session is added, and no session's
configuration changes.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the rolloff reference cruise
renders 0.5355 against the expected 0.5354, and the whole harness
passes, every session. Negative control, through a scratch copy of the
harness: the same cut with laser_dose_curve = off renders 0.3625 (the
identity mapping, level 46 through the 10 percent floor), 0.17 from
the point, and fails the assertion.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 f47373a20d laser_stream_test: check_density holds each lit span to the move, so a level applied late shows
check_density judges a density run by what its lit spans carry: every
power byte at full duty, each span's FIRE density at its level, no
burst past the base period. It did not read a span's length against
the move it rode. A level that reaches the stream late (the move opens
dark, FIRE starts where the level is applied) renders the right
density over a shorter span, and the density alone cannot see it:
with FIRE blanked in the first 40 percent of the S300 move of the
idle-s stream, the check passed. Rule 14 (a level set while idle
survives to the next run) and rule 12 (density rung by rung) proved
the density of whatever was lit, not that the whole move was lit;
rules 16 and 17 keep their span length through check_cut_spans.

check_density takes an optional move length in machine ticks and holds
each span to 0.9 to 1.1 of it. The three density call sites pass
theirs: the ladder sessions (density, floor-derived) LADDER_TICKS,
from the ladder's now explicit LADDER_FEED, and idle-s IDLE_S_TICKS.
Span and move are both in the mode's own ticks, so the bound holds at
every microstep mode. The bound is 0.9 to 1.1, not check_cut_spans'
0.8 to 1.25, because the real spread is about 1 percent (the faintest
rung, S20, opens on carried debt at 0.991 of the move; idle-s runs at
1.007) and the looser bound lets a move 15 percent dark pass. The
header's rules 12 and 14 say what they prove.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the density, idle-s and
floor-derived sessions pass the bound with span ratios 0.991 to 1.007,
and the whole harness passes, every session. Negative control on the
captured idle-s stream, through a scratch copy of the harness: FIRE
blanked in the first 40 percent of the S300 move (span ratio 0.604)
and in the first 15 percent (0.855) fail the bound, and the same
streams pass with the move length omitted; a 5 percent trim (0.956)
passes, so the bound is neither vacuous nor hair-trigger.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 199282c029 setup.focus: the head's place read from /status and judged at X0 Y0 before the burn starts
The focus burn answers the wizard's home prompt with HOME from a dict,
and the wizard takes the operator's word the same way: nothing reads
where the head stands. A read at that prompt places nothing either: the
wizard restarts the controller before it, and a fresh controller drops
the X/Y reference. The one read with meaning comes before POST
/wiz/laser.focus/start, under the campaign's own controller, when a
manual home references X and Y: with manual_home_x and manual_home_y
blank or 0, it is the one provider that declares X0 Y0 at the gantry
stops.

head_at_home(ctx), called after the ready gate and before anything
starts, waits up to 10 s for a /status that serves a position (a
transient miss is not a stop; ten seconds of none fails the test before
any energy), records the read as head_at_start (pos, homed_axes,
home_source, judged), and judges the head at X0 Y0 within HOME_TOL_MM
(0.05 mm) when homed_axes carries X and Y and home_source is manual:
off its home, the test fails before any wizard start, so no controller
lever is touched. Unreferenced, the head's place rests on the
operator's word at the ready gate, the log says so, and judged is
False. The HOME answer and the steps stay; the description names the
read. The strict form, a manual home before every focus burn, is a
procedure the operator must add to every campaign; this change records
and judges what it can read.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 22 tests OK
on Windows and on Linux, with tests/test_setup_dark.py (20 OK) and
tests/test_laser_dwell.py (10 OK). The scripted daemon's /status
carries homed_axes 7 and home_source manual: at X0 Y0 the burn runs and
the evidence carries the read with judged True; at X 12.5 the test
fails before any start, with no burn started, no /controller/ lever
posted, and the settings as found; unreferenced (homed_axes 4,
home_source startup) the burn runs with judged False. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py as it was before this change (the HOME answer alone),
the X 12.5 case burns (Failed not raised) and the two evidence cases
find no head_at_start; the three tests catch it.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other suite module imports from
.setup_burns, so nothing else moves. The next campaign runs setup.focus
with the head's place on record.
2026-10-15 22:42:49 -04:00
ScottW514 6a4ece4b0d setup.focus: the test reads the LASER_ON count and the tube current from sysfs itself at every poll of the burn
The focus burn's emission was judged from the burn's result alone: the
daemon's own 25 Hz reads of cnc/laser_on_sampled and pic/hv_current,
reported as laser_on_samples and hv_max. The one live catalog test of
the wizard's burn took the daemon under test at its word for the
emission it reported, where the laser.* tests judge from the test's own
/status samples.

run_check takes on_poll(doc), called once per poll of /wiz/dark with
the status document, a caller's own witness read beside the check's;
the default keeps every positional caller as it is. Witnesses, in
setup_burns, reads the two attributes from sysfs at every poll and
keeps each at its peak and the count of polls that read both. burn()
passes it to run_check, and emission_ok(ctx, last, seen) keeps the
result's three checks and adds the test's own: at least one poll read
both witnesses, the LASER_ON count read nonzero from here, and the tube
current read over HV_ON (30, the daemon's own rule for a lit burn,
jobstream.h JOBSTREAM_HV_ON) from here. Once the result's three checks
pass, the evidence carries the two peaks and the read count as
emission_seen; the two readings are not compared with each other. The beam sensor's handling is unchanged, and
the description names both readings.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 20 tests
OK on Windows and on Linux, with tests/test_setup_dark.py (20 OK) for
the positional callers of run_check. BurnRunTests plants
cnc/laser_on_sampled and pic/hv_current under GF_SYSFS_ROOT (the shape
of a bench record), the lit burn's evidence carries the two peaks with
reads over zero, and a burn whose result reports a lit burn while the
two attributes read 0 from here fails on the LASER_ON count, with the
setting put back and the controller restarted on it. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py and setup_dark.py as they were before this change, that
same case passes (Failed not raised) and the evidence has no
emission_seen; the two new assertions catch both.

Acceptance: setup.focus's fingerprint moves (its own text, and the
shared text of setup_burns.py and setup_dark.py), and every test of
setup_dark.py moves through run_check: setup.check-switches,
setup.check-airflow, setup.check-motion, setup.check-lens and
setup.cloud-header-capture. motion.release does not move (its run_check
import is inside the function body). The next campaign runs setup.focus
with both readings on the bench.
2026-10-15 22:42:49 -04:00
ScottW514 802cd3af8d laser.emission-witness: the LASER_ON count read through the rapid, and two windows past the close
The dark stretch after the M5 judged emission from the samples whose
/status carried a laser block. forgectrl leaves that block out of a
served /status when cnc/laser_on_sampled or cnc/laser_pgood_sampled
does not read, and sample() records the count as None, which the relit
judge (any sample with emission) and the HV judge (the maximum over the
readings) both pass: a stretch whose count went unread after the dark
point passed as dark. The kernel latches the count once per ~1 s window
and holds it through the next, so a window with no read in it is a
window in which a fire is not witnessed. The stretch also ended at the
first disarmed sample, before the count for the window in flight at the
close had latched, so the rapid's last second was never read.

read_gaps(samples, key) reports, over a trail, the samples that carry a
reading, the ones that do not, and the longest stretch of seconds
between two readings, with the trail's ends as edges: an unread lead or
tail is a gap, and a trail with no reading is one gap, its whole span.
The judge requires RAPID_MIN_READS (4) readings of the count with no
gap over EMISSION_READ_GAP_S (0.9 s), inside the EMISSION_WINDOW_S
(1.0 s) latch period, and the same floor of readings for the HV
current. Both checks come before the relit and HV-dark checks, so those
never judge an unread stretch. sample_to_disarm keeps sampling for two
windows after the first disarmed sample: the count for the window in
flight at the close latches up to one window later and is held through
the next, and the gap rule asks for a read of it in that tail,
measured to the last sample served. The timeout bounds the wait for
the close alone. The close is the first disarmed
sample, which the disarm timing and the span are measured to; the
evidence's rapid block carries emission_reads and hv_reads, the log
line says how many samples carried the count and the longest unread
stretch, and the description names the rule.

Proof: tests/test_laser_dwell.py RapidReadTests, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 10 tests OK
on Windows and on Linux: every sample read (gap 0.2 s), one miss
(0.4 s) and a 0.8 s hole are inside the rule; a 1.2 s hole, the block
gone after the dark point (1 of 25 read, gap 4.8 s), the block gone for
the last 1.6 s, and a one-sample stretch fail it. pyflakes reports no
undefined name. Negative control on a scratch copy: with the trail's tail dropped
from the edges, the last-window case and the block-gone case report no
gap and their two tests catch it; with the gap bound raised to 1.3 s,
the 1.2 s hole passes the rule and its test catches it.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), and laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text; no other
suite module imports .laser at module level. laser.emission-witness is
always run, and the next campaign judges the rapid stretch's read
cadence on the bench (about 5 Hz in the last campaign's record).
2026-10-15 22:42:49 -04:00
ScottW514 099399b58f forgetest-ci: the undefined-name step fails when pyflakes is missing or raises
The "No undefined names" step runs under bash -e with no pipefail, and
-e does not reach the condition of an `if`. The former body pipes the
pyflakes run into grep, so the step's status is grep's alone: with
pyflakes absent ("No module named pyflakes" on stderr, exit 1) or
crashing, grep sees no match, the `if` is false, and the step passes
with nothing checked. Its `pip install pyflakes` also names no
interpreter.

The step now installs with `python -m pip`, into the interpreter it
runs; runs `pyflakes --version`, which fails the step under -e when
the module is missing; captures the pyflakes run, stderr included, in
a file (its own exit status is 1 for any message, unused imports
included, so it cannot be the gate); and fails on "undefined name" or
on "Traceback", a crash. The grep is pinned to pyflakes' message text;
no pyflakes version is pinned.

Proof: the step body, read from this workflow, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12, against the
former body as the control. pyflakes missing: the former body exits 0,
this one exits 1. pyflakes 4.0.3 present, the tree as committed: both
exit 0 (22 unused imports and 2 unused locals, no undefined name). An
undefined name planted in a scratch copy: both exit 1. A pyflakes that
raises (a shim, scratch only): the former body exits 0, this one exits
1. In CI the step's log shows the pyflakes version before the check.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-15 21:13:05 -04:00
ScottW514 03146a1529 forgefirm-image: pack-licenses.sh refuses a license text tar cannot read
pack-licenses.sh packs the image's license tree into one tar.gz and
removes the tree. It piped tar into gzip under /bin/sh (dash, no
pipefail): the pipeline's status is gzip's, and tar's is lost. The
check that follows proves only that the bundle holds license.manifest.
So a member tar cannot read leaves a bundle without that text, the
build green, and the tree removed, the only copy of the text with it.

tar now runs the compressor itself (-I 'gzip -9 -n'), so its exit
status covers the read and the write. A failed pack removes the
partial bundle and the script refuses, with the tree left for the
build log; pack_licenses in forgefirm-image.bb fails do_rootfs on the
refusal. The bundle's bytes do not change: tar feeds the same stream
to the same gzip, and a tree packed by both forms gives one sha256.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests gain
test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left (a
text under zlib/ with mode 0: a nonzero exit, a message on stderr, no
bundle, the manifest left); the class's 5 tests OK. Negative control
on a scratch copy: the new case against the script without this change
fails at assertRefused with an exit status of 0. At the shell, on a
fixture tree with the unreadable member, the script without this
change exits 0, leaves the bundle without the text and removes the
tree; with it, the script exits 1, leaves no bundle and leaves the
tree. On a readable tree both forms write byte-identical bundles (one
sha256 for both). pyflakes reports no undefined name.

Acceptance: every catalog test. pack-licenses.sh is under
meta-forgefirm, whose content hash is part of the platform in every
fingerprint, so the image that carries this change needs the whole
catalog on the bench.
2026-10-15 21:12:04 -04:00
ScottW514 852943e20c forgetest: the fixture's press docstring stops inviting a long press
Fixture._press said that a check that must reach the machine while the
button is down asks for the longest press the box can make, and the
unit test's comment repeated the invitation. That contradicts the
design: no press is ever held, every reader of the button catches the
default press (an edge, or a poll of 100 ms or less), and the box
clamps any request into 20 to 500 ms. The docstring now says what `ms`
is (the box's own 200 ms when it is not said, clamped by the box) and
that no check needs a longer press. The `ms` plumbing stays: it
mirrors the box's API field, and the box clamps regardless. The test's
pass-through assertion stays as the client's contract that a request
reaches the box as asked; the clamp is the box's, held by the fixture
firmware's policy test.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12: the
ClientTests of test_fixture.py, 6 tests, OK. A grep for "longest
press" and "while the button is down" over fixture.py and
test_fixture.py prints nothing. Text only, so no negative control
applies. pyflakes reports no undefined name.

Acceptance: none. fixture.py is a non-suite module outside the shared
judges, and tests/ are in no fingerprint; no press length or path
changes.
2026-10-15 21:09:29 -04:00