Skip to content

fix(lurker): PSS bin selection — base is min(PO(peer, target), L), not PO(peer, target) - #54

Open
flotob wants to merge 2 commits into
mainfrom
fix/pss-bin-selection
Open

fix(lurker): PSS bin selection — base is min(PO(peer, target), L), not PO(peer, target)#54
flotob wants to merge 2 commits into
mainfrom
fix/pss-bin-selection

Conversation

@flotob

@flotob flotob commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Credit where due

This correction comes from Viktor Trón, the inventor of the Swarm protocol, who reviewed the light-node receiver design and asked the one question that mattered: which PO bins is the receiver subscribing to? — and then pointed out that our answer was wrong in exactly the deep-peer regime.

The bug

For a trojan mined to an L-bit prefix of target t, on a covering peer p with b_p = PO(p, t), the trie geometry gives two regimes:

Regime Where the trojan sits on p Old selection (b_p..=b_p+3)
b_p < L Exactly bin b_p (p diverges from t at b_p; the trojan still agrees there) Base right by accident; the +3 window pulled bins that cannot contain it
b_p ≥ L L + Geom(½)independent of b_p, i.e. around bin L, shallower than b_p Missed entirely once b_p > L+3

Why every mainnet run passed anyway: the demos mined 2-byte (16-bit) targets while covering storers sat at b_p ≈ 11–14 < L — the accidental-correct regime — and 5-peer covering redundancy masked individual deep-peer misses (the symmetric-rendezvous co-resident node at 18 shared bits was squarely in the broken regime; its neighbors covered for it). Working, but not for the stated reason.

The fix

PSS_MINED_PREFIX_BITS = 16 becomes a receiver-side convention (bee's de-facto 2-byte targets, what ant's own gateway demos send), and bin selection moves into a pure covering_bins(b_p, want_gsoc, want_pss):

  • GSOC: exactly bin b_p — the watch target is the chunk address; never affected.
  • PSS, b_p < L: exactly bin b_p (was: b_p..=b_p+3 — 3 dead bins dropped).
  • PSS, b_p ≥ L: bins L..=L+3 (geometric tail: 15/16 of the mass per peer; the covering-set redundancy handles the rest).
  • Mixed watch: deduplicated union (the desired-set/handover machinery already handles non-contiguous bin sets per peer — only the range plumbing changed).

The convention does double duty, per Viktor's own accounting: bins ≥ L carry only ~2^−(L−d) of a depth-d storer's ingest (candidate try-unwrap traffic stays small), and L must exceed the storage depth or no storer keeps the trojan at all — the "1–3 bytes, your choice" freedom in the API was a trap at both ends.

Follow-up worth its own discussion: at L = 24, the entire trojan-bearing backlog of a 4M-chunk reserve is ~1–2K chunks (~8 MB) — small enough to sweep completely on subscribe. That turns PSS reception from a live tail into an offline mailbox. Not in this PR; noted in the constant's docs.

Verification

🤖 Generated with Claude Code

… PO(peer,target)

Correction due to Viktor Trón (inventor of the Swarm protocol), who
reviewed the receiver design and asked exactly the right question:
which PO bins is the receiver subscribing to?

The trie geometry gives two regimes for where a trojan mined to an
L-bit prefix of target t sits on a covering peer p with
b_p = PO(p, t):

- b_p <  L: p diverges from t at bit b_p while the trojan still agrees
  there, so PO(c, p) = b_p EXACTLY. One deterministic bin; the old
  +3-deeper window pulled three bins that cannot contain it.
- b_p >= L: the trojan agrees with p through bit L and is mined noise
  beyond, so PO(c, p) = L + Geom(1/2) — independent of b_p, i.e.
  around bin L, SHALLOWER than b_p. The old b_p..=b_p+3 selection
  missed it entirely once b_p > L + 3.

