laser_stream_test: the verdict's holds ship no FIRE while held

Rules 24 (the pause tier, held and held again) and 27 (a stale verdict)
asserted the deceleration into each hold ran lit to the stop, the
stretch between the holds dark, and the cut lit after the clear, but not
that the stream is dark inside a hold: the central claim of the
catalog's laser.verdict-cut, which can leave the catalog once the host
proves it. Rule 21 proved it only for a plain feed hold.

- check_hold_dark: no FIRE tick inside a hold's stationary stretch, its
  edges aside (one step pulse straddles each edge: rule 21's margin,
  HOLD_EDGE_TICKS, 120 ticks at x8 scaled to the mode: 480 at the x32
  default).
- Rule 24 checks both of its holds; rule 27 checks its one hold.
- check_fire_gaps runs on both sessions, as on rule 21's.
- The header's rules 24 and 27 say so; their PASS lines report the held
  stretches.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux: verdict-rehold dark while
held for 22620 and 22622 ticks, verdict-stale for 22621, check_fire_gaps
clean on both. A negative control on check_hold_dark: a dark 5000-tick
hold passes, and one FIRE tick inside it fails. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
This commit is contained in:
ScottW514
2026-10-13 16:55:50 -04:00
parent 571f702113
commit ef15c55577
+31 -7
View File
@@ -88,7 +88,8 @@ over TCP, then checks the dumps against the kernel feeder contract:
under the open window; the deceleration into that first hold runs under the open window; the deceleration into that first hold runs
lit to the stop, as a feed hold's does (the segments already planned lit to the stop, as a feed hold's does (the segments already planned
at speed would otherwise play dark and leave a gap in the cut), and at speed would otherwise play dark and leave a gap in the cut), and
the stream engine's per-tick gate masks the stream from the stop on; the stream engine's per-tick gate masks the stream from the stop on,
so the stationary stretch of each hold ships no FIRE;
a ~ under that verdict, which is what a button press or a sender a ~ under that verdict, which is what a button press or a sender
does, moves the head dark for at most one client poll before the does, moves the head dark for at most one client poll before the
client holds it again and says so; the clean verdict then resumes client holds it again and says so; the clean verdict then resumes
@@ -110,7 +111,8 @@ over TCP, then checks the dumps against the kernel feeder contract:
holds the job the moment the client's cache expires, on its own holds the job the moment the client's cache expires, on its own
clock between two of its file reads, lit to the stop like rule 24: clock between two of its file reads, lit to the stop like rule 24:
no dark cut runs out while the client waits for its next read; the no dark cut runs out while the client waits for its next read; the
engine's return resumes the cut lit hold's stationary stretch ships no FIRE; the engine's return resumes
the cut lit
28. a scheduling stall of the step producer (GFSINK_STALL_MS: the 28. a scheduling stall of the step producer (GFSINK_STALL_MS: the
producer is kept off the CPU longer than its lead) maps events producer is kept off the CPU longer than its lead) maps events
behind the ship cursor. Inside an armed window that is a fault: behind the ship cursor. Inside an armed window that is a fault:
@@ -704,6 +706,22 @@ def check_decel_lit(name, ticks, hold, what):
return lead return lead
# One step pulse straddles each edge of a hold's stationary stretch, so
# the edges are left out of its FIRE count (rule 21's margin).
HOLD_EDGE_TICKS = int(120 * XY_SCALE)
def check_hold_dark(name, ticks, hold, what):
"""No FIRE tick inside the stationary stretch of `hold`, its edges
aside: the beam is off for as long as the job is held."""
hs, he = hold
lit = sum(1 for t in ticks[hs + HOLD_EDGE_TICKS:he - HOLD_EDGE_TICKS] if t & 0x10)
if lit:
fail("[%s] FIRE while held: %d fire ticks in the stationary stretch of %d ticks (%s)"
% (name, lit, he - hs, what))
return he - hs
def tick_bytes(data): def tick_bytes(data):
"""The stream with power bytes stripped (tick bytes only).""" """The stream with power bytes stripped (tick bytes only)."""
return bytes(b for b in data if not b & 0x80) return bytes(b for b in data if not b & 0x80)
@@ -1286,6 +1304,7 @@ def main():
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5", "M2",
("expect_text", "laser disarmed")] ("expect_text", "laser disarmed")]
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED) data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
check_fire_gaps("verdict-rehold", data)
text = run_session.text text = run_session.text
if "held again" not in text: if "held again" not in text:
fail("[verdict-rehold] the client did not say it held the job again") fail("[verdict-rehold] the client did not say it held the job again")
@@ -1308,6 +1327,8 @@ def main():
(_h1s, h1e), (h2s, h2e) = holds (_h1s, h1e), (h2s, h2e) = holds
lit_lead = check_decel_lit("verdict-rehold", ticks, holds[0], lit_lead = check_decel_lit("verdict-rehold", ticks, holds[0],
"a pause keeps the beam through its first deceleration") "a pause keeps the beam through its first deceleration")
held = [check_hold_dark("verdict-rehold", ticks, h, "the pause tier masks FIRE while it holds")
for h in holds]
between = sum(fire[h1e:h2s]) between = sum(fire[h1e:h2s])
if between: if between:
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks " fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
@@ -1323,9 +1344,10 @@ def main():
if seq != ["unlock", "lock"]: if seq != ["unlock", "lock"]:
fail("[verdict-rehold] latch ownership sequence %s, expected unlock (arm) and lock " fail("[verdict-rehold] latch ownership sequence %s, expected unlock (arm) and lock "
"(program end) only: a pause must not write the latch" % seq) "(program end) only: a pause must not write the latch" % seq)
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, resumed dark for " print("PASS [verdict-rehold]: held lit to %d ticks before the stop, dark while held (%d and "
"%d ticks (%.2f s), held again, lit after the clear (%d fire ticks), latch %s" "%d ticks), resumed dark for %d ticks (%.2f s), held again, lit after the clear (%d fire "
% (lit_lead, h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq)) "ticks), latch %s"
% (lit_lead, held[0], held[1], h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
# --- rule 25: the verdict's fail tier -------------------------------- # --- rule 25: the verdict's fail tier --------------------------------
# The same line; mid-move the verdict goes to AIRFLOW. The job ends: # The same line; mid-move the verdict goes to AIRFLOW. The job ends:
@@ -1390,6 +1412,7 @@ def main():
("verdict", "stale"), ("wait_state", "Hold:0", 6.0), ("sleep", 0.5), ("verdict", "stale"), ("wait_state", "Hold:0", 6.0), ("sleep", 0.5),
("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("expect_text", "laser disarmed")] ("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("expect_text", "laser disarmed")]
data = run_session("verdict-stale", steps, conf=DENSITY_CONF_FLOORED) data = run_session("verdict-stale", steps, conf=DENSITY_CONF_FLOORED)
check_fire_gaps("verdict-stale", data)
text = run_session.text text = run_session.text
if "cooling service lost" not in text: if "cooling service lost" not in text:
fail("[verdict-stale] the client did not report the engine gone") fail("[verdict-stale] the client did not report the engine gone")
@@ -1401,6 +1424,7 @@ def main():
fail("[verdict-stale] expected one hold in the stream, found %d: %s" % (len(holds), holds)) fail("[verdict-stale] expected one hold in the stream, found %d: %s" % (len(holds), holds))
lit_lead = check_decel_lit("verdict-stale", ticks, holds[0], lit_lead = check_decel_lit("verdict-stale", ticks, holds[0],
"the hold must land at the cache's expiry, lit to the stop") "the hold must land at the cache's expiry, lit to the stop")
held = check_hold_dark("verdict-stale", ticks, holds[0], "a stale verdict masks FIRE while it holds")
fire = [1 if t & 0x10 else 0 for t in ticks] fire = [1 if t & 0x10 else 0 for t in ticks]
step = [1 if t & 0x05 else 0 for t in ticks] step = [1 if t & 0x05 else 0 for t in ticks]
last = len(step) - 1 - step[::-1].index(1) last = len(step) - 1 - step[::-1].index(1)
@@ -1412,8 +1436,8 @@ def main():
fail("[verdict-stale] latch ownership sequence %s, expected unlock (arm) and lock " fail("[verdict-stale] latch ownership sequence %s, expected unlock (arm) and lock "
"(program end) only" % seq) "(program end) only" % seq)
print("PASS [verdict-stale]: the expired cache held the job lit to %d ticks (%.0f ms) " print("PASS [verdict-stale]: the expired cache held the job lit to %d ticks (%.0f ms) "
"before the stop, lit after the return (%d fire ticks), latch %s" "before the stop, dark while held (%d ticks), lit after the return (%d fire ticks), "
% (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq)) "latch %s" % (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, held, lit_after, seq))
# --- rules 28 and 29: the stalls ------------------------------------- # --- rules 28 and 29: the stalls -------------------------------------
# The step density the job plans: F3000 at 53.333 steps/mm is 2667 # The step density the job plans: F3000 at 53.333 steps/mm is 2667