Skip to content

Anomaly: periodic rule targets channel spam bots, channel exclusions and injection-based evaluation - #478

Merged
dborup merged 14 commits into
masterfrom
feat/474-anomaly-spam-bot-target
Oct 15, 2026
Merged

dborup merged 14 commits into
masterfrom
feat/474-anomaly-spam-bot-target

Conversation

@adminopenclaw8-sketch

@adminopenclaw8-sketch adminopenclaw8-sketch commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Relates to #474

Phases 1–3 of #474: the target definition, the detector-side channel exclusion and the injection-based evaluation tooling. Library and offline tool only — no ingestor, server, API or UI wiring, no defaults, no thresholds, no staging or production access. The pre-registration is posted on #474 before any data exists; the coordinator runs the evaluation later.

Plan (what this PR does, and in which order)

  1. Phase 1 — target definition. State in the library's own docs what the periodic rule is for: an abusive channel bot that floods a public or hashtag channel. Expected automation is not a target. anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 round 4 measured the opposite — all 6 labelled periodic incidents were wardriving trips on channel byte 81, where an app posts a position every 75–123 s while someone drives — and the rule failed on recall (0.17).
  2. Phase 2 — detector side. Give the periodic rule an opt-in exclusion list of channel hashes, with no defaults, and document the 1-byte collision cost.
  3. Phase 3 — evaluation tooling. Because real spam is rare, add inject (synthetic bot trains over a real background, with ground truth) and score (the anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 §5 metrics against that ground truth, or without labels for the false-alarm arm).
  4. Then post the pre-registration on anomaly: periodic rule targets channel spam bots (not wardriving) — exclusions + injection-based validation #474 and stop.

Phase 1 — target definition

internal/anomaly/doc.go gains a "What the periodic rule is for (#474)" section, and cmd/anomaly/README.md an "Excluded channels" section. Both say plainly that a target definition, an exclusion list and an evaluation are three different things, and that only the evaluation can support a claim.

Phase 2 — channel exclusion (PeriodicRule.ExcludeChannels)

  • Opt-in, no default. An empty or absent list excludes nothing, so an unconfigured rule behaves exactly as before and internal/anomaly/testdata/replay_golden.json is unchanged.
  • The event never reaches the rule. periodicApplies gates both the per-scope "does any rule apply" pre-check and the rule loop, so an excluded channel forms no pulse, no chain and no episode, and claims no key state in the scope's budget (Stats.Keys).
  • Periodic only. Rate and new-stream rules on the same events are untouched, and a second periodic rule without the list still fires. This keeps anomaly round 2: rate rule with an ingest-backlog guard, blind validation on fresh data #473 (rate rule plus backlog guard) free of collisions: both extend cmd/anomaly, but they touch different rule structs and different subcommands.
  • Matched on the whole hash. The 1-byte hash 81 and the 2-byte hashes 81ab and 8100 are three different entries; a 1-byte entry is never read as the prefix of a wider hash, which is the stance Key.String already takes. The zero "no channel" value is not a valid entry and never matches.
  • Validated like every other field. Canonical, non-zero, no duplicates, at most 256 entries (the whole 1-byte space MeshCore uses today). New clones the slice, so the caller's config and the detector never share a backing array.
  • 1-byte collisions are documented, not hidden (doc comment, doc.go, README): excluding the hash of #wardriving also excludes every other channel name that hashes to that byte — about 1/256 of all names — including any spam on them.
  • Replay JSON: "exclude_channels": ["81"], plain lower-case hex, optional.

