views: exact count unions across relays (fixes cross-relay under-count) (#4)

The watch-page/chart "exact" count relied on the NIP-45 COUNT, but
countEventsAcrossRelays returns bestEstimate = the single relay with the MOST
events — it can't union across relays. So when views are distributed across
relays it under-counted (a video's card showed 19 while the chart's uncapped
list union showed ~63).

exactCountForPointer now lists view events across ALL relays (one on-demand
list, not a storm) and folds the UNION into the deduped dedupeBuckets (one entry
per viewer-window). total = the set size, so it is accurate AND monotonic (never
spuriously decreases on partial relay responses). The per-relay COUNT is kept
only as a lower-bound floor when the list truncates (>2000 events), flagged
partial. The popularity modal passes { exact: true }; since the count flows
through shared pointer state, opening a video corrects its count everywhere.

Grid cards (no exact) still use the §17-capped batched backfill (approximate,
storm-safe) until opened — an accepted accuracy/cost tradeoff.

Also note the ⋯ menu "Event Details" button does nothing (new TODO #31).

Tests: testExactCountUnionsAcrossRelays. Grandfathered viewCounter.js size.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 17:29:53 -04:00
1 parent 1bc2b06395
commit 36f3c6e127
5 files changed
+164 -26

No files matched your search

+11 -7
View File
@@ -319,13 +319,17 @@ export function openPopularityModal({
// Headline total: subscribe to the same counter the card uses (stable + cached).
try {
countToken = subscribeToVideoViewCount(pointer, ({ total } = {}) => {
if (closed) return;
if (Number.isFinite(total)) {
counterTotal = Number(total);
renderHeadline();
}
});
countToken = subscribeToVideoViewCount(
pointer,
({ total } = {}) => {
if (closed) return;
if (Number.isFinite(total)) {
counterTotal = Number(total);
renderHeadline();
}
},
{ exact: true },
);
} catch (error) {
devLogger.warn("[popularity] Failed to subscribe to view count:", error);
}
+68 -19
View File
@@ -249,35 +249,84 @@ async function runBatchedBackfill() {
// Exact NIP-45 COUNT for a single pointer (watch page). Cheap — one pointer,
// no storm — and accurate where a viewer might actually scrutinize the number.
const EXACT_LIST_LIMIT = 2000;
// Accurate single-pointer count (watch page / popularity chart). Lists view
// events across ALL relays and dedupes by (viewer, window) — a true cross-relay
// UNION. This replaces relying on the NIP-45 COUNT, which returns the single
// relay with the most events (it can't union across relays) and therefore
// UNDER-counts whenever views are spread across relays. The deduped set accrues
// monotonically, so the number is accurate AND never spuriously decreases. For
// extremely popular videos where the list truncates, the per-relay COUNT is used
// as a lower-bound floor and the result is flagged partial.
async function exactCountForPointer(key, pointer, options = {}) {
const sinceSeconds =
Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY;
let events;
try {
const result = await countVideoViewEventsApi(pointer, {
events = await listVideoViewEventsApi(pointer, {
since: sinceSeconds,
limit: EXACT_LIST_LIMIT,
relays: options.relays,
signal: options.signal,
});
const best = Number.isFinite(result?.best?.count)
? Number(result.best.count)
: Number.isFinite(result?.total)
? Number(result.total)
: null;
if (best === null) {
} catch (error) {
if (error?.name === "AbortError") {
return;
}
const state = ensurePointerState(key);
if (result && typeof result === "object") {
state.partial = Boolean(result.partial);
userLogger.warn("[viewCounter] Exact view count list failed:", error);
return;
}
const eventList = Array.isArray(events) ? events : [];
const truncated = eventList.length >= EXACT_LIST_LIMIT;
const state = ensurePointerState(key);
// Fold the union into the deduped bucket set (one entry per viewer-window).
for (const event of eventList) {
const bucketKey = deriveBucketKey(event) || deriveFallbackKey(event);
if (!bucketKey) {
continue;
}
if (best > state.total) {
state.total = best;
state.lastSyncedAt = Date.now();
}
schedulePersist();
notifyHandlers(key);
} catch (error) {
if (error?.name !== "AbortError") {
userLogger.warn("[viewCounter] Exact view count failed:", error);
const createdAt = Number.isFinite(event?.created_at)
? Number(event.created_at)
: Math.floor(Date.now() / 1000);
state.dedupeBuckets.set(bucketKey, createdAt);
}
let total = state.dedupeBuckets.size;
let partial = truncated;
if (truncated) {
try {
const result = await countVideoViewEventsApi(pointer, {
relays: options.relays,
signal: options.signal,
});
const best = Number.isFinite(result?.best?.count)
? Number(result.best.count)
: Number.isFinite(result?.total)
? Number(result.total)
: null;
if (Number.isFinite(best) && best > total) {
total = best;
}
partial = partial || Boolean(result?.partial);
} catch (error) {
if (error?.name === "AbortError") {
return;
}
}
}
state.partial = partial;
if (total !== state.total) {
state.total = total;
state.lastSyncedAt = Date.now();
}
schedulePersist();
notifyHandlers(key);
}
restoreCacheSnapshot();
+1
View File
@@ -43,6 +43,7 @@ const GRANDFATHERED = {
"js/nostr/nip46Client.js": 2102,
"js/state/cache.js": 1827,
"js/app/feedCoordinator.js": 1857,
"js/viewCounter.js": 1023,
"js/app/authSessionCoordinator.js": 1803,
"js/ui/components/RevertModal.js": 1663,
"js/payments/nwcClient.js": 1642,
+61
View File
@@ -575,6 +575,66 @@ async function testRelayCountAggregationUsesBestEstimate() {
}
}
// The exact path (options.exact, watch page / popularity chart) must count the
// cross-relay UNION of view events deduped by (viewer, window) — NOT the per-relay
// NIP-45 COUNT, which returns the single relay with the most events and therefore
// under-counts when views are spread across relays.
async function testExactCountUnionsAcrossRelays() {
localStorage.clear();
harness.reset();
harness.resetMetrics();
const previousGetManager = nostrClient.getSubscriptionManager;
// exactCountForPointer only runs when a SubscriptionManager exists.
nostrClient.getSubscriptionManager = () => ({
list: async () => [],
subscribe: () => ({ close() {} }),
handleReconnect() {},
});
try {
const pointer = { type: "e", value: "view-counter-exact-union" };
const pointerKey = harness.pointerKeyFromInput(pointer);
const dayStart =
Math.floor(Math.floor(Date.now() / 1000) / 86400) * 86400;
// alice has two views in the same day (dedupe to 1) → 3 distinct (viewer,day):
// alice@d0, bob@d0, alice@d1.
harness.setEvents(pointerKey, [
{ id: "e1", pubkey: "alice", created_at: dayStart + 10 },
{ id: "e2", pubkey: "alice", created_at: dayStart + 20 },
{ id: "e3", pubkey: "bob", created_at: dayStart + 30 },
{ id: "e4", pubkey: "alice", created_at: dayStart - 86400 + 10 },
]);
// A single relay's COUNT only saw 1 — the union must win.
harness.setCountTotal(pointerKey, {
total: 1,
best: { relay: "wss://relay.one", count: 1 },
});
const updates = [];
const token = subscribeToVideoViewCount(
pointer,
(state) => updates.push({ ...state }),
{ exact: true },
);
try {
await flushPromises();
await flushPromises();
const final = updates.at(-1);
assert.ok(final, "expected an exact-count update");
assert.equal(
final.total,
3,
"exact count = cross-relay union deduped by (viewer,day), not the per-relay COUNT (1)",
);
} finally {
unsubscribeFromVideoViewCount(pointer, token);
}
} finally {
nostrClient.getSubscriptionManager = previousGetManager;
}
}
async function testLocalIngestNotifiesImmediately() {
localStorage.clear();
harness.reset();
@@ -1136,6 +1196,7 @@ await testHydrationSkipsStaleEventsAndRollsOff();
await testLocalIngestNotifiesImmediately();
await testUnsubscribeStopsCallbacks();
await testRelayCountAggregationUsesBestEstimate();
await testExactCountUnionsAcrossRelays();
await testRecordVideoViewEmitsJsonPayload();
await testSignAndPublishFallbackUsesSessionActor();
await testHydrateHistoryPrefersRootEvent();
+23
View File
@@ -221,6 +221,22 @@ parameterized-replaceable event; (b) `exactCountForPointer` runs a NIP-45 COUNT
boundary (map vs the `syncActiveVideoRootTimestamp` field step). Removed from
the QUARANTINE map. Spec corrections logged in TEST_INTEGRITY.md
(SCN-view-counter-legacy-path, SCN-hydrate-root-timestamp).
- [x] **Cross-relay UNDER-count fixed (2026-06-24).** The "exact" path (watch page
+ popularity chart) relied on the NIP-45 COUNT, but `countEventsAcrossRelays`
returns `bestEstimate` = the SINGLE relay with the most events — it can't
union across relays, so it under-counts when views are spread across relays
(e.g. card showed 19 while the chart's uncapped list union showed ~63).
`exactCountForPointer` now lists view events across ALL relays (uncapped, one
on-demand list — not a storm) and folds the UNION into the deduped
`dedupeBuckets` (one per viewer-window); total = the set size, so it's
accurate AND monotonic (never spuriously decreases). Per-relay COUNT is kept
only as a lower-bound floor when the list truncates (>2000), flagged partial.
The popularity modal now passes `{ exact: true }`, and since the count flows
through the shared pointer state, opening a video corrects its count
everywhere (grid cards converge). Test: `testExactCountUnionsAcrossRelays` in
`tests/view-counter.test.mjs`. NOTE: grid cards (no `exact`) still use the
§17-capped batched backfill (approximate, storm-safe) until the video is
opened — acceptable accuracy/cost tradeoff.
- [x] **`reactionCounter.js` (likes/kind-7) audited — accurate, no fix needed.**
It does NOT use NIP-45 COUNT; it lists kind-7 events and dedupes via
`applyReactionToState` keyed by pubkey (one reaction per user, latest-wins),
@@ -469,6 +485,13 @@ toward freshness and looked identical. Gave each a structural identity:
windowed "trending" (views in the last N days) once view-over-time data (#26)
lands, instead of all-time totals.
### 31. ⋯ menu "Event Details" button does nothing (BUG)
- [ ] The "Event Details" item in the video ⋯ (more) menu does not open / does
nothing when clicked. Trace its `data-action` → `handleMoreMenuAction` case
(vs `EventDetailsModal`): confirm the action name matches a case (likely a
missing/renamed case or a no-op like the embed/copy-magnet event-name
mismatches), wire it to open `EventDetailsModal`, and add a regression test.
### 28. Beacon torrent app stuck — spinner never resolves (BUG)
- [ ] The torrent beacon app (`scripts/build:beacon` / `torrent/` integration) shows
a spinner that never goes away. Trace the beacon's init/connect path: what the