Why the mainnet runs passed anyway: the demos mined 2-byte (16-bit)
targets while covering storers sat at b_p ≈ 11–14 < L (the exact-bin
regime, where the old base happened to be right), and the 5-peer
covering redundancy masked the per-peer misses of deeper nodes (the
symmetric-rendezvous co-resident at 18 shared bits was in exactly the
broken regime).

The fix pins L as a receiver-side convention (PSS_MINED_PREFIX_BITS =
16 — bee's de-facto 2-byte targets) and selects per covering peer:

- GSOC: exactly bin b_p (the watch target IS the chunk address; never
  affected by this bug).
- PSS, b_p < L: exactly bin b_p.
- PSS, b_p >= L: bins L..=L+PSS_BIN_WINDOW (geometric tail: 15/16 of
  the mass per peer, N-way covering redundancy on top).
- Mixed watch: the deduplicated union.

The convention also bounds candidate cost (bins >= L carry
~2^-(L-d) of a depth-d storer's ingest) and is a storability floor
(L must exceed the storage depth or no storer keeps the trojan) —
and at L = 24 the ENTIRE trojan-bearing backlog of a reserve is
~1-2K chunks, which makes offline mailbox sweeps feasible (future
work).

Bin selection is now a pure covering_bins(b_p, want_gsoc, want_pss)
with regression tests for both regimes, GSOC exactness, mixed-watch
union/dedup, and the empty watch.
@flotob

flotob commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Deep-regime mainnet verification — the fix observed live

Ran the scenario this PR exists for, on mainnet at 010e903, with a freshly funded depth-20 batch:

Setup: rendezvous target = a connected mainnet storer's own overlay (c7aa1937…), so that storer covers the subscription at b_p = 31 — maximally deep in the geometric regime. Subscribe /pss/subscribe/{topic}?neighborhood=c7aa…, then 4 sends mined to the 16-bit prefix c7aa (4 independent mining draws, so the per-message 1/16 tail miss can't fake a failure).

Puller placement (the fix, visible): the deep peer got pullers at bins 16, 17, 18, 19 — the L..L+3 window:

lurker pulling neighborhood bin peer=QmXcxNMDH… bin=16 …
lurker pulling neighborhood bin peer=QmXcxNMDH… bin=17 …
lurker pulling neighborhood bin peer=QmXcxNMDH… bin=18 …
lurker pulling neighborhood bin peer=QmXcxNMDH… bin=19 …

The pre-fix selection (b_p..=b_p+3) would have placed a single bin-31 puller on this peer — bins that a 16-bit-mined trojan cannot reach.

Delivery attribution (the regime, observed): all 4 messages delivered, every one through the deep peer, at bins matching L + Geom(½):

pull round peer=QmXcxNMDH… bin=18 … delivered=1   (msg #1)
pull round peer=QmXcxNMDH… bin=16 … delivered=1   (msg #2)
pull round peer=QmXcxNMDH… bin=16 … delivered=1   (msg #3)
pull round peer=QmXcxNMDH… bin=16 … delivered=1   (msg #4)

Observed bins {16,16,16,18} against predicted mass (½ at 16, ¼ at 17, ⅛ at 18). Attribution is single-source: the other covering peers sit at b_p ≈ 5–6 and don't store this neighborhood at all, so the deep peer was the only possible path — precisely the path the old code couldn't use.

One additional live observation matching the geometry: an initial single-send run produced no delivery — consistent with the 1/16 tail (PO(c, storer) > L+3) the window deliberately accepts, and the reason the covering-set redundancy (and, in this maximally-adversarial single-source setup, multi-send) exists. Four draws: all four in-window.

No-regression: gsoc_e2e ✅ and pss_e2e ✅ (shallow regime, own-neighborhood) re-passed on the same build.

With the unit tests covering both regimes and the deep regime now demonstrated live against mainnet storers, this is proven end to end.

🤖 Generated with Claude Code

flotob added a commit that referenced this pull request Jul 22, 2026
…ffline delivery

Builds on the bin-selection fix (#54, Viktor Trón's correction): now
that PSS pullers pull the correct trojan bins (L..L+3 in the geometric
regime), those bins can be swept from the past instead of only tailed
live — turning PSS reception from a live socket into a mailbox.

WebSocket `GET /pss/subscribe/{topic}?history=true` (and the
`LurkerSubscribe { history }` control command + `WatchState::history`
it threads to) makes a fresh puller start a bounded window behind the
peer's cursor rather than just PULL_BACKLOG behind it, so messages sent
while the receiver was offline are recovered before live traffic.

The lookback (HISTORY_BACKLOG, in binIDs) is bounded ON PURPOSE:
per-bin occupancy on a depth-d storer is ~2^(22-(k-d+1)), so at the
current 2-byte mining prefix (L=16) the trojan bins hold hundreds of
thousands of chunks — an unbounded start=1 sweep would be a
self-inflicted DoS. The window caps it at a few thousand recent chunks
per bin. Crucially, it becomes a COMPLETE backlog sweep for free under
the deeper (L=24) mining convention Viktor advocates: there the whole
trojan-bin backlog is ~1-2K chunks, so cursor-HISTORY_BACKLOG
underflows to the floor and the mailbox recovers everything. Raising
the mining convention upgrades the mailbox from 'recent' to 'all' with
no code change.

Semantics unchanged where they should be: resume (epoch-matched
handover) always continues exactly where the predecessor stopped —
mailbox never rewinds a live puller; GSOC has no mailbox (a SOC has a
latest value, not a message backlog) — always live; history is
union-OR'd across subscribers on the shared lurker. A sweep exceeding
SEEN_CAP may re-deliver its oldest chunks, within the documented
at-least-once/may-duplicate contract.

Tests: start_bin_id both regimes + resume override + sparse-bin
underflow-to-floor; WatchState::merge_from history union; WS
?history=true wiring smoke.
@flotob

flotob commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Viktor Trón ruled on the mining-prefix convention: L = 24 (3-byte targets). Pushed as 341e696PSS_MINED_PREFIX_BITS 16 → 24, tests made L-relative. (The stacked mailbox #55 was rebased on top and its docs updated to match.)

Re-verified end to end on mainnet at the L=24 build (release, 3-byte mined targets, directed PSS to the node's own overlay so residency is guaranteed):

  • Live: 4/4 — send 4 messages mined to a 3-byte prefix, all received.
  • Mailbox: 4/4 — send 4 with nobody subscribed, then ?history=true recovers all 4 (history pullers swept bins 9–14 — the covering peers' proximities — from cursor − 4096).

One real finding worth flagging (and now in the SWIP as an informative note): 24-bit mining is ~256× a 2-byte mine. On the debug lab build it exceeds the 60s /pss/send timeout outright; even on release the geometric variance of the search occasionally trips 60s, so the mailbox run needed swarm-chunk-retrieval-timeout: 180s. Server-side it's a few seconds; on a constrained mobile sender (Freedom) it's material — L=24 makes reception ~256× cheaper at the cost of ~256× more sender work. The right fix is to mine off the request's critical path (background task) rather than block the send; noted as follow-up, not blocking.

Net: the deeper convention is the right call — it's what makes the mailbox a complete backlog sweep rather than a recent window — and it's now proven on mainnet. Sender-side mining ergonomics are the one thing it costs, and that's a UX problem, not a protocol one.

🤖 Generated with Claude Code

After discussion with Viktor Trón, keep the convention at L=16 (2-byte
targets). His L=24 motivation was proof-of-work spam punishment on
senders, not receiver efficiency. Two reasons 16 is the better fit for
a light-node messaging client:

- No receiver benefit at our residency. The deeper-prefix wins (cheaper
  candidate traffic, complete mailbox) require covering peers at
  b_p >= L. A light node's covering peers sit at b_p ~9-14, so
  covering_bins pulls bin b_p regardless of L — measured identical on
  mainnet at L=16 and L=24. L=24 buys the light-node receiver nothing.
- L=24 costs the sender 256x (~2^24 hashes): seconds on a phone, and it
  trips the /pss/send timeout. PSS already carries an economic spam gate
  via the postage stamp, so the PoW punishment is largely redundant with
  postage and can't distinguish spammer from legitimate mobile user.

Doc-only: flips PSS_MINED_PREFIX_BITS back to 16 (from the reverted L=24
experiment) with the reasoning. A deeper prefix as a deliberate network
anti-spam PoW remains an open protocol-incentive question, tracked in
the SWIP.
@flotob
flotob force-pushed the fix/pss-bin-selection branch from 341e696 to 573a54c Compare July 22, 2026 20:00
flotob added a commit that referenced this pull request Jul 22, 2026
…ffline delivery

Builds on the bin-selection fix (#54, Viktor Trón's correction): now
that PSS pullers pull the correct trojan bins (L..L+3 in the geometric
regime), those bins can be swept from the past instead of only tailed
live — turning PSS reception from a live socket into a mailbox.

WebSocket `GET /pss/subscribe/{topic}?history=true` (and the
`LurkerSubscribe { history }` control command + `WatchState::history`
it threads to) makes a fresh puller start a bounded window behind the
peer's cursor rather than just PULL_BACKLOG behind it, so messages sent
while the receiver was offline are recovered before live traffic.

The lookback (HISTORY_BACKLOG, in binIDs) is bounded ON PURPOSE:
per-bin occupancy on a depth-d storer is ~2^(22-(k-d+1)), so at the
current 2-byte mining prefix (L=16) the trojan bins hold hundreds of
thousands of chunks — an unbounded start=1 sweep would be a
self-inflicted DoS. The window caps it at a few thousand recent chunks
per bin. Crucially, it becomes a COMPLETE backlog sweep for free under
the deeper (L=24) mining convention Viktor advocates: there the whole
trojan-bin backlog is ~1-2K chunks, so cursor-HISTORY_BACKLOG
underflows to the floor and the mailbox recovers everything. Raising
the mining convention upgrades the mailbox from 'recent' to 'all' with
no code change.

Semantics unchanged where they should be: resume (epoch-matched
handover) always continues exactly where the predecessor stopped —
mailbox never rewinds a live puller; GSOC has no mailbox (a SOC has a
latest value, not a message backlog) — always live; history is
union-OR'd across subscribers on the shared lurker. A sweep exceeding
SEEN_CAP may re-deliver its oldest chunks, within the documented
at-least-once/may-duplicate contract.

Tests: start_bin_id both regimes + resume override + sparse-bin
underflow-to-floor; WatchState::merge_from history union; WS
?history=true wiring smoke.
@flotob

flotob commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reverted to L = 16 (2-byte targets) — after more discussion, we're keeping the convention at 16, not 24. 341e696 (the L=24 bump) is dropped; the branch is back at the reviewed L=16 state plus a doc commit recording why 16 is the deliberate choice.

The deciding insight — and a correction to my earlier "L=24 is 256× cheaper reception" claim: that benefit only accrues to a receiver deeply resident in the target neighborhood (covering peers at b_p ≥ L). A light node's covering peers sit at b_p ≈ 9–14, so covering_bins pulls bin b_p regardless of L — I measured it identical at 16 and 24 on mainnet (same bins, same candidates). So for a light-node deployment, L=24 buys the receiver nothing and costs the sender 256× (~2^24 hashes — seconds on a phone, and it tripped the /pss/send timeout in the earlier verify). PSS already has an economic spam gate via the postage stamp, so the extra proof-of-work is largely redundant.

Net: 16 is the right default for a mobile light node — cheap to mine, identical receive. The mailbox (#55) is a recent-history window at any L (its docs are updated to say so honestly, rather than claiming complete-backlog which also needs deep residency).

Whether the network should mandate a deeper prefix as deliberate anti-spam PoW is a genuine protocol-incentive question the Swarm designer raised and is still weighing — parked as an open question in the SWIP, since its home is the core protocol, not our light-node default.

Both branches force-pushed at L=16; gate green.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant