4"""Decide what a bench-contention run actually proved.
6The two accounts, and which one is evidence
7-------------------------------------------
8A run produces two records of the same minutes:
10* the **journal** -- ``/var/lib/ra8-bench/journal.ndjson``, written by the lock
11 about itself. It says who acquired and released, and when.
12* the **witness** -- ``lib/bench_witness.py`` sampling ``/proc`` on the bench
13 host. It says which process had the J-Link, started by which machine, between
14 which two instants, read from the kernel.
16Only the second is evidence. An actor that silently skipped its work writes
17exactly the same pair of journal lines as one that waited its turn and then did
18the work, so no arrangement of journal lines can tell those apart. Every claim
19below is therefore either decided by the witness, or is a claim ABOUT the
20journal decided by checking it against the witness.
22Attribution is derived twice, independently
23-------------------------------------------
24Each actor in a run programs a DIFFERENT app, and the J-Link commander script
25the witness captured names the hex being programmed. That maps a hardware
26interval to an actor through the payload. Separately, the ssh peer address maps
27it to a machine through the kernel. The two are computed separately and then
28required to agree: if the app-derived grouping and the peer-derived grouping
29disagree, something is wrong with the experiment and the run is reported
30inconclusive rather than green.
32Intervals are widened, never narrowed
33-------------------------------------
34A process start instant is exact (``/proc/<pid>/stat`` field 22). Its end is
35bracketed between the last sample that saw it and the first that did not. Every
36interval is taken as ``[exact_start, gone_by]`` -- the PESSIMISTIC bound, which
37can only make overlap more likely to be reported. Non-overlap concluded from
38widened intervals is non-overlap.
42 bench_contention_verdict.py --dir RUNDIR --phases exclusion,death
43 bench_contention_verdict.py --selftest
45Exit 0 when every claim in the selected phases held, 1 when one did not, 3 when
46no verdict could be established (a missing or empty artefact).
49from __future__
import annotations
55from datetime
import datetime
56from pathlib
import Path
66DEATH_HANDOVER_LIMIT_S = 30.0
71CONTAINMENT_SLACK_S = 2.0
75LINGER_NOTICEABLE_S = 1.0
86 """Collected claims and their outcomes, rendered as one report."""
88 def __init__(self) -> None:
89 """Start with no claims recorded and nothing failed."""
90 self.rows: list[tuple[str, str, str]] = []
94 def record(self, phase: str, claim: str, ok: bool |
None, detail: str) ->
None:
95 """Log one claim. ``ok is None`` means no verdict could be established."""
104 self.rows.append((f
"{phase}/{claim}", state, detail))
106 def note(self, phase: str, label: str, detail: str) ->
None:
107 """Record an OBSERVATION -- something measured, not something asserted.
109 Wait distributions and grant orders are evidence a reader needs and
110 nothing can fail on: there is no threshold at which a grant order is
111 "wrong". Recording them as claims that happen to always pass would
112 inflate the pass count with rows that can never fail, which is exactly
113 how a suite drifts into looking stronger than it is.
115 self.rows.append((f
"{phase}/{label}",
"OBSERVED", detail))
117 def report(self) -> int:
118 """Print every claim and return the process exit code."""
119 width = max((len(r[0])
for r
in self.rows), default=10)
120 print(
"\n=== bench contention verdict ===")
121 for name, state, detail
in self.rows:
122 print(f
" {state:<7} {name:<{width}} {detail}")
124 print(f
"\nVERDICT: FAIL -- {self.failed} claim(s) did not hold.")
127 print(f
"\nVERDICT: UNKNOWN -- {self.unknown} claim(s) could not be established.")
128 print(
"UNKNOWN is not a pass. Fix the artefact and re-run.")
130 print(f
"\nVERDICT: PASS -- {len(self.rows)} claim(s) held.")
134def read_ndjson(path: Path) -> list[dict]:
135 """Every parsable object in an NDJSON file; [] when it is absent."""
136 if not path.is_file():
139 for raw
in path.read_text(errors=
"replace").splitlines():
144 out.append(json.loads(line))
145 except json.JSONDecodeError:
150def iso_epoch(text: str) -> float:
151 """Seconds since the epoch for an ISO-8601 instant, or 0.0 when unparsable."""
153 return datetime.fromisoformat(text).timestamp()
154 except (ValueError, TypeError):
159 """One acquire/release pair out of the journal."""
161 def __init__(self, lock_id: str, holder: str, start: float, note: str) ->
None:
162 """Open a hold at *start*; ``end`` stays infinite until a release lands."""
163 self.lock_id = lock_id
166 self.end = float(
"inf")
170 def __repr__(self) -> str:
171 """Render as the holder and its window, for a failure message."""
172 return f
"<Hold {self.holder} {self.start:.0f}..{self.end:.0f}>"
175def holds_from_journal(events: list[dict]) -> list[Hold]:
176 """Reconstruct hold windows, in journal order, from acquire/release lines."""
177 open_holds: dict[str, Hold] = {}
178 holds: list[Hold] = []
180 kind = ev.get(
"event",
"")
181 lock_id = ev.get(
"lock_id",
"")
182 when = iso_epoch(ev.get(
"at",
""))
183 if kind ==
"acquire":
184 hold = Hold(lock_id, ev.get(
"holder_name",
""), when, ev.get(
"note",
""))
185 open_holds[lock_id] = hold
187 elif kind ==
"release" and lock_id
in open_holds:
188 open_holds.pop(lock_id).end = when
189 elif kind ==
"force-take" and lock_id
in open_holds:
190 open_holds[lock_id].forced =
True
195 """One hardware-tool occupancy, widened to its pessimistic bounds."""
197 def __init__(self, ev_start: dict, ev_end: dict |
None) ->
None:
198 """Build the widened interval from a proc_start and its proc_end."""
199 self.pid = ev_start.get(
"pid")
200 self.tool = ev_start.get(
"tool",
"")
201 self.peer = ev_start.get(
"peer",
"")
202 self.script = ev_start.get(
"script",
"")
203 self.argv = ev_start.get(
"argv",
"")
204 self.start = float(ev_start.get(
"start_epoch")
or ev_start.get(
"first_seen")
or 0.0)
205 self.end = float((ev_end
or {}).get(
"gone_by")
or ev_start.get(
"first_seen")
or 0.0)
208 def overlaps(self, other: Interval) -> bool:
209 """True when the two widened intervals share any instant."""
210 return self.start < other.end
and other.start < self.end
212 def __repr__(self) -> str:
213 """Render as tool, pid and machine, for a failure message."""
214 return f
"<{self.tool} pid={self.pid} peer={self.peer} actor={self.actor}>"
217def intervals_from_witness(events: list[dict]) -> list[Interval]:
218 """Pair proc_start with proc_end into widened occupancy intervals."""
219 starts: dict[int, dict] = {}
220 ends: dict[int, dict] = {}
222 if ev.get(
"ev") ==
"proc_start":
223 starts[ev[
"pid"]] = ev
224 elif ev.get(
"ev") ==
"proc_end":
226 out = [Interval(s, ends.get(pid))
for pid, s
in starts.items()]
227 out.sort(key=
lambda i: i.start)
231def attribute_by_payload(intervals: list[Interval], assignment: dict[str, str]) ->
None:
232 """Name each interval's actor from the app its J-Link script programs.
234 ``assignment`` maps actor name -> app name. The commander script contains
235 ``loadfile /tmp/hil_<app>_mram.<pid>.hex``, so the app names the actor
236 without consulting the lock at all. Read-only sessions carry no loadfile and
237 stay unattributed by this route; the peer axis covers those.
239 by_app = {app: actor
for actor, app
in assignment.items()}
241 for app, actor
in by_app.items():
242 if f
"hil_{app}_mram" in iv.script:
247def check_attribution_axes(intervals: list[Interval]) -> tuple[bool, str]:
248 """Require the payload-derived and kernel-derived attributions to agree.
250 Every interval attributed to one actor must carry one peer address, and no
251 two actors may share a peer. A disagreement means the experiment did not run
252 the way it was described, and nothing downstream should be believed.
254 peers_by_actor: dict[str, set[str]] = {}
257 peers_by_actor.setdefault(iv.actor, set()).add(iv.peer)
258 split = {a: p
for a, p
in peers_by_actor.items()
if len(p) > 1}
260 return (
False, f
"an actor's work came from more than one machine: {split}")
261 seen: dict[str, str] = {}
262 for actor, peers
in peers_by_actor.items():
263 peer = next(iter(peers))
265 return (
False, f
"actors {seen[peer]} and {actor} share peer {peer}")
267 return (
True, f
"{len(peers_by_actor)} actor(s), one machine each: {seen}")
270def overlapping_pairs(intervals: list[Interval]) -> list[tuple[Interval, Interval]]:
271 """Every pair of intervals from DIFFERENT machines that share an instant."""
273 for i, first
in enumerate(intervals):
274 for second
in intervals[i + 1 :]:
275 if not first.peer
or not second.peer
or first.peer == second.peer:
277 if first.overlaps(second):
278 out.append((first, second))
282def census_collisions(events: list[dict]) -> list[dict]:
283 """Census samples that directly observed two machines on the board at once."""
285 ev
for ev
in events
if ev.get(
"ev") ==
"census" and len(ev.get(
"peers", [])) > ONE_MACHINE
289def console_collisions(events: list[dict]) -> list[tuple[float, list[str]]]:
290 """Instants at which two machines held the same board console at once.
292 A second axis, and an independently damaging one: the J-Link OB's VCOM
293 delivers each byte to exactly one reader, so two tails of one console each
294 get roughly half the stream. #497 lists that as a concrete failure -- a HIL
295 run silently pattern-matching against half its output -- and it is a
296 collision the J-Link's own multiplexing does nothing to prevent.
298 per_instant: dict[tuple[float, str], set[str]] = {}
300 if ev.get(
"ev") !=
"tty":
302 key = (float(ev.get(
"t", 0.0)), str(ev.get(
"dev",
"")))
303 per_instant.setdefault(key, set()).add(str(ev.get(
"peer",
"")))
305 (when, sorted(peers))
306 for (when, _dev), peers
in sorted(per_instant.items())
307 if len({p
for p
in peers
if p}) > ONE_MACHINE
311def hold_overlaps(holds: list[Hold]) -> list[tuple[Hold, Hold]]:
312 """Every pair of journal hold windows that overlap in time."""
314 for i, first
in enumerate(holds):
317 for second
in holds[i + 1 :]
318 if first.start < second.end
and second.start < first.end
324 """The three artefacts of one phase, loaded together."""
326 def __init__(self, root: Path, phase: str) ->
None:
327 """Load one phase's journal slice and witness trace from *root*."""
329 self.journal = read_ndjson(root / f
"journal-{phase}.ndjson")
330 self.witness = read_ndjson(root / f
"witness-{phase}.ndjson")
331 self.holds = holds_from_journal(self.journal)
332 self.intervals = intervals_from_witness(self.witness)
335 def usable(self) -> bool:
336 """False when an artefact is missing, which is UNKNOWN and not a pass."""
337 return bool(self.witness)
340def _load_assignment(root: Path, phase: str) -> dict[str, str]:
341 """Actor name -> app name, as the driver assigned it for *phase*.
343 Per phase, because the phases assign differently -- `death` leaves its
344 victim out -- and a single shared file let the last phase to run rewrite
345 the record the earlier ones are judged against, which read as an actor
346 having done no work when it had flashed the board perfectly well.
348 path = root / f
"assignment-{phase}.txt"
349 out: dict[str, str] = {}
350 if not path.is_file():
352 for line
in path.read_text().splitlines():
354 if len(parts) >= ASSIGNMENT_FIELDS:
355 out[parts[0]] = parts[1]
359def claim_exclusion(v: Verdict, root: Path, roster: list[str]) ->
None:
360 """Mutual exclusion, journal truthfulness, and no physical interleaving."""
361 data = PhaseData(root,
"exclusion")
364 v.record(phase,
"artefacts",
None,
"no witness output for this phase")
366 assignment = _load_assignment(root,
"exclusion")
367 attribute_by_payload(data.intervals, assignment)
369 ok, detail = check_attribution_axes(data.intervals)
370 v.record(phase,
"attribution-agrees", ok, detail)
372 collisions = census_collisions(data.witness)
373 pairs = overlapping_pairs(data.intervals)
376 "no-physical-overlap",
377 not collisions
and not pairs,
378 f
"{len(data.intervals)} tool session(s); {len(collisions)} census collision(s); "
379 f
"{len(pairs)} overlapping pair(s)" + (f
" -- {pairs[0]}" if pairs
else ""),
382 console = console_collisions(data.witness)
385 "no-console-collision",
387 "no instant had two machines reading the board console"
389 else f
"{len(console)} instant(s) with two console readers, first {console[0]}",
392 worked = {iv.actor
for iv
in data.intervals
if iv.actor}
393 missing = [a
for a
in roster
if a
not in worked]
396 "every-actor-really-worked",
398 f
"programmed the board: {sorted(worked)}"
399 + (f
"; NO hardware evidence for {missing}" if missing
else ""),
402 overlaps = hold_overlaps(data.holds)
407 f
"{len(data.holds)} hold(s) in the journal, none overlapping"
409 else f
"overlapping holds: {overlaps[0]}",
411 _claim_containment(v, phase, data)
414def _claim_containment(v: Verdict, phase: str, data: PhaseData) ->
None:
415 """Every hardware interval sat inside the hold the journal attributes to it.
417 This is where the journal is checked against reality in both directions: no
418 hardware activity outside a hold (the lock did not miss anything), and each
419 actor's activity inside ITS OWN hold (the journal named the right actor).
421 named = [iv
for iv
in data.intervals
if iv.actor]
423 v.record(phase,
"journal-matches-hardware",
None,
"no attributable hardware activity")
425 outside, misattributed = [], []
431 if h.start - CONTAINMENT_SLACK_S <= iv.start
432 and iv.end <= h.end + CONTAINMENT_SLACK_S
438 elif iv.actor
not in window.holder:
439 misattributed.append((iv, window.holder))
442 "journal-matches-hardware",
443 not outside
and not misattributed,
444 f
"{len(named)} programming session(s), each inside the hold the journal "
445 f
"attributes to the same actor"
446 if not (outside
or misattributed)
447 else f
"unlocked activity: {outside}; misattributed: {misattributed}",
451def claim_negative_control(v: Verdict, root: Path) ->
None:
452 """The witness must be able to SEE a collision, or nothing else means much."""
453 data = PhaseData(root,
"negctl")
454 phase =
"negative-control"
456 v.record(phase,
"artefacts",
None,
"no witness output for this phase")
458 collisions = census_collisions(data.witness)
459 pairs = overlapping_pairs(data.intervals)
460 peers = sorted({iv.peer
for iv
in data.intervals
if iv.peer})
463 "witness-detects-collision",
464 bool(collisions
or pairs),
465 f
"unguarded run from {len(peers)} machine(s) {peers}: "
466 f
"{len(collisions)} census collision(s), {len(pairs)} overlapping pair(s)"
469 if (collisions
or pairs)
470 else " -- the witness saw NO collision, so a clean guarded run proves nothing"
473 console = console_collisions(data.witness)
476 "witness-detects-console-collision",
478 f
"{len(console)} instant(s) with two machines on one console"
480 f
", up to {max(len(p) for _, p in console)} at once" if console
else " -- axis unproven"
487 f
"{len(data.holds)} hold(s) in the journal during the unguarded phase",
491def _kill_instant(root: Path) -> float:
492 """The instant the victim's ssh client was SIGKILLed, from the kill log."""
493 path = root /
"kill-marker.txt"
494 if not path.is_file():
496 match = re.search(
r"KILLED at ([0-9.]+)", path.read_text())
497 return float(match.group(1))
if match
else 0.0
500def _claim_death_release(v: Verdict, data: PhaseData, killed: float) ->
None:
501 """The flock really dropped when the holder's ssh client was SIGKILLed."""
502 released = [h
for h
in data.holds
if h.end != float(
"inf")
and h.end >= killed - 1]
505 "death",
"lock-released-on-death", ok=
False, detail=
"no release followed the SIGKILL"
512 gap = max(0.0,
min(h.end
for h
in released) - killed)
515 "lock-released-on-death",
516 gap <= DEATH_HANDOVER_LIMIT_S,
517 f
"the journal recorded a release within {gap:.0f}-1s of the SIGKILL "
518 "(journal resolution is one second), with no trap having run",
522def _claim_death_handover(v: Verdict, data: PhaseData, killed: float, first: Hold) ->
None:
523 """A waiter got in promptly ONCE THE BOARD WAS ACTUALLY IDLE.
525 A waiter must not be judged against the SIGKILL instant. The victim's J-Link
526 session lives on the bench host, over its own ssh, and does not die with the
527 hold; the bench host deliberately withholds the board until that leftover
528 finishes. Time spent in that interlock is the interlock WORKING.
530 in_flight = [iv
for iv
in data.intervals
if iv.start <= killed <= iv.end]
531 idle_at = max([killed, *[iv.end
for iv
in in_flight]])
532 lingered = idle_at - killed
535 "board-quiesced-before-handover",
536 f
"the dead holder's hardware ran {lingered:.1f}s past its own release; "
537 f
"the bench host withheld the board until it stopped"
538 if lingered > LINGER_NOTICEABLE_S
539 else "no hardware outlived the dead holder",
541 delay = max(0.0, first.start - idle_at)
545 delay <= DEATH_HANDOVER_LIMIT_S,
546 f
"{first.holder} acquired {delay:.1f}s after the board went idle "
547 f
"({first.start - killed:.1f}s after the SIGKILL, of which {lingered:.1f}s was the "
548 f
"leftover session); limit {DEATH_HANDOVER_LIMIT_S:.0f}s",
552def _claim_death_work(v: Verdict, data: PhaseData, root: Path, first: Hold) ->
None:
553 """The waiter really used the board, and never at the same time as the dead one."""
554 attribute_by_payload(data.intervals, _load_assignment(root,
"death"))
557 for iv
in data.intervals
558 if iv.start >= first.start - CONTAINMENT_SLACK_S
559 and iv.end <= first.end + CONTAINMENT_SLACK_S
563 "waiter-really-used-the-board",
565 f
"{len(worked)} tool session(s) inside {first.holder}'s hold"
567 else f
"{first.holder} took the lock but no hardware activity is recorded inside it",
569 pairs = overlapping_pairs(data.intervals)
572 "no-overlap-across-the-handover",
574 "the dead holder's work and the waiter's never coincided"
576 else f
"OVERLAP across the handover: {pairs[0]}",
580def claim_death(v: Verdict, root: Path) ->
None:
581 """A waiter really gets the board after the holder is killed."""
582 data = PhaseData(root,
"death")
584 v.record(
"death",
"artefacts",
None,
"no witness output for this phase")
586 killed = _kill_instant(root)
588 v.record(
"death",
"kill-recorded",
None,
"no KILLED marker in kill-marker.txt")
590 _claim_death_release(v, data, killed)
591 after = [h
for h
in data.holds
if h.start >= killed]
597 detail=f
"nothing acquired the bench after the SIGKILL at {killed:.2f} -- "
598 "the queue did not move",
601 first =
min(after, key=
lambda h: h.start)
602 _claim_death_handover(v, data, killed, first)
603 _claim_death_work(v, data, root, first)
606def _fairness_rows(root: Path, roster: list[str]) -> dict[str, list[float]]:
607 """Per-actor wait times, from each actor's own request/acquire timeline.
609 The request instant is the actor's own claim, which is the one thing here
610 that cannot be taken from the kernel -- a machine's intent to ask is not
611 visible on the bench. It is a safe claim to accept: overstating it can only
612 make an actor look like it waited LESS, so it cannot manufacture fairness.
614 waits: dict[str, list[float]] = {a: []
for a
in roster}
615 journal = read_ndjson(root /
"journal-fairness.ndjson")
617 (iso_epoch(e.get(
"at",
"")), e.get(
"holder_name",
""))
619 if e.get(
"event") ==
"acquire"
622 log = root / f
"fairness-{actor}.log"
623 if not log.is_file():
625 mine = sorted(t
for t, who
in acquires
if actor
in who)
628 for m
in re.finditer(
629 rf
"ROUND \d+ {re.escape(actor)} request ([0-9.]+)", log.read_text()
639 grant = next((t
for t
in pending
if t >= req - 1),
None)
642 pending.remove(grant)
643 waits[actor].append(grant - req)
647def claim_fairness(v: Verdict, root: Path, roster: list[str], rounds: int) ->
None:
648 """Report the wait distribution honestly, and fail only on real starvation."""
650 journal = read_ndjson(root /
"journal-fairness.ndjson")
652 v.record(phase,
"artefacts",
None,
"no journal slice for this phase")
654 holds = holds_from_journal(journal)
655 counts = {a: sum(1
for h
in holds
if a
in h.holder)
for a
in roster}
656 starved = [a
for a, n
in counts.items()
if n == 0]
661 f
"acquires per actor: {counts} (asked for {rounds} each)"
662 + (f
"; STARVED: {starved}" if starved
else ""),
666 "all-requests-served",
667 all(n >= rounds
for n
in counts.values()),
668 f
"every actor got all {rounds} of its turns"
669 if all(n >= rounds
for n
in counts.values())
670 else f
"short of {rounds} turns: { {a: n for a, n in counts.items() if n < rounds} }",
672 waits = _fairness_rows(root, roster)
674 a: f
"n={len(w)} max={max(w):.1f}s avg={sum(w) / len(w):.1f}s" for a, w
in waits.items()
if w
676 v.note(phase,
"wait-distribution", f
"{summary}")
677 order = [h.holder.split(
":")[-1]
for h
in sorted(holds, key=
lambda h: h.start)]
678 v.note(phase,
"grant-order",
" -> ".join(order))
681 "no-overlapping-holds",
682 not hold_overlaps(holds),
683 f
"{len(holds)} hold(s), none overlapping",
687def _fake_interval(pid: int, peer: str, start: float, end: float) -> Interval:
688 """A synthetic tool occupancy, for the selftest."""
690 {
"pid": pid,
"tool":
"JLinkExe",
"peer": peer,
"start_epoch": start,
"script":
""},
695def _selftest_overlap() -> list[str]:
696 """Overlap must be found when it is there, and not when it is not."""
699 _fake_interval(1,
"10.0.0.1", 100.0, 110.0),
700 _fake_interval(2,
"10.0.0.2", 110.5, 120.0),
702 if overlapping_pairs(clean):
703 failures.append(
"overlapping_pairs reported an overlap between disjoint intervals")
705 _fake_interval(1,
"10.0.0.1", 100.0, 110.0),
706 _fake_interval(2,
"10.0.0.2", 109.9, 120.0),
708 if not overlapping_pairs(dirty):
709 failures.append(
"overlapping_pairs MISSED a 0.1s overlap between two machines")
711 _fake_interval(1,
"10.0.0.1", 100.0, 110.0),
712 _fake_interval(2,
"10.0.0.1", 105.0, 120.0),
714 if overlapping_pairs(same):
715 failures.append(
"overlapping_pairs flagged one machine overlapping itself")
716 if not census_collisions([{
"ev":
"census",
"peers": [
"a",
"b"]}]):
717 failures.append(
"census_collisions missed a two-peer census")
718 if census_collisions([{
"ev":
"census",
"peers": [
"a"]}]):
719 failures.append(
"census_collisions flagged a single-peer census")
723def _selftest_console() -> list[str]:
724 """Two machines on ONE console at ONE instant, and nothing weaker."""
726 def tty(when: float, dev: str, peer: str) -> dict:
727 return {
"ev":
"tty",
"t": when,
"dev": dev,
"peer": peer}
730 if console_collisions([tty(1.0,
"/dev/ttyACM1",
"a"), tty(2.0,
"/dev/ttyACM1",
"b")]):
731 failures.append(
"console_collisions flagged two readers that were not simultaneous")
732 if not console_collisions([tty(1.0,
"/dev/ttyACM1",
"a"), tty(1.0,
"/dev/ttyACM1",
"b")]):
733 failures.append(
"console_collisions MISSED two machines on one console at one instant")
734 if console_collisions([tty(1.0,
"/dev/ttyACM1",
"a"), tty(1.0,
"/dev/ttyACM1",
"a")]):
735 failures.append(
"console_collisions flagged one machine holding a console twice")
736 if console_collisions([tty(1.0,
"/dev/ttyACM1",
"a"), tty(1.0,
"/dev/ttyACM2",
"b")]):
737 failures.append(
"console_collisions flagged two DIFFERENT consoles as a collision")
741def _selftest_journal() -> list[str]:
742 """Journal reconstruction, and the timestamp parser it rests on."""
745 {
"at":
"2026-01-01T00:00:00+00:00",
"event":
"acquire",
"lock_id":
"a",
"holder_name":
"x"},
746 {
"at":
"2026-01-01T00:00:10+00:00",
"event":
"release",
"lock_id":
"a",
"holder_name":
"x"},
747 {
"at":
"2026-01-01T00:00:05+00:00",
"event":
"acquire",
"lock_id":
"b",
"holder_name":
"y"},
748 {
"at":
"2026-01-01T00:00:20+00:00",
"event":
"release",
"lock_id":
"b",
"holder_name":
"y"},
750 if not hold_overlaps(holds_from_journal(events)):
751 failures.append(
"hold_overlaps missed two journal holds that overlap")
752 if iso_epoch(
"2026-01-01T00:00:00+00:00") <= 0:
753 failures.append(
"iso_epoch could not parse a journal timestamp")
754 if iso_epoch(
"nonsense") != 0.0:
755 failures.append(
"iso_epoch accepted a non-timestamp")
759def selftest() -> int:
760 """Prove the analyser calls a collision a collision, and a clean run clean.
762 A verdict tool that returned PASS on any input would be the most expensive
763 kind of green in this repository. Both directions are asserted on synthetic
764 witness data, so the check does not depend on a bench being present.
766 failures = _selftest_overlap() + _selftest_console() + _selftest_journal()
767 for text
in failures:
768 print(f
"bench_contention_verdict: SELFTEST FAIL -- {text}", file=sys.stderr)
771 print(
"bench_contention_verdict: selftest OK -- overlap detected and non-overlap not")
775def main(argv: list[str]) -> int:
776 """Run every requested phase's claims and print one verdict."""
777 parser = argparse.ArgumentParser(description=
"bench contention verdict")
778 parser.add_argument(
"--dir", type=Path, help=
"run artefact directory")
779 parser.add_argument(
"--phases", default=
"exclusion,negative-control,death,fairness")
780 parser.add_argument(
"--rounds", type=int, default=3)
781 parser.add_argument(
"--selftest", action=
"store_true")
782 args = parser.parse_args(argv)
786 if args.dir
is None or not args.dir.is_dir():
788 "bench_contention_verdict: --dir must name an existing run directory", file=sys.stderr
792 roster_file = args.dir /
"roster.txt"
793 if not roster_file.is_file():
794 print(
"bench_contention_verdict: no roster.txt in the run directory", file=sys.stderr)
796 roster = [line.strip()
for line
in roster_file.read_text().splitlines()
if line.strip()]
799 phases = [p.strip()
for p
in args.phases.split(
",")
if p.strip()]
800 if "exclusion" in phases:
801 claim_exclusion(v, args.dir, roster)
802 if "negative-control" in phases:
803 claim_negative_control(v, args.dir)
804 if "death" in phases:
805 claim_death(v, args.dir)
806 if "fairness" in phases:
807 claim_fairness(v, args.dir, roster, args.rounds)
809 print(
"bench_contention_verdict: no claims were evaluated", file=sys.stderr)
814if __name__ ==
"__main__":
815 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.