The list starts empty in the repo. 81 (#wardriving) is named in the pre-registration, not in any code default, and the owner can add more hashes by editing the config file alone.

Phase 3 — injection-based evaluation

inject

corescope-anomaly inject -spec spec.json -input events.jsonl \
  -out events-injected.jsonl -labels labels.json \
  -max-injected 100000 -max-line-bytes 65536
  • Clearly synthetic. Every injected id is inj-<bot>-<pulse>-<part>. An export numbers its lines tx1, tx2, …, so no real line can carry that marker, and a background line that already starts with inj- is refused rather than mixed in.
  • Never written into any database. The subcommand has no -db flag and opens no database: it reads a spec and a JSONL file and writes exactly two new files, neither of which may exist beforehand, and neither of which appears unless the whole run succeeds.
  • Deterministic from the spec alone. The generator is splitmix64 inside the tool, not math/rand, and each bot's stream is seeded from the spec seed and the bot's name, so adding, removing or reordering bots never changes another bot's events.
  • Background untouched. Background lines are copied through byte for byte and in order; injected lines are merged in by data time, so the output is a valid replay input. A background line whose time cannot be read is an error, never a silently misplaced line.
  • Shapes. Period, Gaussian jitter clamped to jitter_max and applied per pulse (no drift), missed pulses, multi-part pulses (1..N later parts within a delay) and outage holes. The README's example spec is the September bot's documented shape from anomaly: hold a periodic bot as one episode and detect when it stops (evaluate Cooldown, then train tracking) #130 (5 min, ~3 s jitter, ~30 % multi-part with parts up to 55 s behind, ~5 % missed, one 35-minute hole, one stable first hop).
  • No defaults. Every spec field is required; fields whose zero value is meaningful (seed, payload_type, jitter_sd, the fractions, final_after) are pointers, so omitting one is an error instead of a silent zero. Unknown fields and trailing data are rejected.
  • Ground truth from the recipe, not from intent. -labels applies the anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 §4 periodic recipe to the emitted pulses: merge events closer than pulse_merge_gap into one pulse, take P as the median pulse gap in [min_period, max_period], and call an incident a maximal run of ≥ min_pulses pulses whose every gap is within max(tolerance_abs, tolerance_rel·P) of k·P for k ∈ 1..max_harmonic. So a bot with a 35-minute hole at P = 5 min is two incidents, and a 7-pulse bot is none.
  • Key-free and observer-free. The labels file holds bot names, channel hashes, times, periods and counts. No ids, routes, hops, observers, names or text; the test asserts the absence of each.

score

corescope-anomaly score -report report.json -labels labels.json \
  -rule p474_periodic -start ... -end ... [-out metrics.json]

The #457 §5 periodic matching, unchanged except that the labels come from inject:

  • an episode is one EventID; activation is its first transition to active, its end the last transition to ended, or the report's end if it never ended; only episodes activated inside [-start, -end) are scored;
  • a true positive needs an equal channel hash, [activation, end] overlapping [incident start, stop + 4P], and a period within the labelling tolerance of k·P for k ∈ 1..max_harmonic. An episode whose activation carries no periodic evidence can never be a true positive;
  • everything else is a false positive. Without -labels, every episode is one: that is the false-alarm arm, run on background-only traffic;
  • -bots <names> narrows which incidents the recall and the medians cover, so the non-blind sanity-check bot (the September shape, which anomaly: hold a periodic bot as one episode and detect when it stops (evaluate Cooldown, then train tracking) #130 and anomaly: evaluate Cooldown for periodic bot episodes (#130) #447 were tuned on) is reported apart from the blind panel without a hand computation afterwards. It never turns a matched episode into a false alarm, and a filter that selects no labelled incident is an error.

Metrics: episodes, true/false positives, false alarms per day, recall, median time to detect (also in units of P), median stop latency, median episodes per detected incident, per-incident detail, and false positives per channel hash.

Measurements from the new tests (not thresholds, not a validation)

On a synthetic 4-hour, 5-minute bot over a sparse synthetic background, with the #474 periodic parameters (min_pulses 8, burst_gap 15s, jitter_abs 10s, max_missing 2, tracking K = 3, quiet 60 min):

Bot shape (seed 21) Recall Time to detect Stop latency False positives
3 s jitter, clean 1/1 8 P (40 min) 15 min 0
3 s jitter, 5 % missed, 30 % multi-part 1/1 36.9 P (3 h 05) 15 min 0

The second row is the September-like shape and it matters for the pre-registration: strays and jitter near the tolerance delay re-acquisition, exactly as #130 observed (first alarm 1 h 50 on its noisy fixture). Time to detect is a property of the rule and the bot's shape, not of the scorer; it is logged, not pinned, by the test.

Tests, per requirement

Every point below has a test that was red before the change and green after (runInject / runScore stubbed out, and the detector-side mutants M1/M2 for phase 2), and at least one mutant:

Requirement Test Mutant(s) killed
Exclusion suppresses the rule on a listed hash TestPeriodicExcludedChannelProducesNoCandidate, TestPeriodicExclusionCoversEveryListedChannel, TestPeriodicExclusionAtChannelScope exclusion always false; inverted; only the first entry
Whole-hash match (no prefix match) TestPeriodicExclusionMatchesTheWholeHash compare bytes only, ignoring length
No key state for an excluded channel TestPeriodicExclusionKeepsNoKeyState pre-check without the exclusion
Periodic-only, other rules untouched TestPeriodicExclusionLeavesOtherRulesAlone exclusion applied to every rule kind
Config copied, caller's slice never shared TestPeriodicExclusionIsCopiedFromTheCallerConfig ExcludeChannels not cloned in New
"No channel" never excluded TestPeriodicExclusionNeverMatchesNoChannel zero entry accepted by validation
Validation: zero, duplicate, non-canonical, bound TestExcludeChannelsValidation each of the four checks removed
Replay JSON exclude_channels (opt-in, strict hex) TestReplayConfigParsesExcludeChannels, …WithoutExcludeChannelsExcludesNothing, …RejectsBadExcludeChannels field ignored; upper-case hex accepted
Golden unchanged with no exclusions existing TestReplayCommandMatchesTheLibraryGolden —
Injected events marked and unique TestInjectMarksAndMergesWithoutTouchingTheBackground marker prefix changed
Background copied byte for byte, output time-ordered same lines reformatted; injected appended instead of merged
Determinism from the spec TestInjectIsDeterministic seed from the wall clock
Per-bot independence (by name, not position) TestInjectBotsAreIndependent bot stream depends on its position
Pulse shapes: jitter clamp, no drift, missed, multi-part, holes TestInjectJitterIsClampedAndDoesNotDrift, TestInjectMissedPulses, TestInjectMultipartPulsesStayOnePulse clamp removed; jitter accumulates; first pulse missable; merge gap ignored; holes ignored
Labelling recipe (#457 §4) TestInjectLabelsIncidentsFromThePulses min_pulses off by one; median replaced by the maximum
Writes only its two files, never overwrites, bounded TestInjectWritesOnlyItsTwoFiles, TestInjectMaxInjectedIsEnforced -max-injected not enforced
Refuses pre-injected or unreadable background TestInjectRefusesPreInjectedBackground, …RefusesUnreadableBackgroundLine refusal removed; bad line skipped
Spec has no defaults and no unknown fields TestInjectSpecRejectsMissingAndUnknownFields, TestInjectRequiresExplicitFlags seed optional; unknown fields accepted; duplicate bot names accepted
Labels key-free and marked synthetic TestInjectLabelsAreKeyFreeAndMarkedSynthetic synthetic flag cleared; labels carry an injected id
Recall, time to detect, stop latency end to end TestScoreRecallOnAnInjectedBot, …OnANoisyInjectedBot first/last activation and end swapped
False-alarm arm without labels TestScoreFalseAlarmArmWithoutLabels, TestScoreFalsePositivesPerDay per hour instead of per day
Exclusion and injection together TestScoreExcludedChannelDetectsNothing (phase-2 mutants)
Matching: period, harmonics, overlap, channel, window, grouping TestScorePeriodCheck, TestScoreOverlapAndChannel, TestScoreWindowAndEpisodeGrouping, TestScoreOnlyTheNamedRule period check removed; harmonic off by one; no 4P tail; channel ignored; window ignored; no-period episodes counted; rule not filtered
Input validation and -out safety TestScoreRequiresExplicitFlagsAndValidInput, TestScoreWritesOutFileOnce report/labels validation removed; -out overwrites
-bots selects which incidents the bars cover TestScoreBotsFilter filter ignored; unselected match turned into a false alarm; recall still divided by every incident; empty selection accepted

51 of 51 mutants killed: 13 in the library, 20 in inject, 18 in score.

Out of scope / not done here

🤖 Generated with Claude Code

…ased evaluation

Relates to #474, phases 1-3. Library and offline tool only: no ingestor,
server, API or UI wiring, no defaults and no thresholds.

Phase 1 (target). doc.go and the tool README state the periodic rule's
target: an abusive channel bot, not expected automation. #457 round 4
measured wardriving trips, which is the opposite.

Phase 2 (detector). PeriodicRule.ExcludeChannels lists channel hashes a
periodic rule ignores; an excluded event never reaches the rule, so it
forms no pulse, chain or episode and claims no key state. Opt-in, no
defaults, validated (canonical, non-zero, no duplicates, bounded), matched
on the whole hash (length included), and exposed to the replay JSON as
"exclude_channels". 1-byte hash collisions are documented, not hidden.

Phase 3 (evaluation). Two new offline subcommands:

- inject overlays synthetic bot trains on exported background lines. Ids
  are "inj-<bot>-<pulse>-<part>", a value no export can produce; a
  background line already carrying that prefix is refused. File-only (no
  -db flag, no database), deterministic from the spec seed with an
  in-tool splitmix64, per-bot streams seeded by name, background copied
  through byte for byte, merged by data time. The labels file applies the
  #457 §4 recipe to the emitted pulses.
- score measures a report against those labels with the #457 §5 matching,
  or without labels, which is the false-alarm arm.

Nothing injected may ever be written into a database.
Relates to #474. `score -bots <names>` narrows the recall and the medians
to the named bots' incidents, so a non-blind sanity-check bot (the
September shape, which #130 and #447 were tuned on) can be reported apart
from the blind panel without a hand computation afterwards.

An episode that matches an unselected incident stays a true positive: the
filter must never turn a known match into a false alarm. A filter that
selects no labelled incident is an error, not an empty pass.
Relates to #474. The episode struct's id was written and never read, and
an unset -bots filter marshalled as null instead of an empty list.
No behaviour change.
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#478 #474 — head b7ee095

Status: Phases 1–3 of #474 implemented, 51 of 51 mutants killed, CI green except two pre-existing frontend E2E flakes (rerun once, detail below), and the round-1 pre-registration posted on #474 before any data exists — stopping there, as asked.

Evidence tags: [T] a test in this PR, [A] analysis or a reading of existing code and issues, [K] a command run locally (output quoted).

Requirements

# Requirement (#474) How it is met Test Mutant(s) killed
1 Target definition before any data: an abusive channel bot, wardriving is not a target internal/anomaly/doc.go "What the periodic rule is for (#474)"; cmd/anomaly/README.md "Excluded channels"; §1 of the pre-registration [A] docs only, no behaviour —
2 Detector can ignore expected-automation channel bytes, opt-in, no defaults PeriodicRule.ExcludeChannels; periodicApplies gates both the per-scope pre-check and the rule loop [T] TestPeriodicExcludedChannelProducesNoCandidate, …CoversEveryListedChannel, …AtChannelScope exclusion always false; inverted; only the first entry
2a An excluded channel claims no key state checked via Stats.Keys[ScopeStream] [T] TestPeriodicExclusionKeepsNoKeyState pre-check without the exclusion
2b Periodic only — rate, new-stream and other periodic rules untouched (keeps #473 free of collisions) the option lives on PeriodicRule alone [T] TestPeriodicExclusionLeavesOtherRulesAlone exclusion applied to every rule kind
2c Matching on the whole hash, 1-byte collisions documented exact ChannelHash compare (length + bytes); documented in the field comment, doc.go and the README [T] TestPeriodicExclusionMatchesTheWholeHash (incl. 81 vs 8100), …NeverMatchesNoChannel compare bytes only, ignoring length; zero entry accepted
2d Validated like every other field; config copied canonical, non-zero, no duplicates, ≤ 256 entries; New clones the slice [T] TestExcludeChannelsValidation, TestPeriodicExclusionIsCopiedFromTheCallerConfig each of the four checks removed; slice not cloned
2e Configurable list the owner can extend "exclude_channels": ["81"] in the replay JSON, optional, strict lower-case hex [T] TestReplayConfigParsesExcludeChannels, …WithoutExcludeChannelsExcludesNothing, …RejectsBadExcludeChannels field ignored; upper-case hex accepted
2f No defaults; the golden file unchanged no default anywhere; omitted field = nil [T] existing TestReplayCommandMatchesTheLibraryGolden and the library golden test, both unchanged —
3 Injected bots clearly synthetic ids are inj-<bot>-<pulse>-<part>; a background line already carrying that prefix is refused [T] TestInjectMarksAndMergesWithoutTouchingTheBackground (asserts the literal marker), TestInjectRefusesPreInjectedBackground marker prefix changed; refusal removed
3a Never written into any DB inject and score have no -db flag and open no database; inject writes exactly its two files, neither may exist, and nothing appears on failure [T] TestInjectWritesOnlyItsTwoFiles, TestInjectMaxInjectedIsEnforced, TestScoreWritesOutFileOnce [A] no database/sql import in either file -max-injected not enforced; -out overwrites
3b Deterministic, reproducible tooling in-tool splitmix64 (not math/rand); per-bot streams seeded from the seed and the bot's name [T] TestInjectIsDeterministic, TestInjectBotsAreIndependent seed from the wall clock; bot stream depends on its position
3c Realistic bot shapes (periods, jitter, missed and multi-part pulses, stationary hops, holes) spec fields with per-pulse clamped jitter (no drift) [T] TestInjectJitterIsClampedAndDoesNotDrift, TestInjectMissedPulses, TestInjectMultipartPulsesStayOnePulse clamp removed; jitter accumulates; first pulse missable; merge gap ignored; holes ignored
3d Ground truth by the #457 §4 recipe, not by intent -labels, computed from the emitted pulses: merge < 60 s, median P in [1 m, 1 h], runs of ≥ 8 pulses within max(30 s, 0.1 P) of k·P [T] TestInjectLabelsIncidentsFromThePulses (hole ⇒ 2 incidents, 7 pulses ⇒ 0) min_pulses off by one; median replaced by the maximum
3e Spec has no defaults and no unknown fields pointers for fields whose zero is meaningful; DisallowUnknownFields; duplicate names rejected [T] TestInjectSpecRejectsMissingAndUnknownFields, TestInjectRequiresExplicitFlags seed optional; unknown fields accepted; duplicate names accepted
3f Background untouched, output replay-ready lines copied byte for byte and in order, injected lines merged by data time; an unreadable line is an error [T] TestInjectMarksAndMergesWithoutTouchingTheBackground, TestInjectRefusesUnreadableBackgroundLine lines reformatted; injected appended instead of merged; bad line skipped
4 False alarms per day on fresh traffic score without -labels: every episode is a false alarm, divided by the days of E [T] TestScoreFalseAlarmArmWithoutLabels, TestScoreFalsePositivesPerDay per hour instead of per day
4a Recall on injected bots, with #457 §5 matching channel + overlap [start, stop + 4P] + period within max(30 s, 0.1 P) of k·P, k ∈ 1..3 [T] TestScoreRecallOnAnInjectedBot, …OnANoisyInjectedBot, TestScorePeriodCheck, TestScoreOverlapAndChannel, TestScoreWindowAndEpisodeGrouping, TestScoreOnlyTheNamedRule period check removed; harmonic off by one; no 4P tail; channel ignored; window ignored; no-period episodes counted; rule not filtered; first/last activation and end swapped
4b September bot as a non-blind sanity check score -bots selects which incidents the bars cover; an unselected match never becomes a false alarm [T] TestScoreBotsFilter filter ignored; unselected match turned into a false alarm; recall still divided by every incident; empty selection accepted
4c Fresh data only, from 2026-10-15 on §3 of the pre-registration: V is the first 7 whole UTC days at or after 2026-10-15T00:00Z, E = V, no calibration day [A] pre-registration —
5 Exports stay key-free and observer-free injected lines carry only replay-record wire fields; the labels file holds bot names, channel hashes, times, periods, counts [T] TestInjectLabelsAreKeyFreeAndMarkedSynthetic (asserts the absence of hops, routes, ids, observer and key strings) synthetic flag cleared; labels carry an injected id
6 No wiring into ingestor, server, API or UI; no staging or prod only internal/anomaly and cmd/anomaly touched, both their own modules, neither built into any image [K] git diff --stat origin/master -- cmd/server is empty; git diff --stat origin/master -- .github/ is empty —
7 Pre-registration posted before any data posted on #474 as issuecomment-6200066715, pinning head b7ee0952, the round1.json sha256 and the spec generator's sha256 [A] comment on #474 —

51 mutants, 51 killed: 13 in the library, 20 in inject, 18 in score (14 matching and validation, 4 for -bots). Each was applied to the working tree, the suite run, and the tree restored; three first attempts were dropped because they failed to compile rather than failing a test, and were replaced by compiling equivalents. Red before green: with runInject and runScore stubbed out to fail, 22 of the new tool tests fail and pass again once restored [K]; phase 2's behaviour is pinned by the mutants that neutralise excludes and periodicApplies, since the field itself cannot be absent and compile.

Local verification

  • [K] internal/anomaly: go vet ./... clean, go test ./... ok, go test -race ./... ok (9.9 s).
  • [K] cmd/anomaly: go vet ./... clean, go test ./... ok, go test -race ./... ok (3.3 s).
  • [K] sh test-all.sh: 254 passed, 0 failed (254 files).
  • [K] node test-frontend-helpers.js: 718 passed, 0 failed.
  • [K] sh scripts/check-xss-sinks.sh --diff origin/master: no public/**/*.{js,html} changes to scan.
  • [K] Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml; .github/ has no diff against master at all.
  • [K] cmd/server untouched (no diff), and no new map[string]interface{} in any changed Go file outside tests.
  • [A] No E2E is affected: nothing under public/, cmd/server or the ingestor changed, and both touched modules are compiled by no binary or image.

CI per job (run 38787561866, head b7ee095)

Job Result
Go Build & Test pass
Go Ingestor Tests pass
Go Libraries & CLI (internal/anomaly + cmd/anomaly, incl. -race) pass
Go Server Tests (1/3), (2/3), (3/3) pass, pass, pass
JavaScript Unit Tests & Lint pass
Script & Container Unit Checks pass
Playwright E2E Tests (1/3) pass on the rerun (first attempt: the flake below)
Playwright E2E Tests (2/3) pass
Playwright E2E Tests (3/3) pass on the rerun (first attempt: the flake below)
Verify E2E Shards & Coverage pass
Verify Go Test Assignments pass
Validate Docker Image pass
Build & Publish Docker Image pass
Publish Badges & Summary, Release Artifacts, Deploy Staging skipped (not a master run)

Run conclusion: success. The two E2E shards were rerun once, as the task allows for a known flake.

Pipeline check (synthetic only, no real data)

[K] The pre-registered panel injected into an empty background, replayed with round1.json and scored on the pinned head:

Arm Result
Blind panel (8 bots) recall 8/8, median time to detect 7.0 P, median stop latency 15 min 10 s, 1 episode per incident, 0 false positives
Per bot clean bots all alarm at exactly 7 P (the earliest min_pulses = 8 allows); the noisy long-period bots are slow: b6-300s-noisy 43 P, b7-900s-noisy 38 P
Sanity bot (non-blind) 2/2 incidents, 11 P and 25 P

That is a pipeline check, not a validation: it contains no real traffic. The real background has to meet the bars in the pre-registration (false alarms ≤ 0.2/day, recall ≥ 0.80, median time to detect ≤ 12 P, median stop latency ≤ 4 P, median episodes per detected incident ≤ 2).

Residues and open points

  1. Two pre-existing frontend E2E flakes, both in the first attempt of run 38787561866, both rerun once:

    • shard 1, test-issue-217-packets-full-names-e2e.js case B1 every +N pill sits inside its path cell: page.$$eval: TypeError: Cannot read properties of null (reading 'getBoundingClientRect'). The same case passed in the light-theme pass at the same width moments earlier, and the identical failure is on plain master in run 38769891046 (about an hour before this branch ran) [K]. A render-timing race in the test, not flaky: #180 packets URL E2E 'reload shows the same list' depends on fixture age (default 15-min window) #271, and not reachable from this diff.
    • shard 3, test-region-scope-approval-e2e.js: Error: stepping, database is locked (5) while the test seeds observer_neighbors with the sqlite3 CLI — a lock race in the test's own setup, before any assertion.
      Neither can be caused by this PR: nothing under public/, cmd/server or the ingestor changed, and the two touched modules are compiled into no binary. Both were rerun once; the result is in the table above. I filed no flake issue (the task allows no issue changes); the master-side evidence for the first one is run 38769891046.
  2. The noisy shapes detect late. On a synthetic background a 5-minute bot with 5 % missed and 30 % multi-part pulses alarms at 36.9 P in the PR's own test, and the panel's 300 s/900 s noisy bots at 43 P and 38 P. The cause is the one anomaly: hold a periodic bot as one episode and detect when it stops (evaluate Cooldown, then train tracking) #130 named: strays and jitter near the tolerance keep the chain from reaching 8 clean pulses, so re-acquisition takes hours. The 12 P bar applies to the median over the panel, so this may still pass; if it fails, that is a no-go on timing, not on detection. Not tuned here — tuning after seeing data would be a new round.

  3. Contamination risk in the match. anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 §5 keys a match on the channel hash only, so a real train on one of a0–a8 could be scored as a true positive for an injected incident. The pre-registration's mechanical fix is arm A: any arm-A episode on those hashes drops that bot from the arm-B bars, and every drop is reported. A channel-plus-first-hop match would remove the risk entirely but would change anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 §5, so it is a candidate for a follow-up issue, not for this round.

  4. score reads its report leniently. The report is this tool's own output, so unknown fields are ignored rather than rejected (DisallowUnknownFields would break on any future Candidate field). Instead the shape is validated: Experimental, Start/End, and every candidate's EventID, Rule, Kind, To, At and a parsable Key. A field renamed in the library would therefore read as "no episodes" rather than as an error; the per-rule episode count in the metrics is what catches that.

  5. Moving senders are still out of reach. The panel's bots are stationary by design, which is the target. anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 round 4 showed a moving sender splits across route groups; measuring that needs a different stream key and is a separate issue.

  6. No follow-up issues filed. Points 3 and 5 are the two that would deserve one; I filed none, as the task allows no issue changes.

Resolves the two textual conflicts with #477 (#473):

- internal/anomaly/detector.go: keep both periodicApplies (#474) and
  RateRule.counts (#473). periodicApplies now takes *Event, matching
  counts(e *Event), so the hot path no longer copies the Event per
  periodic rule per scope (review F7).
- cmd/anomaly/README.md: keep both optional-field paragraphs - the
  periodic rules' end_after_missed_pulses/exclude_channels list and the
  rate rules' exclude_ingest_backlog - and name all three issues in the
  intro.

#473's pinned behaviour is untouched: backlog.go, export.go, event.go
and all three #473 test files are byte-identical to origin/master, and
internal/anomaly/testdata/replay_golden.json is byte-identical too.

Relates to #474
Review F5. Five mutants survived the first round's suite:

- labelIncidents' run length >= min_pulses -> > (nine pulses required):
  no test had a run of exactly min_pulses.
- labelIncidents' gap tolerance <= -> <.
- labelIncidents' pulse merge gap >= -> >.
- score's periodFits tolerance <= -> <.
- score's episode end taking the first "ended" instead of the last.

Each now has a test that fails on the mutant and passes on the code:
exactly 8 pulses (and 7, which must not label), a gap exactly 30 s off
k*P (and one nanosecond further), events exactly 60 s apart (and one
nanosecond closer), a period exactly at the tolerance through both
periodFits and score, and an episode with two "ended" transitions.

Relates to #474
Review F6. Injected lines were trivially distinguishable from exported
ones in three ways that the detector does not read but a reader does:

- Times carried nanosecond precision, while the export times every line
  from a whole-second observation time (time.Unix(ts, 0)). Injected
  event times, and the final_at derived from them, are now truncated to
  whole seconds, and final_after must be whole seconds exactly as the
  export's -settle must.
- The payload size was constant. The new required spec field
  payload_bytes_spread draws each event's size uniformly from
  payload_bytes +/- spread; 0 keeps the old constant size, and a spread
  must be smaller than payload_bytes so a size can never reach zero.
- The README example used the first hop as the whole route; it now
  shows a real multi-hop route.

Truncation moves a time by less than a second towards the past, so the
effective offset from the grid is in [-(jitter_max + 1s), jitter_max];
that is documented beside jitter_sd.

Relates to #474
…ntially

Review F2 (blocking), F3 and F8.

F2. Round 1's panel sat on channel bytes a0-a8 and first hops
fdff00-fdff08, which no real transmission carries, so every injected
bot had its stream key to itself and arm B measured detection in
isolation rather than detection inside real traffic. Three changes make
a shared-key panel possible and scorable:

- New "keys" subcommand. It reads an export and ranks its stream keys
  by row count, reporting per key the wire fields a bot needs to land
  on it: the canonical key, the channel hash, the route class and kind,
  the first hop, the most common whole route and the median payload and
  wire sizes with a payload spread. The filter is mechanical and
  restated in the output (requested payload type, a channel hash
  present, route kind relay, channel not excluded) and every skipped
  line is counted. It is blind by construction: it reads the export
  only and counts rows, never runs the detector and reads no report, no
  labels and no metrics. -max-keys is a hard bound, as the export's
  -max-rows is.
- inject writes each bot's stream key into the labels, derived from the
  same Event the detector sees.
- score matches an episode to an incident on that key when the labels
  carry one (falling back to the channel hash when they do not), so a
  different route group on the same busy channel is no longer credited
  to the bot. New -baseline takes the report of the same window without
  the injection: an episode it already has on the same key, overlapping
  in time, is the background's - neither a true positive nor a false
  positive, counted as baseline_episodes. That is the differential
  reading a shared key needs.

F3. Five clean bots alarm at the earliest pulse the rule allows, so the
median sits on that floor and hides the realistic shapes. score now
reports the nearest-rank p75 and the maximum beside every median (time
to detect, also in units of P, stop latency, episodes per incident), so
the tail is visible without reading the per-incident table.

F8. The metrics recorded the local path of the labels file; they now
record its file name only, and the same for the new baseline report. A
path can carry a user or a host name and a metrics file may be shared.

Relates to #474
Review F2, F3 and F4.

- A "keys" section: what the subcommand is for (a bot on a key no real
  transmission carries has that key to itself), why it is blind (it
  reads the export and counts rows, never the detector or a report),
  the mechanical filter and every field it reports.
- The score section states that matching is on the stream key, what
  -baseline does and why a shared key needs a differential reading, and
  that the median hides the tail - so every median comes with its p75
  and its maximum, and -bots can give the realistic subset its own
  numbers.
- F4: the spec section now says to take the jitter from measurement,
  not from the rule's tolerance, with #130's measured values (|gap -
  300 s| median 3 s, p95 9 s over 85.2 h), the derivation of a 3 s
  per-pulse sd from them, and the consequence that some pulses of a
  short-period bot then fall outside a tight tolerance. Scaling the
  jitter to the rule makes recall conditional on the rule being
  evaluated and moves the panel whenever the rule moves.
- Provenance is file names only (F8).

Relates to #474
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#478 runde-2 — head 7f2f1a8

Review feedback addressed (commit 7f2f1a8d8884f48729ff0999d4612f860514904c).

Five commits on top of b7ee0952: one merge of origin/master (31dac2b0) and four ordinary commits. No rebase, no amend, no force-push. The PR stays a draft.

Because the head and the protocol both moved, a new, superseding pre-registration was posted on #474 before any data for its window exists: issuecomment-6202292768 (#474), posted 2026-10-15T21:07Z, so V starts 2026-10-16T00:00:00Z. Round 1's pre-registration (issuecomment-6200066715) is withdrawn in full and is not a pass of anything.

Evidence tags: [T] I ran it · [A] GitHub API or CI logs · [K] code reading only.

1. F1 (blocking) — merge with #477 (#473)

origin/master (31dac2b0) is merged in with an ordinary merge commit d8500d30. Both conflicts were textual and both sides are kept [T]:

#473's pinned behaviour is unchanged, byte for byte [T]:

Check Result
internal/anomaly/testdata/replay_golden.json vs origin/master sha256 3d6fbf08… on both — identical
cmd/anomaly/backlog.go, export.go, export_test.go, fixture_test.go, internal/anomaly/event.go byte-identical to origin/master (git diff --quiet)
All three #473 test files (internal/anomaly/backlog_473_test.go, cmd/anomaly/backlog_473_test.go, internal/anomaly/rate_test.go) byte-identical to origin/master
git diff origin/master removals in the five files #478 also edits 8 removed lines, all on the periodic path (appliesTo(d.cfg.Periodic…), the periodic replay fields, two comment lines). None touches counts, ExcludeIngestBacklog, the backlog export or event.go
#473-related tests 11 pass in internal/anomaly, 14 in cmd/anomaly

F7 resolved with it. periodicApplies now takes *Event, matching #473's counts(e *Event), and both call sites pass e instead of *e, so the 160-byte Event is no longer copied per periodic rule per scope [T]. No mutant applies: with one periodic rule the two forms are semantically identical and the compiler inlines the call, and an A/B of BenchmarkObserve/periodic=true (count=6, three stream counts) shows no difference beyond noise — 370.5–382.1 ns/op with the pointer against 370.7–374.5 ns/op with the value at 1,000 streams, identical at 10,000 and 100,000, 0 allocs/op throughout [T]. The change is structural alignment, not a measured win, and I report it as such.

2. F2 (blocking, protocol) — the panel now shares stream keys with real traffic

Agreed, and it was the right call: round 1's bots sat on channel bytes a0–a8 and 3-byte hops fdff00–fdff08, so each had its stream key to itself and arm B measured detection in isolation. Three changes, plus a new pre-registration.

a. New keys subcommand (cmd/anomaly/keys.go). It reads an export and ranks its stream keys by row count, reporting per key everything a spec needs to land on that key: the canonical key encoding, the channel hash, route class, route kind and first hop, the most common whole full_route (ties to the smallest encoding), the median wire size, and the median payload size with a payload_bytes_spread of (p90 − p10)/2. Percentiles are nearest-rank [K].

It is blind by construction: it reads the export and counts rows, never runs the detector, and reads no report, no labels and no metrics — so it cannot know which keys would alarm [K]. The filter is mechanical, is restated in its own output and counts every skip: payload type must match -payload-type, a channel hash must be present (the target is a channel bot), the route kind must be relay (only then is the first hop part of the stream key, so only then can a bot share it), and the channel must not be in -exclude-channels. -max-keys is a hard bound, as the export's -max-rows is: an over-run fails and writes nothing [T].

How the keys are chosen, mechanically and without seeing results (pre-registration §6a): panel position i takes the key of rank i+1, and nothing else is consulted. keys.json's sha256 is reported with the results so the assignment can be re-derived.

b. inject writes each bot's stream key into the labels, derived from the same Event the detector sees (botStreamKey) [K].

c. score matches on the stream key, and reads the shared arm differentially. An episode is the same series as an incident when their stream keys are equal (falling back to the channel hash for a labels file without keys), so another route group on the same busy channel is no longer credited to the bot. New -baseline <report-a.json> marks every arm-B episode that arm A already has on the same key, overlapping in time: it is neither a true positive nor a false positive but baseline_episodes. A detection in the shared arm counts only when it is absent from the plain arm [T].

d. The panel is now paired (pre-registration §5/§6). Each of the nine shapes appears twice at the same time on two different keys: -shared on the real key of rank i+1, and -iso on the same channel byte with hop 3:fdff0i, which is round 1's situation kept as a control. The headline result is the pair: the difference between them is the cost of key sharing, which round 1 could not measure. The shared arm is primary; the isolated arm keeps round 1's bars so it is comparable.

The pipeline check in §11 of the new pre-registration shows the effect on a synthetic Poisson background (busiest group 1 message per 10 min): shared keys give median time to detect 13.5 P, p75 16.0 P, max 94.0 P, up to 3 episodes per incident; the isolated twins give 7.0 P, 7.0 P, 35.0 P and up to 2. Recall was 8/8 in both arms on that background [T]. No bar was set from it.

3. F3 (protocol) — the median no longer stands alone

score now reports, beside every median, the nearest-rank p75 and the maximum: time to detect (also in units of P), stop latency, and episodes per detected incident [T]. The pre-registration adds a p75 bar on the shared arm (≤ 70 P) and a separate report with its own bar for the realistic subset b5/b6/b7-…-shared, whose recall, median, p75 and maximum are published in full (§9). The README states plainly that a median alone is not a result: a clean train alarms at the floor min_pulses allows, so five of eight shapes sit there by construction.

4. F4 (protocol) — jitter from #130's measurements, not from the rule

Agreed. #130 measured a real 5-minute bot over 85.2 h: |gap − 300 s| median 3 s, p95 9 s. A gap offset is the difference of two per-pulse offsets, so sd(gap) = √2 · sd(pulse); p95 9 s on a normal gap offset gives sd(gap) ≈ 9/1.96 ≈ 4.6 s and sd(pulse) ≈ 3.2 s. Every bot in the new panel therefore gets jitter_sd 3 s and jitter_max 9 s, for every period and independent of any rule value — no tol(P)/4, no clamp derived from the rule [K]. Pulses of the 90 s bots do now fall outside a tight tolerance, which is deliberate and is stated with the panel. The README's spec section carries the derivation and the reason, so a future spec cannot quietly go back to scaling the jitter to the rule.

5. F5 — the five surviving mutants now die

Each boundary the first round's suite left open has a test that is red on the mutant and green on the code [T]:

Mutant Test
labelIncidents run length >= min_pulses → > (nine pulses required) TestLabelIncidentsAtExactlyMinPulses — exactly 8 pulses labels, 7 does not
labelIncidents gap tolerance <= → < TestLabelIncidentsAtTheToleranceBoundary — a gap exactly 30 s off k·P stays in the run, one nanosecond further breaks it
pulse merge gap >= → > TestLabelIncidentsAtThePulseMergeGapBoundary — events exactly 60 s apart are two pulses, one nanosecond closer are one
periodFits tolerance <= → < TestScorePeriodFitsAtTheToleranceBoundary — asserted on the helper and through score (true positive, no period mismatch)
episode end takes the first ended TestScoreTakesTheLastEnded — two ended transitions, the end is the later one

6. F6 — injected lines now have the export's shape

  • Whole-second times. The export times every line from time.Unix(ts, 0) and takes a whole-second -settle; injected event times and the final_at derived from them are now truncated to whole seconds, and final_after must be whole seconds too. Truncation moves a time by less than a second towards the past, so the effective offset from the grid is in [-(jitter_max + 1 s), jitter_max] — documented beside jitter_sd [T][K].
  • Varying payload size. The new required spec field payload_bytes_spread draws each event's size uniformly from payload_bytes ± spread. 0 keeps the old constant size, and the spread must be smaller than payload_bytes so a size can never reach zero. In the new panel the spread comes from the chosen key's own (p90 − p10)/2 [T].
  • Realistic route. The README example used the first hop as the whole route; it is now a real multi-hop route, and the new panel takes the whole observed route of its key [K].

7. F8 — provenance is file names only

score records filepath.Base of the labels file and of the new baseline report, not the paths they were read from. Empty stays empty [T].

8. F9 — V starts after the new comment

The new pre-registration's V is the first 7 whole UTC days starting at or after 2026-10-16T00:00:00Z, the first whole UTC day that begins after the comment was posted. The "before any data" claim is now literal, not a statement of intent.

Tests

Go suites — all with go vet and -count=1 [T]:

Suite Result
internal/anomaly ok, also with -race
cmd/anomaly ok, also with -race
cmd/server ok
cmd/ingestor ok

Node [T]

  • sh test-all.sh: 255 passed, 0 failed (255 files).
  • node test-frontend-helpers.js: 719 passed, 0 failed.

E2E [T]

This PR changes no file outside cmd/anomaly; the merge brings in master's public/nodes.js, public/style.css and two test files, so those are the suites I ran. A local Go server on port 13810 served a scratch copy of e2e-fixture.db, prepared exactly as CI does (freshen, the Kpa-clawbot#1486 and Kpa-clawbot#1791 seed SQL, corescope-migrate, then the 2073, 199, 245 and 122 seeds) with a .git-commit in the tree. The committed fixture was left untouched. The server was stopped by pid and the port confirmed free afterwards.

Suite Result
test-issue-217-packets-full-names-e2e.js (master's #471 fix) 50/50
test-458-scope-name-case.js (master's #458 fix) 7/7
test-e2e-playwright.js (core) 154/157, 3 skipped

New tests (17, in cmd/anomaly/review_r2_test.go) [T]: the five F5 boundary tests above, four for F6 (TestInjectTimesAreWholeSeconds, TestInjectPayloadSizeVariesWithinTheSpread, TestInjectPayloadSpreadIsValidated, TestInjectFinalAfterIsWholeSeconds), three for keys (TestKeysRanksBackgroundStreamKeysByRowCount, TestKeysFilterIsMechanicalAndCounted, TestKeysMaxKeysIsAHardBound), three for the key matching and the differential arm (TestScoreMatchesOnTheStreamKeyNotTheChannel, TestScoreBaselineDropsEpisodesPresentWithoutInjection, TestScoreBaselineFlagReadsAReportFile), one for F3 (TestScoreReportsTheTailBesideTheMedian) and one for F8 (TestScoreProvenanceIsFileNamesOnly).

One existing test changed: TestInjectLabelsAreKeyFreeAndMarkedSynthetic no longer bans the first hop from the labels file, because the stream key now carries it, and it asserts the key is present instead. What stays banned is the whole route, ids, observers, raw frames and public keys. That is a real widening of the labels file and it is stated in the pre-registration (§7, §12): the key holds the payload type, the channel hash, the route class and kind and the first hop — fields the export itself carries — and the file still stays with the coordinator.

Mutants

22 mutants, one or more per finding, all killed [T]. Each was applied to the pushed tree, the named tests run, and the tree restored.

# Mutant Finding Killed by
M1 labelIncidents >= min_pulses → > F5 TestLabelIncidents*
M2 labelIncidents d <= tol → < F5 TestLabelIncidents*
M3 pulse merge >= → > F5 TestLabelIncidents*
M4 periodFits d <= tol → < F5 TestScorePeriodFitsAt…
M5 episodes keeps the first ended F5 TestScoreTakesTheLastEnded
M6 no time truncation F6 TestInjectTimesAreWholeSeconds
M7 payload spread ignored F6 TestInjectPayloadSizeVaries…
M8 payload_bytes_spread no longer required F6 TestInjectPayloadSpreadIsValidated
M9 fractional final_after accepted F6 TestInjectFinalAfterIsWholeSeconds
M10 sameSeries compares channels, not keys F2 TestScoreMatchesOnTheStreamKey…
M11 markBaseline is a no-op F2 TestScoreBaseline*
M12 baseline ignores the key F2 TestScoreBaseline*
M13 baseline ignores the time overlap F2 TestScoreBaseline*
M14 a baseline episode is still a true positive F2 TestScoreBaseline*
M15 keys ranks ascending F2 TestKeysRanks…
M16 keys ignores -exclude-channels F2 TestKeysFilter…, TestKeysRanks…
M17 keys keeps non-relay lines F2 TestKeysFilter…
M18 -max-keys truncates instead of failing F2 TestKeysMaxKeysIsAHardBound
M19 keys reports the first hop as the whole route F2 TestKeysRanks…
M20 p75 time to detect is the median F3 TestScoreReportsTheTail…
M21 max time to detect in P is the median F3 TestScoreReportsTheTail…
M22 labels provenance keeps the whole path F8 TestScoreProvenance…

No mutant is reported for F1 or F7: F1's resolution is textual and is evidenced by the byte-identity table above, and F7's pointer-versus-value change is semantically identical, so no mutant of it can fail a test. M8's kill is through a nil dereference in the following validation case rather than a clean assertion failure; I report that rather than present it as a clean kill.

Determinism and offline properties re-checked [T]

  • keys twice on the same input: byte-identical output. inject twice with the same spec, seed and background: byte-identical inj.jsonl and labels.json.
  • keys, inject and score import no database driver and take no -db flag; keys writes nothing unless the whole run succeeds, and -out must not exist.
  • No file outside cmd/anomaly changed in my four commits, and nothing in cmd/server, cmd/ingestor or public imports the anomaly module.
  • scripts/check-xss-sinks.sh --diff origin/master: "no public/**/*.{js,html} changes to scan", rc 0.
  • No closing keywords in the commits; all five are authored and committed by dborup <kontakt@meshview.dk>.
  • Nothing touched staging or production, and no API key was used.

CI per job

Run 38803855328 (7f2f1a8d), green on attempt 2 [A].

Job Attempt 1 Attempt 2
✅ Go Libraries & CLI success success
✅ Go Server Tests (1/3) success success
✅ Go Server Tests (2/3) success success
✅ Go Server Tests (3/3) failure success
✅ Go Ingestor Tests success success
✅ JavaScript Unit Tests & Lint success success
✅ Script & Container Unit Checks success success
🎭 Playwright E2E Tests (1/3) success success
🎭 Playwright E2E Tests (2/3) success success
🎭 Playwright E2E Tests (3/3) success success
📊 Verify E2E Shards & Coverage success success
📋 Verify Go Test Assignments success success
🧱 Validate Docker Image success success
✅ Go Build & Test failure (gate) success
🏗️ Build & Publish Docker Image failure (downstream) success
📦 Release Artifacts, 🚀 Deploy Staging, 📝 Publish Badges skipped skipped

All E2E shards were green on attempt 1 as well. The one real failure was in cmd/server, which this PR does not touch, and I reran the failed jobs once; attempt 2 is fully green.

The failure, named and explained. TestRoutePatterns410_HiddenHopWithTrafficLooksUnknown/observer_blacklist in cmd/server/subpaths_visibility_410_test.go [A]. It is neither #271 nor #301, so I did not treat it as one of the known flakes — I found the mechanism and reproduced it [T].

The test compares two whole JSON bodies, one for a hidden hop and one for an unknown hop, and expects them to be identical once the hop byte is normalised. The two sets of rows are seeded from two separate time.Now() calls: seedRoutePatternsDB (line 45) seeds the bb paths and rpAddTraffic (line 722) seeds the mirrored f0 paths, each taking its own time.Now().UTC().Add(-time.Hour). When the two calls straddle a second boundary, firstSeen and lastSeen differ by one second and the comparison fails. CI's failure shows exactly that: 19:49:05Z against 19:49:06Z [A].

I reproduced it locally on this tree: green at -count=20, red at -count=120 -race and again at -count=300, with the same one-second split (19:55:09Z against 19:55:10Z) [T]. The test file is byte-identical to origin/master and cmd/server is untouched by this PR (git diff --name-only origin/master HEAD -- cmd/server is empty), so it is pre-existing and unrelated. I found no tracking issue for it and have not filed one; the fix is a single shared time.Now() for both seeds, which belongs in its own PR.

Not verified

  • No real data: no export, no staging, no production. Every number above comes from unit tests or from the synthetic pipeline check, and the real key-sharing rate is unknown.
  • The §8 -baseline path is not exercised by the synthetic pipeline check, because arm A produced no episode at all on that background. It is covered by three unit tests.
  • Round 1's own 51 mutants were not re-run; the 22 above were.
  • I did not check that any published macOS/arm64 binary sha256 matches a Linux build.

Round-3 review of PR #478 (#474), tests first.

G1: the pre-registered pipeline could not be injected on a realistic key.
make_spec.py held wire_bytes at the key's median while drawing the payload
from median +/- (p90 - p10)/2, and Event.Validate refuses
PayloadBytes > WireBytes - 2, so the first wide draw aborted the run.
TestInjectSpreadEnvelopeMustFitTheFrame pins the whole draw envelope against
the frame and against MaxPayloadBytes; the two pipeline tests run
export -> keys -> make_spec.py -> inject on a block-quantised background and
on the package's own synthetic CoreScope fixture.

G2: injected sizes were still distinguishable. The keys ranking now has to
carry the key's observed (wire, payload) pairs, and inject to draw whole
pairs from them.

G3: a timing bar over the detected incidents cannot fail on a missed bot.
TestScoreSelectedTailCountsAMissAsInfinite shows the panel that passes
every round-2 timing bar with four of eight realistic shapes never found,
and requires the selected tail that fails it.

G4: one test per surviving mutant - the keys tie-break, -top, the
nearest-rank percentile boundary, the spread clamp at equality, first/last,
truncation towards the past, and both touching ends of the baseline overlap.

G5: baseline masking has to be visible per incident.

The two round-2 payload-spread cases move with the envelope rule: a spread
one below payload_bytes is no longer enough on its own.
G1/G2 of the round-3 review of PR #478 (#474).

A frame is header plus path plus payload, so wire - payload is the route's
own overhead, while a GRP_TXT payload comes in 16-byte cipher blocks. Round 2
described a bot's sizes as a fixed median wire plus a payload spread, which
gets both halves wrong: the drawn payload routinely passed wire - 2 and
Event.Validate aborted the run part way through (G1), and where it did fit,
the sizes were ones the key never carried, so an injected line was
distinguishable on size alone (G2).

keys now reports size_mix per key: every observed (wire_bytes,
payload_bytes) pair with its count, ordered by wire then payload, with the
payload absent for the lines whose length the export did not know. The
distinct pairs are bounded like the other per-key histograms and the overflow
is reported.

inject takes size_mix as an alternative to the scalar sizes and draws one
whole pair per event, weighted by count - a binary search over cumulative
weights computed once per bot. A single-entry mix consumes no randomness, as
a zero spread does, so an existing train's draws do not move.

The scalar path keeps working for hand-written specs, but now has to fit:
payload_bytes + payload_bytes_spread <= wire_bytes - 2. And every size a bot
can draw is checked against the event validator while the spec is read, so an
impossible size is a spec error with a message naming the fields, never an
abort thousands of lines into a run.
G3 and G5 of the round-3 review of PR #478 (#474).

G3. Every median and p75 was taken over the detected incidents only, so no
timing bar on them could fail on a bot that was never found: a panel whose
four cleanest shapes alarm at the floor and whose four realistic ones are
missed reports recall 0.50 and a p75 of one period, and passes the lot. The
time to detect is now also reported over every selected incident with a miss
ranked above every finite value. A percentile whose rank falls among the
misses is infinite; JSON has no infinity, so it is reported as
..._selected_infinite with the value left out, which fails any bar placed on
it. incidents_missed counts the misses.

G5. A -baseline episode is not scored either way, so an incident whose only
episode was absorbed by the background read as a plain miss. Each
per-incident row now carries baseline_episodes and baseline_masked, and
incidents_baseline_masked counts them, so a no-go caused by background
contamination can be told from a detector miss.
…anges

make_spec.py moves into the repository. Round 2 kept it only in the
pre-registration comment, which is why nothing exercised the one step that
was broken: the chain export -> keys -> make_spec.py -> inject on realistic
sizes. It is stdlib-only, reads only a window start and a keys ranking, has
no clock, and now passes each key's size_mix through verbatim instead of
describing the sizes as a median and a spread.

The README documents size_mix on both sides, the envelope rule for the
scalar sizes, the selected-incident tail with a miss ranked as infinite, and
the per-incident baseline flag.
@adminopenclaw8-sketch

Copy link
Copy Markdown
Collaborator Author

Rapport — CS-Macmini PR#478 runde-3 — head 743d0f3

Review feedback addressed (commit 743d0f33). Round 3 answers the re-review "Review — CS-MacBook PR#478" on head 7f2f1a8d (REQUEST CHANGES, G1–G6). origin/master (412f0dbc) is merged in with a merge commit; four ordinary commits follow, no force-push, no rebase, no amend.

Evidence tags: [T] I ran it myself · [A] GitHub API or CI logs · [K] code reading only.

1. G1 (blocking) — inject aborted on realistic keys. Fixed.

Reproduced first [T]. I extracted round 2's own make_spec.py from issuecomment-6202292768; it reproduces its published sha256 0f718b5c…. On a synthetic background with GRP_TXT-shaped sizes (payload in 16-byte cipher blocks, wire = payload + 2 + path width) the rank-1 key is wire_bytes_median 38 with payload_bytes_median 35 ± 24, so a draw reaches 59 where the frame allows 36. Round 2's spec is refused and the pipeline stops, exactly as the review says.

The fix uses sizes that cannot fail the validator, because they are real sizes. The two sizes are not independent — a frame is header plus path plus payload, so wire − payload is the route's own overhead while the payload comes in cipher blocks — so neither can be drawn on its own:

  • keys now reports size_mix per key: every observed (wire_bytes, payload_bytes) pair with the number of lines that carried it, ordered by wire then payload, payload_bytes absent for the lines whose length the export did not know. The distinct pairs are bounded like the other per-key histograms (2048) and the overflow is counted.
  • inject takes size_mix as an alternative to the scalar sizes and draws one whole pair per event, weighted by count (binary search over cumulative weights computed once per bot). Every pair came off a real line, so it is valid by construction.
  • make_spec.py passes the key's size_mix through verbatim and aborts on a rank with no observed pair, rather than falling back to an invented size.
  • Every size a bot can draw is now checked against the event validator while the spec is read, before a single event, line or file exists — for size_mix each pair, for the scalar path both ends of the draw envelope. An impossible size is a spec error naming the fields, never an abort thousands of lines into a run [K].
  • The scalar path additionally requires the whole envelope to fit: payload_bytes + payload_bytes_spread ≤ wire_bytes − 2. Round 2 checked the spread against payload_bytes only.

The end-to-end test the review asked for, twice [T]:

  • TestPipelineKeysSpecInjectOnBlockQuantisedSizes — keys → make_spec.py → inject on a 10-key background with 16-byte payload steps. Asserts rc 0, every injected line valid as an event, and that no injected (wire, payload) pair is one the background never carried.
  • TestPipelineExportKeysSpecInjectOnTheFixture — the whole chain from a database: export → keys → make_spec.py → inject on the package's own synthetic CoreScope fixture (newFixtureDB), with real grpPayload 3 + 16k sizes over a one-hop path. It asserts up front that the fixture really has the structure that broke round 2 (payload_bytes_spread > wire_median − 2 − payload_median), so the test cannot quietly stop covering G1.

make_spec.py is now a file in the repository (cmd/anomaly/make_spec.py, sha256 10a306b0…). Round 2 kept it only inside the pre-registration comment, which is precisely why nothing exercised the one step that was broken. Both pipeline tests run it through python3 and skip if python3 is absent; GitHub's ubuntu-latest has it, and both ran green in CI [A][T].

2. G2 — injected sizes were distinguishable. Fixed by the same change.

Drawing whole observed pairs closes it rather than narrowing it: the set of sizes an injected line can carry is a subset of the sizes that key's real lines carry, and wire − payload is tied to the route as it is on a real line. TestInjectSizeMixDrawsOnlyObservedPairs asserts every injected pair is one of the four configured ones, that all four are drawn, and that the draw follows the configured weights [T]. The pipeline test asserts the same against the whole background's pair set [T].

The §5 claim "the only way to tell them apart is the id marker" now holds for sizes. I did not re-audit every other field for distinguishability; times, route and field set were covered in round 2.

3. G3 (protocol) — timing bars could not fail on a missed bot. Fixed, and documented in the new pre-registration.

The review's own framing is right: with the medians and p75s taken over the detected incidents only, a panel whose four cleanest shapes alarm at the floor and whose four realistic ones are never found reports recall 0.50 and a p75 near one period, and passes every round-2 timing bar.

score now reports the time to detect twice:

  • the detected tail (unchanged) — it describes the shapes that were found, and it carries no primary bar any more, because it cannot fail on a miss;
  • the selected tail — the nearest-rank percentile over every selected incident, with a missed incident ranked above every finite value. JSON has no infinity, so a percentile whose rank lands among the misses is reported as median_time_to_detect_selected_infinite / p75_time_to_detect_selected_infinite with the value omitted, which fails any bar on it. incidents_missed counts the misses.

TestScoreSelectedTailCountsAMissAsInfinite builds exactly the review's panel — four detected at 1 P, four missed — and asserts both halves: the detected-only p75 is 1.0 P (the statistic that cannot fail), while the selected p75 is infinite and the selected median is still finite at 1 P. It also checks that a complete panel's selected tail equals the detected tail, and that a miss outside the -bots selection does not enter the statistic [T].

The protocol choice is documented in the superseding pre-registration (issuecomment-6203588711, §8 and §9). I took both of the review's options, and §9 states why each bar sits where it does:

  • The primary B-shared median bar moves to the median over the selected incidents (a miss = +∞), ≤ 24 P. With 8 selected incidents the nearest-rank median is the 4th value, so it is finite exactly at recall ≥ 0.50 — the same threshold as the recall bar, so the two are consistent and neither implies the other.
  • The realistic subset's recall bar rises from 1 of 3 to 2 of 3 (recall ≥ 0.50, the same measured chatter point the primary recall bar comes from), plus a median-over-selected bar of ≤ 70 P, which on three incidents is also finite exactly at 2 of 3.
  • I deliberately did not put a bar on the p75-over-selected: with 8 incidents that is the 6th value, so a bar on it would silently require recall ≥ 0.75, which no prior measurement supports. It is reported in full instead. §9 says this in those terms rather than leaving it implicit.

4. G4 — the eight surviving mutants. All eight now die.

One test per mutant, each written red-first (see Mutants). The tie-break mattered most, as the review said: it is presented in §6a as part of the mechanical, re-derivable assignment, and nothing pinned it.

Mutant Killed by
N3 keys tie-break reversed TestKeysTieBreakIsTheSmallerCanonicalKey
N11 -top keeps top − 1 TestKeysTopReportsExactlyTopKeys
N6 histPercentile seen > idx → >= TestKeysPercentilesAreNearestRank
N4 spread clamp >= → > TestKeysSpreadIsClampedStrictlyBelowTheMedian
N7 first uses After TestKeysFirstAndLastAreTheExtremeTimes
N8 Truncate → Round TestInjectTruncatesTowardsThePast
N1 markBaseline touching end excluded TestScoreBaselineOverlapIsClosedAtBothEnds
N2 markBaseline touching start excluded TestScoreBaselineOverlapIsClosedAtBothEnds

5. G5 — baseline masking was invisible per incident. Fixed.

Each per-incident row now carries baseline_episodes (the episodes on that incident's own series, overlapping its window, that the plain arm already had) and baseline_masked (not detected, but the background already had an episode here); incidents_baseline_masked counts them. The period check is deliberately not applied to them, because it is not applied to a baseline episode anywhere — that is stated in the field's own comment [K].

TestScoreReportsBaselineMaskingPerIncident pins both the masked incident and a plain miss beside it, so the flag cannot degenerate into "not detected" [T]. And §11 of the new pre-registration demonstrates it end to end, which round 2 could not do (its arm A had no episode at all): with one real 5-minute train added on rank 1's own key, arm A has 1 episode, and in arm B the same run reads recall 6/8 without -baseline and 5/8 with it, baseline_episodes 1, incidents_baseline_masked 1, and b0-90s-clean-shared carries baseline_masked: true instead of reading as a plain miss [T].

6. G6 — CI flake outside this PR. Not fixed here, as instructed.

Agreed and left alone: TestRoutePatterns410_HiddenHopWithTrafficLooksUnknown/observer_blacklist, two separate time.Now() seeds, no cmd/server diff in this PR. It did not recur in this round's run. I filed no issue.

The superseding pre-registration

Posted as issuecomment-6203588711 on #474 at 2026-10-15T22:28:57Z, updated_at = created_at (unedited) [A]. It says "No validation data has been seen", replaces round 2 in full, pins head 743d0f33…, pins make_spec.py's sha256 and a -trimpath binary hash with its go version (round 2's bare-build hash did not reproduce for the reviewer), and starts V at 2026-10-17T00:00:00Z — after the comment, so no part of V existed when it was posted.

One imprecision in it, which I am flagging rather than editing. Its §3 glosses V's start as "the first whole UTC day that begins after this comment was posted". GitHub stamped the comment 2026-10-15T22:28Z, so the first such day is 2026-10-16, not 2026-10-17 — I drafted against a local date of 2026-10-16. The fixed value is unchanged and is the binding one: V starts at or after 2026-10-17T00:00:00Z, which is strictly later than required, so the property the gloss exists to guarantee holds with a full extra day of margin. No window, panel, labelling, metric or bar moves, so this is not a round 4. I recorded it as a no-change note, issuecomment-6203595309 [A].

Tests

New tests: 19, all in cmd/anomaly/review_r3_test.go. Red before green: the file does not compile against the round-2 tree (undefined: sizePair, scoreResult has no field IncidentsMissed), and each behavioural test was then checked against its own mutant below [T].

Two round-2 tests moved with the envelope rule [T]: TestInjectPayloadSpreadIsValidated gains a "wider than the frame allows" case, and its closing positive case changes from spread 34 (which with wire_bytes 60 can reach 69 > 58) to spread 23, whose top is exactly wire_bytes − 2. The old assertion encoded the bug.

Go suites — go vet + -count=1, the anomaly modules also with -race [T]

Suite Result
cmd/anomaly ok; -race ok (7.7 s)
internal/anomaly ok; -race ok (10.1 s)
cmd/server ok (54.5 s)
cmd/ingestor ok (107.5 s)

Node [T]: sh test-all.sh 257 passed, 0 failed. node test-frontend-helpers.js 719 passed, 0 failed.

E2E [T]. Server on port 13841 with the fixture prepared as in CI (freshen, the inline Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL, corescope-migrate, then seeds 2073, 199, 245, 122), stopped afterwards and the port confirmed free; the fixture was restored with git checkout and the build artefacts removed. This PR has no E2E of its own and touches no served code, so I ran the core suite plus master's two newest UI suites:

Suite Result
test-e2e-playwright.js 154/157, 3 skipped
test-issue-480-no-scope-e2e.js 25/25
test-issue-479-airtime-e2e.js all passed

Mutants (18, all killed) [T]

Each mutant was applied to the pushed tree, the named tests run, and the tree restored; the sweep script re-verifies the restore.

# Mutant Finding Killed by
G1-a the scalar envelope check is disabled G1 TestInjectSpreadEnvelopeMustFitTheFrame
G1-b botStreamKey's error is dropped at spec parse G1 TestInjectSizeMixIsValidatedBeforeAnythingRuns
G1-c only the median payload is validated, not the envelope G1 TestInjectSpreadEnvelopeMustFitTheFrame
G2-a keys records the payload as unknown in every pair G2 TestKeysReportsTheObservedSizeDistribution, TestPipelineKeysSpecInjectOnBlockQuantisedSizes
G2-b the size draw always takes the first pair G2 TestInjectSizeMixDrawsOnlyObservedPairs
G2-c size_mix orders payloads descending inside one wire size G2 TestKeysReportsTheObservedSizeDistribution
G3-a an infinite selected percentile clamps to the slowest detection G3 TestScoreSelectedTailCountsAMissAsInfinite
G3-b the selected percentile ignores the misses in its rank G3 TestScoreSelectedTailCountsAMissAsInfinite
G5-a baseline_masked is never set G5 TestScoreReportsBaselineMaskingPerIncident
G5-b per-incident baseline episodes are not counted G5 TestScoreReportsBaselineMaskingPerIncident
N1 markBaseline touching end excluded G4 TestScoreBaselineOverlapIsClosedAtBothEnds
N2 markBaseline touching start excluded G4 TestScoreBaselineOverlapIsClosedAtBothEnds
N3 keys tie-break reversed G4 TestKeysTieBreakIsTheSmallerCanonicalKey
N4 spread clamp >= → > G4 TestKeysSpreadIsClampedStrictlyBelowTheMedian
N6 histPercentile seen > idx → >= G4 TestKeysPercentilesAreNearestRank
N7 keys first uses After G4 TestKeysFirstAndLastAreTheExtremeTimes
N8 Truncate → Round G4 TestInjectTruncatesTowardsThePast
N11 -top keeps top − 1 G4 TestKeysTopReportsExactlyTopKeys

Three of these (G1-a, G1-c, G2-c) survived my first pass, and I report that rather than only the final state. G1-a and G1-c survived because the two size guards are mutually redundant for the spread envelope; I did not delete one — the explicit check carries the diagnostic that round 2 lacked, and the validator check catches bounds the explicit one does not. So the tests were strengthened instead: the envelope error must name payload_bytes + payload_bytes_spread (which only the explicit check produces), and a payload of 180 ± 10 in a 255-byte frame fits wire − 2 but passes MaxPayloadBytes 184 (which only the validator check catches). G2-c survived because every pair in the first fixture had a distinct wire size, so the payload tie-break never decided anything; the fixture now has three pairs sharing one wire size.

§11 pipeline check (synthetic background, no real data) [T]

Reported in full in the pre-registration. 4,945 background lines over 12 route groups and 7 days, GRP_TXT-shaped sizes, the excluded byte 81 the busiest of all; 11 keys after the exclusion; 4,434 injected events, 18 bots, 20 incidents; all four score runs.

Run recall TTD detected med / p75 / max TTD selected med / p75 median stop eps/incident med / max
A (false alarms) – – – – 0 episodes, 0 false alarms
B-shared (8 blind) 6/8 = 0.75 7.5 P / 12.0 P / 14.0 P 8.0 P / 14.0 P 10 m 41 s 1 / 3
B-shared realistic (b5/b6/b7) 3/3 8.0 P / 12.0 P / 12.0 P 8.0 P / 12.0 P 15 m 12 s 1 / 3
B-iso (8 blind, control) 8/8 7.0 P / 7.0 P / 115.0 P 7.0 P / 7.0 P 15 m 10 s 1 / 1

The two shared misses are the two longest clean periods (b3-600s, b4-900s): key sharing costs recall on the slow shapes while every isolated twin is found. None of the nine isolated keys appears in the full ranking, so §10's absence check passes with 0 contaminated. No bar in §9 is set from any of this.

Determinism and offline properties re-checked [T]

  • keys twice on the same input: byte-identical. inject twice with the same spec, seed and background: byte-identical inj.jsonl and labels.json (covered by TestInjectIsDeterministic, still green).
  • A single-entry size_mix consumes no randomness, as a zero spread does, so adding it to a bot does not move any other draw of the train [K].
  • keys, inject and score import no database driver and take no -db flag; make_spec.py is stdlib-only with no clock and no network.
  • No file outside cmd/anomaly differs from origin/master in my four commits; git diff --name-only origin/master...HEAD excluding cmd/anomaly/ and internal/anomaly/ is empty, so public/, cmd/server, cmd/ingestor and test-e2e-playwright.js are byte-identical to master.
  • All four commits are authored and committed by dborup <kontakt@meshview.dk>; no closing keywords; no force-push.
  • Nothing touched staging or production, and no API key was used.

CI per job

Run 38816399459 (743d0f33), green in every job on attempt 2 [A].

Job Attempt 1 Attempt 2
✅ Go Libraries & CLI success —
✅ Go Server Tests (1/3), (2/3), (3/3) success —
✅ Go Ingestor Tests success —
✅ JavaScript Unit Tests & Lint success —
✅ Script & Container Unit Checks success —
🎭 Playwright E2E Tests (1/3) success —
🎭 Playwright E2E Tests (2/3) failure success
🎭 Playwright E2E Tests (3/3) success —
📊 Verify E2E Shards & Coverage failure (downstream) success
📋 Verify Go Test Assignments success —
🧱 Validate Docker Image success —
✅ Go Build & Test success —
🏗️ Build & Publish Docker Image failure (downstream) success
📦 Release Artifacts, 🚀 Deploy Staging, 📝 Publish Badges skipped skipped

The one failure, named and checked rather than assumed. #382 node stats table rows follow the rounded corners in dark theme in test-e2e-playwright.js, asserting Table must not clip the help tooltip (overflow ) — note the empty computed overflow, which is a style not yet applied rather than a wrong style [A]. It is not #271 and not #301, so I did not treat it as one of the known flakes. It is nonetheless unrelated to this PR, on three independent grounds [T]:

  • test-e2e-playwright.js, public/ and cmd/server are byte-identical to origin/master on this head — git diff --stat origin/master...HEAD -- public/ test-e2e-playwright.js cmd/server/ is empty.
  • This PR's whole diff is cmd/anomaly and internal/anomaly, neither of which is imported by any served code.
  • The same test, both themes, passes locally against a fixture-backed server on port 13841 on this exact tree.

The light-theme twin of the same test passed in the same CI run, which is the usual signature of a per-context style-application race rather than a CSS fault. I reran the failed job once, as instructed, and noted it here. I did not find a tracking issue for it and filed none; the fix (waiting for the stylesheet before reading computed style) belongs in its own PR.

Not verified

  • No real data. No export, no snapshot, no staging, no production. Every number above comes from unit tests or from the synthetic §11 pipeline, so the real key-sharing rate, the real size distributions and the real false-alarm rate are all still unknown. G1's original failure on real V keys stays an inference from the protocol's 16-byte cipher blocks, now reproduced on two synthetic backgrounds and on the package's fixture, not on V itself.
  • The round-2 reviewer's 16 mutants were not re-run individually, nor round 2's own 22; the 18 above were.
  • Only three E2E suites ran locally, not the full CI shard set.
  • The -trimpath binary hash in §2 is from go1.27.0 darwin/arm64; I did not check that a Linux or CI build reproduces it, only that the build is now pinned to -trimpath so it can be.
  • size_mix is bounded at 2048 pairs per key and 4096 per bot. I did not test a key that overflows the bound on real data, only that the overflow is counted.
  • I did not re-audit the non-size fields of an injected line for distinguishability; that was round 2's work.

@dborup
dborup marked this pull request as ready for review October 15, 2026 23:13
@dborup
dborup merged commit 429d451 into master Oct 15, 2026
33 of 36 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.

2 participants