Skip to content

feat(notify): adaptive offline threshold and certain node analytics (#2260) - #26

Merged
efiten merged 3 commits into
masterfrom
feat/adaptive-offline
Oct 22, 2026
Merged

efiten merged 3 commits into
masterfrom
feat/adaptive-offline

Conversation

@efiten

@efiten efiten commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Part 2 of Kpa-clawbot#2260. Builds on #25 (upstream Kpa-clawbot#2261) (certain liveness): review/merge that first; until then this diff also shows its commit.

Adaptive offline threshold

userManagement.notifications.adaptiveOffline — off by default:

"notifications": {
  "enabled": true,
  "adaptiveOffline": { "enabled": true, "floorMinutes": 30, "minObservers": 3 }
}

The fixed infraSilentHours (72 h) treats every repeater the same. With this on, a watched node's node.offline window is learned from its own certain traffic (the Kpa-clawbot#2261 rule):

  1. Certain packets per hour over the week before its last certain packet. Ending there, not at now, keeps an outage from dragging the rate down until the fixed window returns and the node reads "back online".
  2. Empty runs are classified: ≤ 6 h and recurring on another day at the same hours = normal silence (kept; the window is never shorter than the longest + 1 h); any other run = outage, left out of the learning so it neither stretches the window nor blocks learning.
  3. λ = 10th percentile of the remaining hourly counts, so quiet nights don't alert.
  4. Window = 14/λ hours: silence at rate λ has a < 10⁻⁶ chance (Poisson, ln 10⁶ ≈ 13.8).
  5. Clamped to [floorMinutes, the role's silent window].

Kept on the fixed window: λ < 1/hour, under 24 h of history in memory, or a 10th-percentile hour heard by fewer than minObservers observers (one observer dropping out must not read as the node going offline). Silence is measured up to the newest packet in the store, so a stalling feed stops every node's clock. A node stored offline comes back only on evidence from the moment it went offline on. The notifier caches each learned window for 10 minutes and drops unwatched nodes.

For the Kpa-clawbot#2260 repeater (~840 packets/day) the mail would have gone out about 30 minutes into the outage, instead of never (or 72 h with Kpa-clawbot#2261 alone).

Certain node analytics

  • Each activityTimeline bucket carries certain: the part that can only be this node (certainFor, the packet-level form of the certainHeard rule). TimeBucket.Certain is omitempty, so other endpoints are unchanged.
  • Availability and longest silence use certain buckets only; the longest silence now includes one still running (an outage in progress is the silence worth seeing).
  • The chart stacks certain (--accent) and uncertain (--text-muted) — existing theme variables, so no new customizer entry. Descriptions updated.

Performance

CertainActivity walks a week of packets per watched node, at most once per node per 10 minutes, with the read lock taken per node:

BenchmarkCertainActivity-16   12.1 ms/op   (50 watched repeaters × 6,000 packets; ~0.25 ms per lock hold)
BenchmarkNoteCertainRelays-16  121 ns/op   0 allocs

Node analytics adds one certainFor per packet of the requested node (a prefix-map lookup, or the cached decoded JSON for its own packets).

Tests

  • adaptiveOfflineAfter: busy repeater hits the floor; 5/hour; quiet nights set the rate; > 10 % empty hours, short history, too few observers → fixed.
  • CertainActivity ends at the last certain packet and counts only certain packets — verified red by mutating the window to end at now.
  • Notifier end to end: learned window passed → bad; inside → good; too few observers / setting off → fixed 72 h; cache refresh and prune.
  • certainFor, longestSilence (incl. running silence), config defaults, /api/nodes/{pk}/analytics splits a shared-prefix bucket.
  • Frontend: NodeAnalyticsTimeline.series (new tests/unit/test-node-analytics-certain-timeline.js).
  • cmd/server go test ./... and test-all.sh pass locally, also on a trial merge into a downstream fork.

Validation

Server on the freshened e2e fixture: analytics buckets carry certain (e.g. 89 of 90 packets certain for one repeater). No browser screenshot: Playwright isn't installed on this machine.

Later (rule 8)

floorMinutes / minObservers are server config, not theme settings; an admin UI for notification settings would be the place, not the customizer.

🤖 Generated with Claude Code

@efiten
efiten force-pushed the feat/adaptive-offline branch from 000e458 to 7ad251a Compare October 22, 2026 11:14
@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Review feedback addressed (commit 7ad251a5, rebased on f7a809d9)

  1. False "back online" during a long outage. The learning window is clipped to the store's coverage, so after retention or a restart the learned window could vanish, fall back to 72 h and read a still-dead node as online. nodeOnlineState now keeps a node stored offline until there is evidence from the moment it went offline on (prevAt = the state's ChangedAt). Test: TestOfflineNodeNeedsNewEvidenceToComeBack (verified red with the check removed).
  2. Flapping on regular silent hours. The 10th percentile tolerates up to 10 % empty hours, so a node with silent nights could get a 30-minute window. The learned window is now never shorter than the longest run of empty hours in the learning week + 1 h. Test case: regular silent nights lengthen the window.
  3. Longest silence missed cases. No certain bucket in the window now reports the whole covered window, and the silence from the window start to the first certain bucket counts. Tests in TestLongestSilence.
  4. certainFor also ignores adverts whose signature failed (same rule as fix(liveness): count only evidence that can be the node alone (#2260) #25).

Not changed, with reasons:

cmd/server go test ./... and test-all.sh pass, also on a trial merge of both PRs into this fork's master.

efiten and others added 2 commits October 22, 2026 14:22
…awbot#2260)

A repeater silent since 18 Oct showed "Active, last relayed 4m ago" with
Last Heard 36m, and its offline mail never fired. Every hit after that
date was the 1-byte hop 67, shared by nine nodes: the live resolver's
affinity tiebreak put it in byNode, and last_relayed folds in the 1-byte
prefix bucket on purpose (Kpa-clawbot#662).

The store now keeps certainHeard per node: the newest transmission that
can only be that node. Its own packets (pubKey / srcPubKey) count, and a
flood relay hop counts when its prefix at the packet's path hash size
matches exactly one path-capable node. Direct-route paths (hops still to
come, Mesh::removeSelfFromPath) and TRACE (SNR bytes) never count. The
same rule feeds every load path: Load, loadChunk, scanAndMergeChunk,
live ingest and late observations.

Liveness reads only that:
- /api/nodes and the node detail: last_heard is the newer of last_seen
  and the certain last heard, no longer a copy of last_seen;
- health and bulk-health stats.lastHeard;
- LastHeardMap, so the notify offline check; the separate relay input
  (lastRelayed / relayTimes) is gone;
- roles.js getEffectiveHeardMs: the newest known timestamp, without
  last_relayed; the side pane no longer says "Last relayed".

Attribution for analytics (byNode, packet counts, last_relayed, relay
counters) is unchanged.

Cost: noteCertainRelays 116 ns per observation for 8 hops, 0 allocs;
LastHeardMap 0.77 ms for 5,000 nodes (was 3.7 ms), one map lookup each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…pa-clawbot#2260)

Part 2 of Kpa-clawbot#2260, on top of the certain-liveness fix.

Adaptive offline threshold (userManagement.notifications.adaptiveOffline,
off by default). A watched node's node.offline window is learned from its
own certain traffic: certain packets per hour over the week before its
last certain packet (ending there, not at now, keeps an outage from
dragging the rate down until it reads "back online"), λ the 10th
percentile so quiet nights do not alert, window 14/λ hours (silence at
that rate has under a one-in-a-million chance), clamped to [floorMinutes,
the role's silent window]. Nodes with λ < 1/hour, under a day of history
or fewer than minObservers observers keep the fixed window: one observer
dropping out must not read as the node going offline. The notifier caches
each learned window for 10 minutes and drops unwatched nodes.

The Kpa-clawbot#2260 repeater (~840 packets a day) would have been mailed about 30
minutes into its outage instead of never.

Node analytics: each timeline bucket carries certain, the part that can
only be this node (certainFor: the packet-level form of the certainHeard
rule). Availability and the longest silence come from certain buckets
only, and the longest silence includes one still running. The chart
stacks certain (--accent) and uncertain (--text-muted).

Cost: CertainActivity walks a week of packets per watched node, at most
once per node per 10 minutes, with the read lock taken per node: 12 ms
for 50 busy repeaters with 6,000 packets each (~0.25 ms per lock hold).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@efiten
efiten force-pushed the feat/adaptive-offline branch from 7ad251a to b0a37f2 Compare October 22, 2026 12:32
@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Review notes addressed (commit b0a37f2c, rebased on b5556bdc)

Answers to the questions: silence is measured against the newest ingested packet; the zero-run rule covers only runs that recur and are at most 6 h, longer or one-off runs are outages.

  1. Stalled feed vs floor. notifyInput.NewestPacket: a node's silence is measured up to min(now, newest packet in the store), so a stalling feed stops every node's clock and nothing is mailed before IngestStale trips, whatever floorMinutes is. Test: TestStalledFeedStopsTheSilenceClock (verified red without the clamp).
  2. minObservers. Counted per hour now: certainActivity.HourObservers, and the 10th-percentile hour (of hours with packets) must reach minObservers. A node carried by one observer stays on the fixed window. Test case: one observer carries the node.
  3. Window start in local time. CertainActivity converts now and the coverage start to UTC; certainActivity.From is UTC. Test: TestCertainActivityUTCBoundsAndLegacyTimestamps with a +02:00 caller just after local midnight (verified red without it).
  4. Legacy timestamps. The pre-filter compares the date only (FirstSeen[:10]); the parsed time decides. Same test, verified red with the old string bound.
  5. Outage over ~17 h and the new zero-run question. Empty runs are classified first (classifySilences): a run of ≤ 6 h that recurs (another empty run on a different day covers one of the same hours of the day) is normal silence — it stays in the percentile and the window is never shorter than the longest such run + 1 h. Every other run is an outage and is left out of the learning entirely (percentile, history length, and the zero-run floor). So one outage neither stretches the window for a week nor makes the node unlearnable. Tests: recurring 2 h nightly silence → 3 h; one-off 6 h gap → floor; 30 h outage → still learnable; recurring 8 h gaps → outages; recurring 4 h nightly (17 % empty) → not learnable.

cmd/server go test ./... and test-all.sh pass.

…lawbot#2260)

Retention copies a node into inactive_nodes and nothing removes the row
when the node is heard again. markRetired loaded those rows too, so such a
node's own prefix counted as a retired namesake and it got no certain
relay at any width. markRetired now skips rows whose public_key is also in
nodes.

On a live instance 838 of the 7877 repeater and room rows in
inactive_nodes are also in nodes; repeaters and rooms with a certain relay
in the last 72 h went from 1256 to 629 with the rule, 1206 with this fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@efiten
efiten merged commit 7de7e3f into master Oct 22, 2026
12 checks passed
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