TEL-04: make a missing build() guard fail legibly instead of aborting

Review point from the non-vacuity run. Deleting build()'s "is the net entity
cached at all" guard makes build() raise KeyError rather than return False.
That still failed the suite, but by aborting it with a traceback at whichever
check ran first - a red that costs the next person ten minutes deciding
whether the suite is broken or the code is.

New `built()` helper in test_p1.py wraps build() and turns an escaping
exception into a returned value, so the comparison against True/False fails
by name. Applied only to the ha_signed section; TEL-01's own checks are
untouched.

Mutation 4 before: 0 named checks red, aborted at check 123 of 174.
Mutation 4 after:  2 named checks red - "nothing cached yet builds nothing"
and "an unavailable entity does not build a sample" - all 174 reached.

Still 174 checks, all green, and the other nine mutations are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
This commit is contained in:
glenn schrooyen
2026-08-25 17:01:33 +02:00
co-authored by Claude Opus 5
parent 8b51a51e20
commit 632be44f6c
+28 -9
View File
@@ -57,6 +57,25 @@ def raises(name, fn):
fails.append(name) fails.append(name)
def built(src):
"""`src.build()`, with any escaping exception turned into a visible value.
⚠️ Legibility of a RED, not leniency. build() is contracted to return a bool
and to funnel every bad telegram through ingest.reject() - a guard that goes
missing (say the "is the net entity cached at all" one) makes it raise
instead. That still fails the suite, but by aborting it with a traceback at
whichever check happened to run first, which costs the next person ten
minutes deciding whether the suite is broken or the code is. Returning the
exception makes it compare unequal to True/False, so the NAMED check goes red
and says which rule died.
"""
try:
return src.build()
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" build() raised {type(err).__name__}: {err}")
return err
def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None): def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None):
return make_sample(SOURCE_HA, net_import, net_export, phases=phases, return make_sample(SOURCE_HA, net_import, net_export, phases=phases,
phase_import_w=pi, phase_export_w=pe, phase_import_w=pi, phase_export_w=pe,
@@ -614,20 +633,20 @@ NET = {"net": "sensor.p1_meter_active_power", "phase_net": []}
ing = P1Ingest(phases=1, max_age_s=30.0) ing = P1Ingest(phases=1, max_age_s=30.0)
sig = HaSignedSource(None, ing, NET, token="x") sig = HaSignedSource(None, ing, NET, token="x")
check("nothing cached yet builds nothing", sig.build() is False and ing.last is None) check("nothing cached yet builds nothing", built(sig) is False and ing.last is None)
sig._absorb("sensor.p1_meter_active_power", "1000") sig._absorb("sensor.p1_meter_active_power", "1000")
check("one signed entity is a complete telegram on its own", check("one signed entity is a complete telegram on its own",
sig.build() is True and ing.net_w == 1000.0) built(sig) is True and ing.net_w == 1000.0)
check("the sample is tagged with its own transport", check("the sample is tagged with its own transport",
ing.last.source == SOURCE_HA_SIGNED) ing.last.source == SOURCE_HA_SIGNED)
sig._absorb("sensor.p1_meter_active_power", "-2500") sig._absorb("sensor.p1_meter_active_power", "-2500")
sig.build() built(sig)
check("a negative state lands as a negative net", ing.net_w == -2500.0) check("a negative state lands as a negative net", ing.net_w == -2500.0)
before = ing.last before = ing.last
sig._absorb("sensor.p1_meter_active_power", "unavailable") sig._absorb("sensor.p1_meter_active_power", "unavailable")
check("an unavailable signed entity is a parse error", ing.parse_errors == 1) check("an unavailable signed entity is a parse error", ing.parse_errors == 1)
check("an unavailable entity does not build a sample", sig.build() is False) check("an unavailable entity does not build a sample", built(sig) is False)
# ⚠️ The rule the whole ticket turns on: a missing reading is MISSING. Resolving # ⚠️ The rule the whole ticket turns on: a missing reading is MISSING. Resolving
# it to 0 W would read as a perfectly balanced house and defeat the staleness # it to 0 W would read as a perfectly balanced house and defeat the staleness
# trigger that FW-01's watchdog is built on. # trigger that FW-01's watchdog is built on.
@@ -640,7 +659,7 @@ check("a non-numeric signed state is a parse error, not 0 W",
ing.parse_errors == 3 and ing.net_w == -2500.0) ing.parse_errors == 3 and ing.net_w == -2500.0)
sig._absorb("sensor.p1_meter_active_power", "64954") sig._absorb("sensor.p1_meter_active_power", "64954")
check("64954 is refused at the signed transport too", check("64954 is refused at the signed transport too",
sig.build() is False and ing.parse_errors == 4) built(sig) is False and ing.parse_errors == 4)
sig._absorb("sensor.not_ours", "123") sig._absorb("sensor.not_ours", "123")
check("an unsubscribed entity is never cached by the signed transport", check("an unsubscribed entity is never cached by the signed transport",
"sensor.not_ours" not in sig.cache) "sensor.not_ours" not in sig.cache)
@@ -652,7 +671,7 @@ sig = HaSignedSource(None, ing, dict(NET), token="x")
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1, ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
ingest_mono=time.monotonic() - 20.0)) ingest_mono=time.monotonic() - 20.0))
sig._absorb("sensor.p1_meter_active_power", "unavailable") sig._absorb("sensor.p1_meter_active_power", "unavailable")
sig.build() built(sig)
check("a rejected reading does not reset the published age", check("a rejected reading does not reset the published age",
ing.published_age_s > 19 and ing.net_w == 1200.0) ing.published_age_s > 19 and ing.net_w == 1200.0)
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1, ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
@@ -669,10 +688,10 @@ NET3 = {"net": "sensor.p1_meter_active_power",
sig3 = HaSignedSource(None, ing3, NET3, token="x") sig3 = HaSignedSource(None, ing3, NET3, token="x")
for eid, val in (("sensor.p1_meter_active_power", "187"), ("sensor.p1_l1", "2301")): for eid, val in (("sensor.p1_meter_active_power", "187"), ("sensor.p1_l1", "2301")):
sig3._absorb(eid, val) sig3._absorb(eid, val)
check("an incomplete signed phase set waits instead of guessing", sig3.build() is False) check("an incomplete signed phase set waits instead of guessing", built(sig3) is False)
sig3._absorb("sensor.p1_l2", "468") sig3._absorb("sensor.p1_l2", "468")
sig3._absorb("sensor.p1_l3", "-2582") sig3._absorb("sensor.p1_l3", "-2582")
check("a complete signed three-phase set builds", sig3.build() is True) check("a complete signed three-phase set builds", built(sig3) is True)
check("signed per-phase entities keep the exporting phase negative", check("signed per-phase entities keep the exporting phase negative",
ing3.last.per_phase_w == (2301.0, 468.0, -2582.0)) ing3.last.per_phase_w == (2301.0, 468.0, -2582.0))
check("per-phase IMPORT clamps the exporting phase to zero", check("per-phase IMPORT clamps the exporting phase to zero",
@@ -688,7 +707,7 @@ sig_bad = HaSignedSource(None, P1Ingest(phases=3, max_age_s=30.0),
for eid in ("sensor.net", "sensor.a", "sensor.b"): for eid in ("sensor.net", "sensor.a", "sensor.b"):
sig_bad._absorb(eid, "100") sig_bad._absorb(eid, "100")
check("two phases delivered against meter_phases 3 is rejected, not padded", check("two phases delivered against meter_phases 3 is rejected, not padded",
sig_bad.build() is False and sig_bad.ingest.last is None built(sig_bad) is False and sig_bad.ingest.last is None
and sig_bad.ingest.parse_errors == 1) and sig_bad.ingest.parse_errors == 1)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #