An XLX system is a MODE: OUTBOUND system with an XLX_MODULE letter set. XLX's
DMR interface is the homebrew protocol unmodified -- DMRGateway uses the same
CDMRNetwork class for XLX and BrandMeister, and there are no XLX conditionals in
its network layer -- so this adds no new mode, subclass, or data path. The only
protocol difference is one in-band private call at connect that selects the
module, plus pinning the connection to TS2/TG9.
Module binding is static by design. Runtime or end-user module selection would
break the nailed-up systems/bridges model; DMRGateway is the right tool for
user-selectable modules at a repeater or hotspot.
TS2/TG9 is injected by expand_xlx_bridges rather than read from rules.py, so
XLX_BRIDGES is a flat {system: bridge} map with no TS or TGID column. This is
not a default -- it makes a wrong value inexpressible. That matters more here
than elsewhere because every XLX failure is silent: the reflector never
acknowledges a link, and no frame on the wire carries a module identity, so a
mistyped talkgroup would leave HBlink looking healthy while traffic vanished.
The existing conference-bridge retarget path already rewrites the header dst,
flips the slot bit, and regenerates the full and embedded LC, so no XLX-specific
rewrite code is needed -- an XLX system is just a bridge member with a fixed
TS/TGID.
An XLX system in UNIT is a startup error. Reflector module selection *is* a
private call to 4001-4026, so forwarding a unit call to one could silently move
the reflector into a different room for every user connected to it.
Behaviour verified against xlxd source (src/cdmrmmdvmprotocol.cpp):
* Five acceptance gates at :636-655, each of which silently drops the packet:
exactly 55 bytes, DATA_SYNC frame type, TS2, slot type VOICE_LC_HEADER, and
a valid BS/MS data sync. The 55-byte requirement is why the packet carries
two trailing pad bytes; a bare 53-byte DMRD is rejected.
* Only the three header frames are parsed for the link command; the two
terminators have slot type 2 and are ignored, but are sent to frame the call.
* Unlink-then-link is required, not defensive. At :297-325 a link naming a
different module while already linked is discarded and rewritten to the
module already held, so a reconnect that skipped the unlink would silently
stay in the previous room.
* xlxd reads the destination from DMRD header bytes 8-10 and never decodes the
LC (its BPTC decode is commented out at :657-660). The LC is still built
honestly from the same src/dst: a packet whose payload contradicts its header
is a debugging trap for anything that does decode it.
Note that a stock xlxd is built with NB_OF_MODULES = 10, so only modules A-J
exist unless the operator rebuilt it. A-Z is accepted here since the protocol
allows it, but an absent module produces no link and no error.
tests/test_xlx_link.py adds 35 tests: the acceptance gates transcribed from
xlxd's parser, packet structure, XLX_BRIDGES expansion and its guards, and
XLX_MODULE validation. One test reproduces the canned bursts from the 2019
reference implementation byte for byte, from their own decoded src/dst and
colour code, which pins the burst builder against six years of field-proven
behaviour.
No new dependencies: bitarray and dmr_utils3 were already required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tools/migrate_obp_rules.py reads hblink.cfg (to learn which systems are
OPENBRIDGE) and an old-form rules.py, lifts every inline OBP member into an
OBP_BRIDGES table, drops any bridge left empty (OBP_BRIDGES recreates it at
load), flags ingress-fork ERRORs and renumber WARNINGs, prints a summary, and
writes a new file (never overwrites rules.py). Comments are not preserved.
tests/test_obp_bridges.py gains a round-trip proof: an old inline config
migrated then loaded the new way yields identical routing indexes, plus
fork/renumber/TS-override/empty-bridge cases (74 tests total, all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OpenBridge systems are point-to-point trunks that pass talkgroups by TGID: no RF
user to key ON/OFF/TIMEOUT triggers, no meaningful timeslot. Configuring them as
inline BRIDGES members was verbose and made every trunk TGID carry dead trigger
machinery. They now live in a compact per-OBP table in rules.py:
OBP_BRIDGES = { <OPENBRIDGE system> : { <bridge name> : <TGID> } } # TS 1, or (TGID, TS)
expand_obp_bridges() (bridge.py) expands each row into a synthetic bridge member
with triggers hard-wired inert (TO_TYPE='NONE') and appends it to the named
bridge, so the forwarding path and indexes run UNCHANGED. Because an unmapped
TGID is never a routing key, the fail-closed ingress/egress filter falls out for
free -- no separate ACL. A TGID-heavy trunk now costs zero rule-timer work.
Load-time validation (design spec §3.3/§3.5):
* OBP system left as an inline BRIDGES member -> hard ERROR
* OBP_BRIDGES entry that isn't an enabled OPENBRIDGE system -> hard ERROR
* one TGID -> two bridges on a single OBP (ingress fork) -> hard ERROR
(the mirror, a bridge with two TGIDs on one OBP, is unrepresentable in the
{bridge: tgid} dict, so it needs no check)
* a bridge carrying different TGIDs across two OBPs (renumber-in-transit)
-> non-blocking WARNING
OBP_BRIDGES is optional (getattr fallback), so non-OBP configs are unaffected.
tests/test_obp_bridges.py covers expansion, TS default/override, and every
validation branch (10 tests). rules_SAMPLE.py + CONFIGURING.md document the model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Logs into a running SERVER as a HomeBrew repeater and sends keepalive pings
with a deliberate, bounded drop rate (clean warmup first so the server
calibrates the cadence; never more than --max-consec dropped in a row so it
can't trip MAX_MISSED). Lets you watch the per-repeater ping-loss % climb and
the callsign turn gold without a genuinely marginal RF link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Canonical event names across the reporting feed, matching hblink4's emit()
vocabulary (the OmniLink north star):
* peer -> peer_connected / peer_disconnected
* stream -> stream_start / stream_end
The lifecycle now rides the message `type`; the `action` field is retained
where consumers still use it (log label, END duration/hang handling).
Unix-socket transport for a same-box dashboard, alongside the existing TCP:
* daemon: REPORT_TRANSPORT=unix binds start_unix_server on REPORT_SOCKET
(stale socket file cleared first); TCP stays the default for remote dashboards.
* dashboard: HBLINK_TRANSPORT=unix connects via open_unix_connection(HBLINK_SOCKET).
A local Unix socket can't NIC-flap or be conntrack-evicted, retiring the whole
silent-severance class for local dashboards (filesystem perms replace the IP ACL;
TCP keepalive is moot).
Also: ping-cadence calibration now takes the MEDIAN of the first PING_WARMUP
gaps (robust to an odd first interval), counting loss only once the cadence
is known.
Tests updated for the new vocabulary; fixes an unbounded-loop test bug in
test_loss_registers_then_ages_out (the window-fill loop's bound tracked the
list it was appending to -> infinite append -> OOM).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exponential-decay (tau) metric was hard to explain. Replace it with a simple,
exact 'ping loss % over the last PING_LOSS_WINDOW minutes' -- easy to reason about
('3% loss over the last 5 minutes'). The gap-based, self-calibrated miss detection
is unchanged (a repeater that just pings slower still isn't read as lossy); only
the aggregation is now a pruned event window instead of a decaying counter.
Two [GLOBAL] knobs (optional, sane defaults keep existing configs working):
- PING_LOSS_WINDOW: minutes, default 5, clamped 1-60.
- PING_LOSS_WARN: gold-threshold %, default 5, advertised to the dashboard.
Documented in hblink-SAMPLE.cfg and CONFIGURING.md. Dashboard now reads the gold
threshold from config (was hard-coded 5). Dropped PING_QUALITY_TAU and the math.exp
import. Tests: clean/slower-cadence -> 0, dropped -> loss, and old loss ages out of
the window -> 0. 59 tests pass. Verified live at a picky 1min/1% (local hblink.cfg):
healthy repeaters read 0%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaces the case the retired warn light was a poor proxy for: a repeater that
stays connected but loses pings over time (lossy link -> choppy/dropped audio).
Server-side, at each received RPTPING we infer how many pings were lost in the
gap since the previous one, against a per-repeater self-calibrated cadence (so a
repeater that simply pings slower is not read as lossy). Received and missed
counts decay exponentially (PING_QUALITY_TAU, ~15 min) VU-meter style, so the
value reflects recent/chronic loss and rises/falls slowly. Exposed as PING_LOSS%
in each repeater's snapshot (rides the 60s resync -- it moves slowly, so that
cadence is fine); the dashboard shows '· N% ping loss' and ambers the callsign
past a threshold. History is keyed by radio id and survives reconnects, so a
flapping lossy repeater still accumulates a meaningful figure.
Tests: clean cadence -> 0%, slower cadence -> 0% (not mistaken for loss),
dropped pings -> clear loss. Verified live: real repeaters read 0%. 58 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard learned about repeater connect/disconnect only via the periodic
full-config push (up to REPORT_INTERVAL late), because there were no granular
peer events -- the full push WAS the change-propagation mechanism. Add proper
deltas so the dashboard updates immediately, as the first step toward demoting
that redundant full push.
- json_repeater() extracted (shared by the snapshot and the new event).
- ReportServer.send_peer() emits {'type':'peer', system, radio_id, action, info}.
- HBSYSTEM.report_peer_deltas(): centralized reconciliation sweep -- diffs
currently-connected peers vs last reported and emits only deltas, so it can't
miss any of the scattered add/remove paths and never floods. Called from the
server maintenance loop (every PING_TIME) and immediately on config completion
for snappy connect feedback.
- Dashboard backend applies the delta and re-broadcasts config (no browser-JS
change yet). Full push kept as fallback this step; Step 2 demotes it.
- Tests: connect emits one 'connected' w/ info, no duplicate on re-sweep,
timeout emits 'disconnected'. 55 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The test harness still used the old config MODE vocabulary (MASTER/PEER) that the
current schema no longer accepts, so build_config() silently dropped those
systems and ~33 tests failed (config not loaded). Migrate the fixtures and test
code to the current terms; the whole suite (52 tests) now passes.
- harness.cfg: MODE MASTER->SERVER, PEER->OUTBOUND; MAX_PEERS->MAX_REPEATERS;
MASTER_IP/PORT->SERVER_IP/PORT. System labels MASTER-1/-2 -> SERVER-1/-2
(REPEATER-1 kept; 'repeater' is not a dropped term).
- test code: _peers->_repeaters, send_master->send_server,
MASTER_SOCKADDR->SERVER_SOCKADDR, send_bridgeEvent->send_bridge_event mocks.
- harness.py: the 'mark connected' gate keyed on mode=='PEER' -> 'OUTBOUND', so
the router again forwards to outbound systems (fixes the routing/baseline
scenarios).
- Comments/identifiers describing our system roles moved off master/peer;
genuine HBP wire-protocol terms (peer-id, the DMRD peer field, the homebrew
login handshake) are left as-is.
- baseline_emissions.json regenerated consistently for the renamed systems.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reporting tests referenced the removed Twisted reportFactory/send_clients/
bridgeReportFactory API. Rewrite them against the current ReportServer /
BridgeReportServer, and add coverage for the dead-client shedding added in the
keepalive fix (is_closing() and over-limit write buffer). Self-contained mocks,
no dependency on config fixtures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When HBlink3 acts as a PEER, the router forwarded bridged call traffic to it
regardless of whether it had completed login to its upstream master. The frames
were fired at the master's address and silently dropped (we're not registered),
and the call falsely appeared as an active forward on the dashboard. This is
long-standing behavior (master/original hblink3 did the same) -- the new
event-sourced dashboard just made it visible.
Add HBSYSTEM.egress_ready() (True for masters/OpenBridges; for a PEER, True only
once CONNECTION == 'YES') and skip un-ready targets at the top of routerHBP
bridge_group / bridge_unit, so a disconnected peer gets no forwarded frames, no
TX report event, and no TX status churn. send_master also drops DMRD when not
connected as defense-in-depth, while still letting login/keepalive packets flow.
The routing harness models connected, participating systems, so it now marks its
PEER systems CONNECTION == 'YES' -- the 15-scenario byte baseline is unchanged.
Tests: tests/test_peer_egress.py (connected forwards, disconnected skipped) plus
a send_master drop/allow test; full suite 51 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sends DMRD frames over the OpenBridge wire format (53-byte payload + 20-byte
HMAC-SHA1(passphrase, payload)) from a socket bound to the OBP's TARGET_PORT, so
the receiver's TARGET_SOCK source-address check passes. OBP is not timeslot-
bound, so --streams N fires N overlapping calls at once to exercise many
concurrent streams (and, via bridging, the peer OBP sending them all out
concurrently) -- the multi-stream contrast to the slot-bound HBP side.
Verified live against a two-OpenBridge demo bridge: 4 streams into each OBP
plus an HBP peer produced 20 concurrent active streams (9 per OBP, send+receive)
with MASTER-1 carrying one per slot, and clean teardown (no leaked streams).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The source-side "collided with existing call" warning fired on every dropped
frame, flooding the log when streams contend for a slot (e.g. several peers on
one master keying the same talkgroup). Gate it on a per-slot RX_COLLISION_SID so
each contending stream is reported once, matching the once-per-stream behavior
of the target-side contention log. Routing/emissions are unchanged; this is
logging volume only. The dashboard's stale-stream reaper stays as-is for
defense-in-depth (non-local dashboard, hblink failure, etc.).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A slot's "stream has ended" state was inferred from RX_TYPE/TX_TYPE ==
HBPF_SLT_VTERM. But those fields hold the last frame's _dtype_vseq, which for
voice bursts cycles 0..5 and so collides with HBPF_SLT_VTERM (== 2). Any stream
whose last received burst was vseq 2 (burst "C") looked already-terminated, so:
- a dropped-RF "lost tail" ending on vseq 2 was never ended -- not by the
stream-timeout trimmer, nor by new-stream supersession -- leaking a stuck
call until a downstream consumer timed it out; and
- a live holder transmitting its vseq-2 burst momentarily looked terminated, so
a competing stream could steal the slot, producing spurious CALL STARTs and a
flood of per-frame collision warnings.
A properly terminated call always finishes a full superframe (last voice burst
F/vseq 5) then sends the terminator, so the clean-END path was usually fine --
this only bit lost tails and mid-call contention. The bug is long-standing
(present in master); the new event-sourced dashboard just made it visible.
Replace the RX_TYPE/TX_TYPE == VTERM inference with explicit RX_TERMINATED /
TX_TERMINATED flags, set False when a stream starts and True on a real
terminator (or synthesized end), across the group and unit, source and target
paths. RX_TYPE/TX_TYPE remain only as informational last-frame-type.
Verified live: lost-tail@vseq2 now times out cleanly; 3 overlapping peers x 4
calls yield 12 STARTs / 12 ENDs (was 72 / 4) with no stuck streams. All 47
unit tests pass, including the 15-scenario byte-identical routing baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Logs into a running MASTER as a HomeBrew peer over a real socket and injects
DMRD voice streams, exercising the full server path (socket -> handshake ->
ACL -> bridge routing -> contention -> reporting). Complements the in-process
tests/harness.py and is the tool used to drive the dashboard with live traffic.
DMR-realistic call generation:
- properly terminated calls finish a complete superframe (last burst F/vseq 5)
then send the terminator;
- --no-term produces a "lost tail" that stops mid-superframe with no terminator
(--tail-vseq forces the last burst, e.g. 2 to reproduce the END-loss case);
- distinct random per-process stream-ids so concurrent instances model separate
repeaters instead of aliasing into one call;
- --concurrent overlaps N streams on one TGID/slot to characterize contention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix the root cause of the dashboard's phantom "active" streams: HBlink3 did not
guarantee a matching END for every START. When a new stream took over a timeslot
whose previous stream never sent a terminator, the slot state was simply
overwritten, and the trimmer only ever emitted an END for whichever stream
currently owned the slot -- superseded streams were dropped silently.
- routerHBP._end_slot_stream(slot): if a slot holds an un-terminated stream,
declare it ended and emit a matching END (with the correct GROUP/UNIT call
type, now tracked per slot as RX_CT). Idempotent.
- Call it at the new-stream adoption point in group_received and unit_received,
so a superseding stream first ends the one it replaces (restores the original
pre-supersession behavior; the overwrite-only version kept routing correct but
orphaned the call logs).
- Stream timeout dropped from 5s (polled every 5s) to STREAM_TIMEOUT=2s polled
every 1s: on RF, ~2s of no audio means sync is lost and any resumption is a new
stream (late entry), so the stream is over. The trimmer's RX timeout now routes
through _end_slot_stream too.
Report-only behavior; routing is unchanged (byte baseline still green). The
dashboard's stream reaper stays as a backstop for feed-disconnect gaps HBlink3
can't help with. Suite now 42 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the old reporting wire format (pickled CONFIG/BRIDGES + CSV events,
netstring-framed) with newline-delimited JSON, in preparation for the new
dashboard. A consumer connects to the reporting socket and receives:
- a `config` snapshot on connect (and periodically): a JSON-safe, trimmed view
of the systems (masters with their connected peers, peers with stats,
openbridges) built by json_systems() -- bytes decoded, DMR IDs as integers,
passphrases and internal ACL structures omitted.
- a `bridges` snapshot: each conference bridge's members (system/ts/tgid/active/
timer/triggers) via json_bridges().
- `stream` events on call start/end (the send_bridgeEvent CSV is parsed centrally
into JSON, so the routing call sites are unchanged).
The report protocol is now plain NDJSON (one json object per line); consumers are
passive and get the initial snapshot pushed on connect (no CONFIG_REQ handshake).
Dropped pickle from hblink.py/bridge.py and a leftover `send_clients(b'bridge
updated')` debug line.
Note: this changes the reporting wire format, so the old HBmonitor (which this
new dashboard replaces) will no longer parse it. Verified live: a consumer
connecting to a running bridge.py receives the config snapshot (with the
connected peer) and bridge state as NDJSON. Tests updated; suite now 39.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the protocol mechanics the routing harness doesn't reach, against mock
transports (no real sockets):
- tests/test_protocol.py:
- master login handshake: RPTL -> challenge -> RPTK -> RPTC drives the master
state machine to CONNECTION='YES'; wrong passphrase is NAK'd and the peer
removed. Exercises datagram_received + transport.sendto.
- reporting: netstring framing round-trip, disallowed client is closed, and a
CONFIG_REQ (split across reads) triggers a CONFIG_SND push.
- transport send: OPENBRIDGE.send_system and peer send_master use
transport.sendto with the right address.
Full suite now 36 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the harness to drive unit calls (seed UNIT_MAP, instantiate non-bridge
systems) and add single-target unit scenarios covering HBP<->HBP, HBP<->OBP
(including a TS2->OBP case that must clear the slot bit), OBP->HBP and OBP->OBP.
Frozen from current code so the unit-forwarding refactor can be proven
byte-identical for the normal single-target case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add HBP->HBP and OBP->OBP scenarios whose first frame is a burst (no voice
header), so the synthesized-LC path (src_lc = LC_OPT + dst + src, vs decoded
from the header) is also locked before the routing refactor. Regenerated from
current code; existing scenarios unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establish a golden master of the current routing core's emitted packets so the
forward_group extraction (and the later OBP/HBP unification) can be proven to
change nothing on the wire.
- tests/harness.cfg: self-contained topology (2 HBP masters, 1 HBP peer, 2 OBPs)
so scenarios can cover every source/target combination including OBP<->OBP.
- tests/baseline_scenarios.py: deterministic full-call scenarios -- HBP<->HBP
(same slot, cross slot with TS-bit flip, TGID rewrite), HBP<->OBP, OBP->HBP,
OBP->OBP, multi-target fan-out, and a contention case where the second stream
is correctly blocked.
- tests/baseline_emissions.json: frozen emissions captured from current code.
- tests/test_baseline.py: replays the scenarios and asserts byte-identical match.
Verified the baseline distinguishes real semantics (cross-slot flips the TS bit,
TGID rewrite applies, HBP targets get 55-byte frames with BER/RSSI while OBP
targets get 53-byte frames without) and that a single changed byte fails the test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scaffolding for the planned OBP/HBP routing unification. It stands up the real
routerHBP/routerOBP instances against the sample config, drives synthetic DMRD
frames through their routing entry point (dmrd_received), and captures the
packets each system would emit -- with a manually advanced clock so contention
and group-hangtime are deterministic.
The point: during the unification we can replay identical frame sequences
through the old and new code and assert byte-identical emissions, proving OBP
multi-stream behavior and HBP slot locking are preserved without a live
over-the-air network.
- tests/harness.py: World builder, controllable Clock, mk_dmrd() frame builder,
per-system send_system capture.
- tests/test_harness_smoke.py: characterization tests proving the harness drives
the real core -- HBP->HBP and HBP->OBP forwarding, and two concurrent OBP
source streams reaching two different systems (the case the naive "OBP as TS1"
unification would break).
No production code changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
group_received ran a full O(bridges x members) scan on every voice frame to
find which bridges a (system, TS, TGID) source feeds, then scanned the
members again for targets; the in-band signalling pass did yet another full
scan per terminator. Cost scaled with the size of the rule file, per frame,
per concurrent stream.
Build two indexes once at startup from the processed BRIDGES (index_bridges):
- BRIDGE_SRC_INDEX: (system, ts, tgid) -> [(bridge, source, members), ...]
- BRIDGE_BY_SYSTEM: system -> [(bridge, member), ...]
The match keys are immutable per member and ACTIVE is read live at packet
time, so the indexes stay valid and only need rebuilding if bridge membership
changes (today: never after load). Per frame is now a dict lookup over only
the relevant sources/targets. Selection is identical to the original scan,
verified against a brute-force reference over randomized rule sets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
acl_check was a linear scan over a list of (start, end) ranges, so large
enumerated ACLs (e.g. DENY: hundreds of individual IDs) cost O(n) on every
ingress frame. Rework the structure produced by acl_build into
(action, singles_frozenset, range_starts, range_ends):
- single IDs -> frozenset membership, O(1)
- ranges -> merged into disjoint sorted intervals, located by one bisect, O(log n)
acl_check is updated to match; behavior is identical to the original linear
semantics (verified against a reference implementation across permit/deny,
overlapping/adjacent ranges, ALL, and an 800-entry enumerated ACL).
Also fixes the empty-ACL branch, which previously returned a malformed
set((ID_MIN, max)) that acl_check would have crashed on (unreachable today
since the config always supplies an ACL string).
Note: the ACL tuple shape changed. It is internal to acl_build/acl_check,
but it is also carried in the pickled CONFIG sent to reporting clients, so a
dashboard that introspects ACL internals (most don't) would need updating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the behavior of the refactored helpers, since a live OBP/HBP network
with real DMR audio can't be reproduced in a dev environment:
- tests/test_acl.py: HBSYSTEM.dmrd_acl_check() -- PERMIT/DENY matching,
per-timeslot scoping, global-vs-system precedence, and once-per-stream
drop logging.
- tests/test_lc.py: gen_lcs() and embed_lc() -- LC generation plus the
payload rewrite's inject/preserve/passthrough/purity contract.
Stdlib unittest, no new deps:
venv/bin/python -m unittest discover -s tests -v
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>