1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
|
# Give the HIL worker result a name
**Origin:** split out of PR #3840 (making `hil_report.md` a rendering of `hil_report.json`).
Delete this file when its own PR lands.
> **SUPERSEDED IN PART (2026-08-26).** Written against a 7-field tuple whose index 5 was
> `blind`. The sysfs blindness subsystem is gone: `test_board` now returns **6** fields with
> `stray` at index 5, and its board-locked early return is 5 wide. The problem described
> below is unchanged and still worth fixing — three producers, three widths, and
> `len(r) > 5 and r[5]` reads a WRONG SLOT rather than raising. But drop the `blind` field
> from the proposed NamedTuple and re-derive every index from `hil_test.test_board` before
> executing, or `_stray_note` starts reading a duration as a stray count.
> `StrayNoteSurvivesTheTupleWidth` pins the current shape.
## What is established
`test_board()` returns a bare tuple that three producers build and fourteen call sites read
positionally. It has grown 5 → 6 → 7 fields, and the code already works around its own
shape:
```python
hil_test.py:1992 dirty = [(r[0], r[6]) for r in mret if len(r) > 6 and r[6]]
hil_test.py:2014 blind = [r[0] for r in mret if len(r) > 5 and r[5]]
hil_test.py:2386 for name, _, _, _, dur, *_ in mret:
hil_report.py:306 for name, _, _, rows, *_ in mret:
```
Two facts make this worth closing rather than tolerating:
- **The declared type is already wrong.** `hil_test.py:1711` says
`tuple[str, int, list[str], list, float]` — five fields — while the main return at `:1872`
yields seven (`+ sysfs_blind(), stray`).
- **A wrong slot is a wrong verdict, not a crash.** Field 5 is `blind`, which decides whether
a board's red cells are reported as broken hardware or as "could not tell". Inserting a
field mid-tuple makes `r[5]` read the wrong slot and keep running.
It has bitten once already: `test_hil_bounded.py`'s
`test_both_row_widths_survive_the_report_writers` exists because the blindness flag widened
the tuple to 6 while the pool-timeout path still synthesised 5-field rows, and *"a
fixed-width unpack in either one raises INSIDE the containment path, which is where a raise
costs every board's results."* That is why the unpacks end in `*_`.
## What remains
A `NamedTuple` with defaults. Verified to pickle across the pool boundary and to stay
fully tuple-compatible — existing `r[0]`, `e[1]`, `for name, _, _, rows, *_` and `len(r)`
all keep working, so it lands without touching the fourteen consumers:
```python
class BoardResult(NamedTuple):
"""What one worker returns. Field ORDER is load-bearing: it is unpacked positionally
in a dozen places, and the pool-timeout path synthesises one by hand."""
name: str
err_count: int
failed_tests: list[str]
rows: list | None # None from the pool-timeout synthesis, never []
duration: float
blind: bool = False # defaults, so a synthesised result is full-width
stray: int = 0
```
Then a second, smaller step removes the coupling itself: `accumulate_report` takes
`[(name, rows)]` pairs instead of `mret`, and `hil_test` does the extraction because it owns
the shape. One line at each end; the subtle merge logic — stale lock clearing,
`BOUNDARY_CELL`, `duration=None` preservation — is untouched.
## Sizing
| | Sites |
|---|---|
| Producers to convert | 4 (`hil_test.py:1724`, `:1872`, `:2283`, `:2327`) |
| Arity guards deleted | 2 (`:1992`, `:2014`) |
| Wrong annotation fixed | 1 (`:1711`) |
| `hil_report`'s coupled line | 1 (`:306`) |
| Positional consumers (optional migration) | 14 |
| **Test fixtures building tuples by hand** | **34** |
Production code is roughly ten changed lines. **The work is dominated by the test
fixtures**, which is also the risk.
## Do this first, or the refactor is unverifiable
`test_hil_report.py` (27 sites) and `test_hil_bounded.py` (7) construct plain tuples by
hand — `('boardA', 0, [], [], 1.0, True)`. A producer that forgot to switch to
`BoardResult`, or a pickling regression, **passes the entire 310-test suite** and surfaces
only on the rig. Convert the fixtures to build `BoardResult` as task 1, before touching any
producer. This ordering is not optional.
Second trap: `rows` is `None` on the pool-timeout path (`hil_test.py:2283`), never `[]`, and
`accumulate_report` guards with `if rows and ...`. A well-meaning `rows: list = []` default
silently changes that path. Pin it with a test before the conversion.
## Why it was split out
PR #3840 touches the report document. This touches `test_board`'s return and the containment
paths, where a raise costs every board's results rather than one board's — a different blast
radius, needing its own review and its own rig run. #3840 is twice-reviewed and dogfooded
ten times on hardware; folding this in would reset that surface for a latent-trap cleanup
that is not causing bugs today.
|