Announce dedup and path replacement (Python-RNS reference facts)
The reference facts behind the #376 desk measurement (a board's announce
reaches Columba only through the other board, never on the direct BLE
link). Four questions, answered strictly from the vendored
reference/Reticulum tree (Python-RNS 1.3.5), with citations, plus a
fifth section (added for Codeberg #231) on what 1.5.2 — the version a
periculum rnsd arm pins — does differently in the same arm. This page
states what the reference does; it decides nothing.
Sibling pages: Hop counting, Broadcast Python-RNS parity.
1. Direct vs. forwarded copy: same packet hash
Question. An announce received directly (header type 1, wire hops 0) versus the same announce forwarded by a transport node (header type 2, wire hops 1, transport id inserted): same packet hash?
Answer: yes, the hash is identical. The hashable part is built by
get_hashable_part (reference/Reticulum/RNS/Packet.py:355):
def get_hashable_part(self):
hashable_part = bytes([self.raw[0] & 0b00001111])
if self.header_type == Packet.HEADER_2:
hashable_part += self.raw[(RNS.Identity.TRUNCATED_HASHLENGTH//8)+2:]
else:
hashable_part += self.raw[2:]
return hashable_part
Three exclusions make the two copies hash the same:
- The hop count is excluded entirely. It is header byte 1 —
hops(reference/Reticulum/RNS/Packet.py:245) — and both branches above start at byte 2 or later. - The transport id is excluded. For header type 2 the slice starts
after the 16-byte transport id (
TRUNCATED_HASHLENGTH//8 + 2= 18). - The header-type and transport-type bits are masked off. Byte 0 is
packed as
packed_flags(reference/Reticulum/RNS/Packet.py:171) —header_type << 6 | context_flag << 5 | transport_type << 4 | destination.type << 2 | packet_type— and the& 0b00001111mask keeps only destination type and packet type. Header type (bit 6), transport type (bit 4) and the on-air IFAC flag (bit 7) all vanish.
So a relay changing hops, inserting its transport id and flipping the
header to type 2 does not change the packet hash: the two copies are the
same announce to every dedup structure keyed on packet_hash.
2. The second copy in Transport.inbound: order, and the announce exemption
Order. The dedup check runs first, the path-table update later, and both copies pass the dedup:
packet.hops(reference/Reticulum/RNS/Transport.py:1457) is incremented for every inbound packet, right after unpack.- The filter runs —
packet_filter(reference/Reticulum/RNS/Transport.py:1486) gates all further processing. - On acceptance the hash is remembered at once —
add_packet_hash(reference/Reticulum/RNS/Transport.py:1506) — before any announce processing (deferred only for link-table traffic and LR proofs). - Announce processing, including the path-table update, comes much
later in the same call —
validate_announce(reference/Reticulum/RNS/Transport.py:1691).
The exemption. Inside packet_filter
(reference/Reticulum/RNS/Transport.py:1336), a packet whose hash is
already in packet_hashlist
(reference/Reticulum/RNS/Transport.py:1376) is dropped — except an
announce for a SINGLE destination, which is accepted anyway:
if not packet.packet_hash in Transport.packet_hashlist and ...: return True
else:
if packet.packet_type == RNS.Packet.ANNOUNCE:
if packet.destination_type == RNS.Destination.SINGLE:
return True
So the second copy of the same announce is not discarded by the hashlist. It runs the full announce path again, and what it may change is decided there, by the random-blob replay check and the hops comparison of §3 — not by dedup. For #376 this matters in both directions: hashlist dedup cannot explain a missing direct announce, and hearing the relayed copy first does not inoculate the node against the direct copy.
3. The path replacement rule
All of this sits under the hop cap and non-local condition —
PATHFINDER_M (reference/Reticulum/RNS/Transport.py:1750) — with
announce_emitted (reference/Reticulum/RNS/Transport.py:1753) the
emission timestamp read out of the announce's random blob (bytes 5..10;
announce_emitted, reference/Reticulum/RNS/Transport.py:3191), and
the table side aggregated as the maximum over the recorded blobs
(timebase_from_random_blobs,
reference/Reticulum/RNS/Transport.py:3182).
Unknown destination: added unconditionally — should_add
(reference/Reticulum/RNS/Transport.py:1831).
Fewer or equal hops than the table entry — path_table
(reference/Reticulum/RNS/Transport.py:1765):
if packet.hops <= Transport.path_table[packet.destination_hash][IDX_PT_HOPS]:
path_timebase = Transport.timebase_from_random_blobs(random_blobs)
if not random_blob in random_blobs and announce_emitted > path_timebase:
should_add = True
(path_timebase, reference/Reticulum/RNS/Transport.py:1772.) Two
conditions, both required:
- the random blob must be new — the same announce heard again, e.g. the direct copy after the relayed copy, has the same blob and does not replace the path, however many hops it saves;
- the emission timestamp must be strictly newer than the newest one recorded for the destination. A later announce whose clock is behind the recorded one loses even at fewer hops — which is why the #376 announce instrument keeps the telemetry path's clock gate.
More hops than the table entry: ignored, unless one of three escapes fires, in order —
- the path has expired:
path_expires(reference/Reticulum/RNS/Transport.py:1793), still requiring an unseen blob; - the emission is strictly newer:
path_announce_emitted(reference/Reticulum/RNS/Transport.py:1809), still requiring an unseen blob; - same emission, but the recorded path has been marked unresponsive:
path_is_unresponsive(reference/Reticulum/RNS/Transport.py:1822).
4. No hops-0 drop rule
Question. Does Python drop or downgrade announces received with hops 0 on any interface class — anything that could make Columba ignore a direct board announce?
Answer: no such rule; searched Transport.inbound. There is no
condition anywhere in Transport.inbound that keys on hops == 0 for
an announce or treats a directly received announce worse than a relayed
one. (packet.hops, reference/Reticulum/RNS/Transport.py:1457,
increments every inbound packet before any decision, so a direct
announce is processed at hops == 1; the only decrements are the two
shared-instance IPC cases — is_local_client_interface,
reference/Reticulum/RNS/Transport.py:1482 — which are not radio
interfaces.)
The gates that do exist on the way to the path table, for any hops value, are:
- Signature validation —
validate_announce(reference/Reticulum/RNS/Identity.py:532): fails, and the announce is silently dropped. - Ingress limiting —
should_ingress_limit(reference/Reticulum/RNS/Transport.py:1705): on an interface withingress_controlenabled (should_ingress_limit,reference/Reticulum/RNS/Interfaces/Interface.py:145), an announce for an unknown destination can be held —hold_announce(reference/Reticulum/RNS/Transport.py:1706) — rather than processed; a pending path request bypasses the hold. - PLAIN/GROUP announces are always invalid
(
packet_filter,reference/Reticulum/RNS/Transport.py:1336);lxmf.deliveryannounces are SINGLE and unaffected. - The §3 replacement conditions, in particular the strict emission-timestamp comparison.
Sections 1 to 4 recorded 2026-09-09 against reference/Reticulum as
vendored (1.3.5).
5. 1.5.2's same-emission arm: gravity, not hops
§3 answers for 1.3.5, the vendored tree. It is not the whole answer for
the Python a comparison run actually faces: periculum's rnsd arm pins
1.5.2 (rns_pin in every pathchoice_*_rnsd.json), and 1.5.2 has an
acceptance path on EQUAL emission that 1.3.5 does not. Codeberg #231's
readings 308 and 309 disagreed because each had read one of the two
versions, so both are recorded here.
The line references in this section are deliberately NOT written in
the path:line citation form. 1.5.2 is not vendored — the tree read
was ~/coding/Reticulum at _version.py 1.5.2 — so the citation guard
would resolve a Transport.py line citation written here against the
1.3.5 copy and pass it on existence alone, a green that means nothing
(docs/src/concepts/checks-and-citations.md §"could not be checked").
Prose line numbers are checkable by a reader and by nothing else, which
is the truth about them.
The outer gate is unchanged — packet.hops <= the known hop count — and
the first test inside it is the 1.3.5 one, verbatim: 1.5.2
Transport.py lines 2237-2238, against
reference/Reticulum/RNS/Transport.py:1771-1772 here. What 1.5.2 adds
is the else, at 1.5.2 Transport.py lines 2245-2252:
# If the same announce is received later on an interface
# with higher gravity, allow updating the path table to
# use this interface instead.
if announce_emitted != path_timebase: should_add = False
elif announce_gravity == None or current_gravity == None: should_add = False
else:
if announce_gravity <= current_gravity: should_add = False
else:
... should_add = True
Three facts follow:
- Hop count is not in the acceptance test, in either version. It
appears only in the
packet.hops <=gate above. A second copy of an emission already installed does not replace the path for having taken fewer hops in 1.3.5 or in 1.5.2. - What 1.5.2 does accept on equal emission is a higher-
gravityinterface.gravityis an operator-configured per-interface preference — 1.5.2Reticulum.pylines 798-799 read it out of the interface stanza, and_add_interface(lines 1134-1136) fills thedefault_gravitywhen the stanza names none. It is a configured ranking, not a measurement. - On a default config 1.5.2 behaves exactly like 1.3.5 here.
1.5.2
Interfaces/Interface.pyline 75 setsDEFAULT_GRAVITY = 0, soannounce_gravity <= current_gravityholds and the arm rejects. Anrnsdarm that sets nogravityanywhere is measuring the strict 1.3.5 rule — and that is everypathchoice_*cell: nogravitykey appears anywhere under periculum'semulated/,conformance/orpericulum/adapters/, and each node in those cells has exactly one interface to begin with.
Recorded 2026-09-27 against ~/coding/Reticulum at 1.5.2. The nearest
thing we have to this arm is our own same-emission clause
(transport.rs, SameEmissionRule, Codeberg #231), which ranks by hop
count rather than by a configured preference.
6. The measured same-emission arm: path_choice = hops_and_loss
Since 2026-10-05 (Codeberg #230) the same-emission clause has a second,
opt-in rule beside the default hop ranking: path_choice = hops_and_loss (the [reticulum] key; TransportConfig::path_choice,
PathChoice in transport.rs). Where §5's 1.5.2 gravity re-ranks
equal-emission copies by an operator-CONFIGURED preference, this rule
re-ranks them by a MEASURED one: the direct link is priced at its
bidirectional ETX, estimated for free from the announce re-hear ratio —
of the emissions this transport provably learned about, the fraction
that also arrived as a direct copy (DirectLinkEvidence,
lower_route_cost). A clean two-hop route then beats a direct link
losing more than ~29 % of announces. Three properties bound it:
- Scope. Only the same-emission comparison changes. A newer emission still installs unconditionally in both hop arms (§3's rules, which is also the liveness guarantee when the relay dies), and the random-blob replay gate lets a same-blob copy through exactly when the cost arm will accept it. Path responses and local-client copies feed no evidence: a response is served once over one route, so the absence of a direct copy of it proves nothing.
- Degradation. Below
DIRECT_EVIDENCE_MIN_KNOWNknown emissions — a fresh destination, or a mesh whose announce cadence is too sparse to score — the rule decides exactly likehops. The default IShops; the knob is a deliberate opt-in and rnsd ignores the key in a shared config. - Evidence. The pinned replay
mvr_pathchoice_rule_table(leviculum-core, node/) drives both knob settings through the real acceptance gate over thepathchoice_loss*room shape at five loss levels and eight seeds: identical at 0 % loss, more delivered and fewer flaps at every measured loss ≥ 30 %, and the Python reference row (§3/§5 semantics as a pure fold) matches the shipped rule exactly, which pins the replay to the gate. The companionmvr_pathchoice_direct_vs_routeholds the norelay control: with no competing route the penalty can never unroute the only path.