Repository navigation
feat(anomaly): experimental offline traffic-anomaly detector library (inactive) - #94
Conversation
Adds internal/anomaly, an encryption-independent anomaly detector for normalized MeshCore transmissions. It is inactive: nothing in CoreScope imports it, and there is no ingestor, server, API, UI, database, config or notification wiring. It has no default thresholds; New rejects the zero config. - Normalizer: observations -> final Events with an explicit settle policy; late observations are rejected, and capacity cuts mark the event Truncated. - Stream keys: channel + route class + first hop with its width. Route groups use flood relays only; the full route is separate evidence. - Detector (Observe/Advance/Flush/Snapshot, data time only): dedup with an explicit horizon, a reorder buffer, and multi-scale rate windows at stream, channel, route-group and global scope. - Also: new-stream volume with left censoring, and causal periodicity with harmonic handling and a union-bound Poisson null. - An episode state machine with cooldown, stable IDs and reason codes. - Every piece of state is capped by Limits, and coverage loss is reported. DecidedAt comes from a monotone decision clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replay runs one Detector over normalized events from a neutral JSON Lines format with an explicit [Start, End) range and returns a deterministic report. It uses no database, no network and no wall clock, and there is no command-line tool: TestOfflineReplay runs it from go test with local files only. - Line length is bounded exactly. - Unknown fields and trailing data are rejected. - It drains after every call, so the harness itself never drops candidates. The fixtures are synthetic (2030 dates, neutral hashes), with a golden report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unds Six targets: - key encoding round trip and canonical parsing; - detector memory bounds and no panics on arbitrary event streams; - dedup idempotence; - normalization independent of observation order within the settle window; - legal state-machine transitions only. Seeds live in code; no corpus files are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bursts Covers: - Observe at 1k/10k/100k streams, with and without periodicity; - bytes per stream; - 1M events over 10k Zipf streams at all four scopes (detector peak heap); - allocations per event in steady state; - TTL expiration and capacity eviction; - duplicate fan-out through the Normalizer; - the worst single-call candidate burst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
internal/anomaly is a standalone Go module that no binary imports, so the existing server, ingestor and channel steps never reached its tests. This adds one step to the go-test job that runs go vet, go test and go test -race -v in that module. The step fails if no test passed. It does not fuzz (fuzz targets run only their in-code seeds) and does not run benchmarks. Triggers, permissions, jobs, needs, concurrency, fork guards and deploy/publish conditions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The golden replay test failed on linux/amd64 while it passed on arm64. Both machines produced the same report except for PeriodicEvidence.Chance, which differed in its last digits. Chance used exp(-l*lo) - exp(-l*hi). For small tolerances that subtraction cancels, and Pow(pg, gaps-1) then amplifies the lost digits. Over valid parameters, amd64 and arm64 differed by up to 5e-5 relative. - Compute the same probability without cancellation: exp(-l*lo) * -expm1(-l*(hi-lo)). Measured over 400k cases on both architectures, the spread is now at most 5.2e-13 relative. - The detector still compares the full value with MaxChance. - JSON writes Chance rounded to 6 significant digits (ChanceDigits), a margin of about 1e6 over that spread, so replay output is byte-identical on amd64 and arm64. - The golden file changes only in its four Chance values. A test pins the rest of the file by hash. - The golden test now names the first differing line on failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1. Periodic: an alternating pattern within tolerance (294s/306s around 300s) was missed because every candidate came from a single gap/k. Each seed now also yields a refined candidate: the periods common to the 16 newest gaps (integer interval intersection), checked with their own tolerance. The seeds' estimate is unchanged; a refined candidate replaces it only when it would signal (meets the criteria, Chance within MaxChance) and strictly outranks it. searchCells, the union bound and every Chance value stay as before. 2. Dedup: a repeated ID could stay a duplicate forever. A repeat more than DedupHorizon after the stored copy now counts again (inclusive boundary); an older copy stays a duplicate. 3. HistoryStart: the automatic start is set when the first event is processed, not when it is accepted, so reorder cannot change results. 4. Config: New now also copies each rule's PayloadTypes. 5. Replay: width 0 with bytes is rejected for full_route and first_hop, as NewRoute does. 6. BurstGap validation no longer overflows (BurstGap >= MinPeriod-BurstGap). The ad3ab30 numeric fix, its tests and the golden file are unchanged. Cost, accepted for this fix: the periodic search is up to about 30% slower in the worst case and end to end about 4-10% slower, with no new allocations; a test enforces a per-pulse bound and pins the total work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review feedback addressed (commit
The |
… surviving mutants searchCells now counts the refined periods as cells of their own: 2*recentGaps*(MaxMissing+1)+1 (seeds, at most one refined period per seed, the phase refinement). A common period of m gaps exists exactly when the intersection's highest lower end (g_j-tol)/k_j explains the other m-1 gaps, so the refined fits are covered by as many cells as there are seeds, each spending one gap. The earlier comment claiming the seeds' cells covered them is removed. Every Chance value doubles (x97/49 for the fixture rule); the four golden Chance values change, nothing else in the golden file does. Tests: the cells cover every period estimate measures; null false alarms on 10,000 fixed Poisson trains are no higher than ad3ab30's; the range rounds the multiple of a missing-pulse gap (M12) and ends at a gap beyond MaxMissing (M13); a refined period equal to a seed is not measured again (M14); an older copy beyond the horizon stays a duplicate (M18). The work-bound comment no longer claims to catch CPU regressions it cannot count; the single Chance gate for refined candidates is documented as deliberate. Under nulls more regular than Poisson the refined periods still raise false alarms (documented). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review feedback addressed (commit
|
Re-review of 0d5995d, P3-1: mutant N7 (gate at Chance/2 <= MaxChance) survived. TestRefinedCandidateGateIsTheSignalChance builds a ring where only the refined period meets the criteria and checks that it becomes the estimate and signals at MaxChance equal to its Chance, and is not taken one ulp below or with Chance just under twice MaxChance. Nit 1: the searchCells comment states the strict union bound per pulse, searchCells + (MaxMissing+1), and why the kept-hypothesis cells are left out. Test and comment only; no change to detection behaviour or the golden file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review feedback addressed (commit Re-review of
Checks: |
Conflict in .github/workflows/deploy.yml (Go Build & Test): both sides added a step at the same place. Kept both unchanged: master's "Test shared channel registry + schema packages" first, then "Vet and test anomaly library (internal/anomaly, with -race)". No other lines changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
This PR adds
internal/anomaly, an experimental, offline Go library for encryption-independent traffic-anomaly detection on normalized MeshCore transmissions. It is phase 1 of the detector: an analysis library and its tests only, with no production integration.What it is not
cmd/, the Dockerfile or any othergo.modimports or replacesgithub.com/meshcore-analyzer/anomaly. The only callers are the package's own tests and benchmarks.io.Readerin the offline replay harness.Newrejects the zero config and any config that enables no rule. Values used in tests are named experimental fixtures, not recommendations.Files (33)
internal/anomaly/: the library, its tests, benchmarks, fuzz targets andtestdata/. The package is its own Go module..github/workflows/deploy.yml: one added step in Go Build & Test, Vet and test anomaly library (internal/anomaly, with -race).go vet,go testandgo test -race -vininternal/anomaly.-raceand fails if none did.needsor fork guard changes.CI now tests the library. The server and ingestor steps cannot reach it, because it is a separate module.
Design
NormalizerEventper transmission. The event is final at first arrival +SettleWindow. Later observations are rejected as late, so a replay never uses route evidence that arrived after the decision. When capacity cuts evidence (fan-out cap, forced finalization), the event getsTruncatedandExcludedObservations.DetectorObserve/Advance/Flush/Drain/Snapshot. Dedups eachEvent.IDat most once within an explicitDedupHorizon(bounded byMaxDedupIDs). A reorder buffer lets events arrive up toReorderDelayout of order.RateRule(t-W, t]of any width from 1 s to 24 h at stream, channel, route-group or global scope. Global candidates are never attributed to a route.NewStreamRuleCensorWindowmust coverQuietPeriod). Handles reactivation after silence and fires once per episode.PeriodicRuleBurstGap, explicit jitter tolerance, missing pulses (k*P). Candidate periods are seedsgap/kand, per seed, one refined period common to the newest gaps. Harmonics are handled with criteria-first ranking. The Poisson null (a union bound over every period the search can report and every evaluated pulse) uses the local rate. Alternatives (2P, 3P, P/2, P/3) are reported, not hidden.normal -> suspicious -> active -> endedwith cooldown, stableEventIDs and reason codes.Replay[Start, End)range and deterministic JSON output. It uses no DB and no network.Memory is bounded everywhere. Limits cover:
Cut evidence is counted in
Statsand flagged throughCoverageReduced/CoverageFlags.Cross-architecture Chance (commit
ad3ab30f)PeriodicEvidence.Chanceis computed without cancellation, asexp(-λ·lo) * -expm1(-λ·(hi-lo)):MaxChance.ChanceDigits= 6 significant digits, so replay output is byte-identical across architectures.All of this is unchanged. Only the
Chancevalues themselves changed in commit0d5995d4, because the union bound now charges the refined candidates (see below).Review findings fixed (commit
5b816f66)An independent review of
ad3ab30ffound four P2 and two P3 defects. Its six reproduction tests are committed byte-identical asindependent_review_test.go(sha256d467bdf2…2445). All six failed onad3ab30ffor the stated reasons and pass now.gap/k. Neither 294 s nor 306 s explains the next gap, so no chain formed.[(g-tol)/k, (g+tol)/k]and checked with the candidate's own tolerance. The seeds' estimate is computed exactly as before. The refined candidate replaces it only if it would signal (meets the criteria and its Chance is withinMaxChance) and strictly outranks it. Since0d5995d4the union bound charges the refined candidates; see the next section.DedupHorizonafter the stored copy counts again. The boundary is inclusive, matching the log expiry. An older copy stays a duplicate and never revives the ID.HistoryStartdepended on arrival orderAdvance,Flushor forced release. An explicitHistoryStartis untouched.PayloadTypeswere shared with the caller's configNewcopied only the outer rule slices.PayloadTypesis cloned for Rate, NewStream and Periodic rules. A sweep of the config types found no other shared slice, map or pointer."0:deadbeef")full_routealways goes throughNewRoute, andfirst_hopis validated whenever width or bytes are present.BurstGapvalidation could overflow2*BurstGapwraps negative.BurstGap >= MinPeriod-BurstGap, which is exact and cannot overflow.Cloud review of
5b816f66fixed (commit0d5995d4)P2-1: the union bound now charges the refined candidates
In
5b816f66the refined candidates could signal, butsearchCellsdid not count them. The code comment claimed the seeds' cells covered them; the review showed that claim was wrong.New formula:
This counts, per pulse:
g/kfor each of the 16 newest gaps and eachk = 1..MaxMissing+1;A kept hypothesis reports only when the search does not run, and then with at most
MaxMissing+1periods (itself and its multiples). The strict union bound per pulse is thereforesearchCells + (MaxMissing+1). ThoseMaxMissing+1cells are left out: the kept branch predates the refined cells and was never charged, and they add less than 1/32 ofsearchCells(about 3 %).Chance = evaluated · searchCells · pg^(gaps-1)is otherwise unchanged.Why one gap per cell is still the cost of a fit:
[(g-tol)/k, (g+tol)/k]of themnewest gaps it explains.L = (g_j-tol)/k_j, lies in every other range. In other words, the periodLexplains each of the otherm-1gaps.mgaps" is the union, over therecentGaps · (MaxMissing+1)pairs(j, k_j), of "(g_j-tol)/k_jexplains the otherm-1". Each term spends one gap, which gives as many cells as there are seeds.mtimes as often as one given seed explains the others. That is why the refined periods cannot share the seeds' cells.Two approximations remain, as elsewhere in this bound:
pgis taken at the reported period.Tests (both failed on
5b816f66and pass now):TestSearchCellsCoverEveryPeriodTheSearchMeasures:estimatenever measures more periods thansearchCells. On5b816f66it measured 52 against 49 cells.TestRefinedCandidatesDoNotRaiseTheNullFalseAlarmRate: 10,000 fixed Poisson trains, scenario a) atMaxChance0.1.ad3ab30ffires on 60,5b816f66on 107, this commit on 54 (60 + 10 % allowed).False alarms per 10,000 null trains (95 % Wilson interval)
Paired trains: every train is generated in full from its own seed, and all three versions see identical input. The trains are my own reconstructions. The rule parameters are the review's scenarios; the train generator is mine (64 pulses, mean gap drawn from 2 to 22 min), so absolute rates differ from the review's.
JitterAbs20 s,JitterRel0,MaxJitterFraction1,MinPulses4,MinCoverage0.1,MaxMissing0, Poisson null.JitterRel0.1,MaxJitterFraction1,MinPulses4,MinCoverage0.1,MaxMissing2, Poisson null.JitterRel0.05, fixture rule otherwise.Cells a) and b) use 40,000 trains each; cell c) uses 400,000. The last column is the paired difference, this commit minus
ad3ab30f.ad3ab30f5b816f66ad3ab30f[95 %]ad3ab30f.MaxChance.Scenario c) is a known limitation, accepted by the owner.
0d5995d4used its own generator and measured, againstad3ab30f:ad3ab30fdoes not, and never the other way round. That is +26 % relative, still about 15× below the nominal level.searchCellschanges this: the chains fire with Chance of 3e-5 to 3e-4, far below the threshold. Weighting the refined candidates' gate by 8 or 32, or requiring strictly more gaps than the seeds, still leaves 54 and 43 extra trains.Golden file
Only four
Chancevalues change, each by the factor 97/49 (searchCellsof the fixture rule, 49 → 97):4.59387e-89.09399e-80.0002419650.000478992TestGoldenChangedOnlyInTheDocumentedChanceValuesstill pins the rest of the file by hash, so detections, transitions, states, reason codes and the number of candidates are unchanged.TestMaxChanceDecisionAroundTheBoundarypins the new value.P2-2: performance (numbers below)
The performance numbers in the previous description were too low; they are replaced by the measurements in Performance, including noise, bursty and alternating traffic.
P3-1: signal pulses after the first detection
The earlier "0 lost" applied to first detections only. After a first detection, a version can hold a different period and miss single signal pulses that another version signals. The detection timing section now reports this separately.
P3-2: work bound
TestPeriodicSearchWorkIsBoundedcatches only work that the chain and gap counters count. More CPU per counted step is not caught. The earlier statement that "a 2× regression fails" was an overstatement; onlyBenchmarkPeriodicSearchmeasures CPU.P3-3: surviving mutants
Each has a named test that fails with the mutant and passes without it:
krounded down in the rangeTestRefinementRoundsTheMultipleOfAGapk <= MaxMissing+1check in the range (not equivalent)TestRefinementRangeEndsAtAGapBeyondMaxMissingTestRefinedPeriodEqualToASeedIsNotMeasuredAgain, with an exact chain and step count, andTestRefinedPeriodsAreMeasuredOnceTestDedupOlderCopyBeyondTheHorizonIsStillADuplicateNit: the Chance gate for refined candidates
Only the best-ranked refined candidate is tried against
MaxChance. This is deliberate: a lower-ranked one that would pass is not taken instead, so the estimate is always the best-ranked chain, never the one chosen because it is the most likely to fire. It is documented inestimate.Re-review of
0d5995d4addressed (commit4554e314)The re-review found no defects in the code. This commit changes only a test and a code comment; detection behaviour and
testdata/replay_golden.jsonare byte-unchanged.Chance/2 <= MaxChance. New testTestRefinedCandidateGateIsTheSignalChance: on a ring where only the refined period meets the criteria, the refined chain is the estimate and signals atMaxChanceequal to its Chance, and is not taken (nothing signals) one ulp below and with Chance just under twiceMaxChance. N7 survived on0d5995d4and is killed now.searchCellscomment states that the strict union bound per pulse issearchCells + (MaxMissing+1), and why the kept-hypothesis cells are left out.Performance
Hardware and conditions
ad3ab30f,5b816f66and this commit, with-benchmem. Onad3ab30fthe same benchmark file is used, with the work counters added.This commit leaves the search cost of
5b816f66unchanged: it changes only the Chance charge. The previous description understated that cost (review P2-2).Periodic search per pulse (
BenchmarkPeriodicSearch, µs)Each cell shows the median of 6 rounds, with the range in brackets. "Change" is the median of the per-round ratios. Steps per pulse (gaps visited) do not depend on the machine.
ad3ab30f5b816f66Allocations per pulse (allocs/op) are identical on all three versions in every row, and B/op is identical except fixture/jitter (7 → 1 B/op, amortized signal evidence).
End to end (
BenchmarkMillionEvents, 1M events, seconds)ad3ab30f5b816f66Peak detector heap is 147.4 MiB on all three versions. B/op and allocs/op come from one run per round; the differences are at noise level (under 0.05 % of bytes and at most 8 allocations per million events).
In short, compared with
ad3ab30f, on two machines. Steps per pulse are identical across hardware; only the time per step differs.0d5995d4(Xeon 2.1 GHz, 4 cores, Go 1.24.7). Only the rows it reported are filled in.5b816f66measured +110 to +178 % on noise and bursty traffic (Go 1.24, 4-core Xeon); the search cost has not changed since.Detection timing
This is a paired Monte Carlo against
ad3ab30f. Each train is generated in full from its own seed, and every version sees identical input.wide, thefixturerule, and three with a tightMaxChance:tight1e-9,wide1e-7,loose1e-5).Rows are summed over the four signal modes (12,000 trains per family).
This commit against
ad3ab30f(12,000 signal trains per rule family)For comparison,
5b816f66againstad3ab30f(12,000 signal trains per rule family)Null modes (9,000 trains per family): false alarms
ad3ab30f→ this commit are wide 5 → 5, fixture 3 → 3, tight1e-9 0 → 0, wide1e-7 0 → 0 and loose1e-5 0 → 1.Later and lost detections occur where Chance is the binding criterion. With twice the cells, a chain typically needs one more gap before its Chance is within
MaxChance. Whether that delays a detection depends on which criterion is reached last:MinPulses8): by the time the chain is long enough, its Chance is already far belowMaxChance. 0 later, 0 lost in myfixtureandwiderows at 0.01, and in the re-review's fixture-rule run.MaxChance, or loose shape criteria (smallMinPulsesorMinCoverage, wide tolerance), even atMaxChance0.01. Then some first detections come later and a few never come.5b816f66againstad3ab30fhas 0 later and 0 lost in every row, in my runs and in the re-review's. So the delay comes from the doubled Chance, not from the refined candidates themselves.The re-review's measurements (independent probe: 80,000 signal trains, 4 signal types × 20,000, 80 pulses per train, log-uniform scales, paired against
ad3ab30f,MaxChance0.01):ad3ab30fdoes not than the other way round (for example 5,742 against 21 forwide). With the re-review's rule a) at 0.01 it is the reverse: 34,511 signal pulses only inad3ab30fagainst 17,788 only in this commit.loose1e-5(0 → 1).The owner accepted this detection delay and loss on 2026-09-26 as the cost of charging the refined candidates. It is listed under known limitations.
Evidence
Local results on darwin/arm64 (Apple M2 Pro, Go 1.26) unless marked otherwise.
go test -raceclean,gofmtandgo vetclean;go test -listshows 142Testfunctions, one new in4554e314testdata/replay_golden.json(new Chance values, see above)ad3ab30fpass, 10 of 10 runs;independent_review_test.gois byte-identicalChance/2)FuzzPeriodicSearch(1.58M execs) andFuzzReplayRouteParsing(4.63M execs), no failures; the seed corpus of all 8 fuzz targets passes-raceis clean (on amd64 only under emulation). The only raw difference is the last digits of one Chance on darwin/amd64 (about 6e-16 relative), which the 6-digit JSON absorbs. Native linux/amd64-raceis the CI step.Review status
5b816f66was pre-reviewed by a subagent in the implementing session, with a final verdict of PASS.5b816f66then returned BLOCK, with two P2, three P3 and one nit. This commit addresses all of them, and the owner accepted scenario c) as a known limitation.0d5995d4found no code defects. Its verdict was BLOCK on the PR text only (P2-1: detection timing), plus one P3 (surviving mutant N7) and three nits. Commit4554e314addresses all of them with a test and a code comment only; detection behaviour and the golden file are unchanged.Known limitations
This fix:
ad3ab30f, far below nominal. The size depends on the train generator: atMaxChance0.01, +1.40 [+1.03, +1.77] per 10,000 with mine and +1.75 [+1.49, +2.01] with the re-review's. With the re-review's generator it also reaches the fixture rule under the uniform null: +0.25 per 10,000 [+0.10, +0.40].MaxChance, or loose shape criteria such as smallMinPulsesorMinCoverageand a wide tolerance), the doubled Chance delays some first detections compared withad3ab30f, usually by one pulse, and loses some. The re-review measured up to 63,087 later and 997 lost of 80,000 signal trains atMaxChance0.01 with loose rules. Where the shape criteria bind, as with the fixture rule, nothing is later or lost. See Detection timing.pgis taken at the reported period. Refined cells that fit the same window are counted as separate cells on the older gaps.MaxChance(deliberately conservative).Existing before this update:
Stats.EffectiveDedupHorizonis measured from the dedup log in acceptance order. With out-of-order input it can read up toReorderDelaylow. It is diagnostic only.MaxChance. This applies to ordinary seeds too.General:
DecidedAtreflects the order events are offered in.Validation gate
Nothing here may be proposed for production before a blind validation on a fresh snapshot taken no earlier than 2026-10-08:
Until then this PR stays a draft.
🤖 Generated with Claude Code