Skip to content

anomaly: periodic rule targets channel spam bots (not wardriving) — exclusions + injection-based validation #474

Closed

Description

@dborup

Owner decision (2026-10-15): what the periodic rule is for

The target is abusive channel bots: a sender that floods a public or hashtag channel with automated messages, like the bible-verse bot seen earlier. Wardriving traffic is not a target.

Why round 1 measured the wrong thing for this rule

In #457, all 6 labelled periodic incidents were on channel byte 81, which is #wardriving. Wardriving apps post positions automatically every 75–123 s while someone drives. Under the §4 recipe ("≥ 8 pulses at a regular cadence") every wardriving trip counted as an incident. The detector missed 5 of 6 because a moving sender enters the mesh through changing first hops. The route-group key splits one trip into many short streams.

That is the opposite of what we want: we do not want wardriving to trigger, and a moving sender should not count. A stationary spam bot is a different case. The #130 replay found that the September 5-minute channel bot used one stable first hop (1,232 of 1,237 messages), and the periodic rule detected it within about 35 minutes.

Design for the next periodic round

  1. Target definition (before any data).
    • An incident is a sender posting automated, regular-cadence messages on a channel that is not an expected-automation channel.
    • Excluded channels are a fixed list of channel hash bytes chosen by configuration: at least #wardriving (81), plus any other known automation channels the owner names.
    • Note that 1-byte hashes collide. Excluding 81 also excludes any other channel whose hash byte is 81; this is documented, not hidden.
  2. Detector side. The rule must be able to ignore those channel bytes, through an expected or exclusion option in the library config. No defaults.
  3. Evaluation, because real spam is rare. Fresh data may well contain no spam bot. A blind run on fresh data alone would then be "inconclusive". The evaluation therefore needs two parts:
  4. Then a pre-registration, posted before any data, with bars for false alarms per day, injected-bot recall, time to detect and stop latency.

Relation to other work

#473 (rate rule plus backlog guard) is independent and continues. New-stream detection is parked: per-sender onsets are mostly normal behaviour, such as people becoming active in the morning or wardriving trips starting.

Activity

  1. adminopenclaw8-sketch commented on Oct 15, 2026

    @adminopenclaw8-sketch
    Collaborator

    Pre-registration — round 1 for the channel-bot target (fixed before any data is seen)

    No validation data has been seen by the author of this comment: nothing from 2026-10-15 on has been exported, injected, calibrated, labelled, replayed or scored, and no snapshot has been taken. Everything below is fixed now. Changing a rule value, the injection panel, the labelling, a metric or a bar after the data is seen is a round 2, reported separately; it is never a pass of round 1. This comment is not edited after posting; a correction is a new comment and starts a new round.

    This round replaces the #457 periodic target (issuecomment on #457, round 2/3/4), which measured wardriving. It does not touch the rate rule: #473 continues independently.

    Stream keys are observed route groups (payload type + channel hash + route class + route kind + first hop). They are never senders, and no result attributes traffic to a node. 1-byte hops and 1-byte channel hashes are globally ambiguous.

    1. Target definition (phase 1)

    An incident is a sender posting automated, regular-cadence messages on a channel that is not an expected-automation channel. Wardriving is not a target: an app posting a position every 75–123 s while someone drives is expected behaviour, and a moving sender does not count.

    Excluded channels for this round: exactly the 1-byte channel hash 81, the #wardriving byte all six #457 round-4 incidents fell on. The owner may add hashes to this list before the run; adding one after any data is seen is a new round.

    A channel hash is not a channel: excluding 81 also excludes every other channel name that hashes to 81 (about 1/256 of all names), including any spam on them. That is a stated cost of keying on an opaque 1-byte hash, not something the list can avoid.

    2. Tool

    cmd/anomaly from PR #478 at head b7ee09529816ea4944081a44471d0c158051e492, built with cd cmd/anomaly && go build -o corescope-anomaly .. The binary used for the run has its sha256 reported with the results (the macOS/arm64 build used for the §11 pipeline check is 3974280d900a3a7b…). Phases 1–3 of this issue (target wording, PeriodicRule.ExcludeChannels, inject, score) are that PR; the library has no default exclusions and the replay golden file is unchanged.

    3. Data and runs

    Item Fixed value
    Snapshot An offline copy made by scripts/backup-sqlite.sh (container stopped briefly), used as-is, read-only, with -immutable, with no -wal, -shm or -journal beside it; or, where disk allows, sqlite3 -readonly <db> "VACUUM INTO '<snap>/meshcore.db'". The choice is made before any export and is reported. .backup is not used (#457 round 3).
    Validation window V The first 7 whole UTC days present in the snapshot starting at or after 2026-10-15T00:00:00Z. The choice uses row counts per UTC day only, never results. Fewer than 5 whole days voids the round; 5 or 6 days shorten the panel by §6.
    Evaluation window E E = V. There is no calibration day: no value in this round is derived from the data.
    Export corescope-anomaly export -db <snap>/meshcore.db -immutable -start <V.start> -end <V.end> -settle 30s -max-rows 50000000 -out bg.jsonl
    Injection spec python3 make_spec.py <V.start> > spec.json (§6).
    Inject corescope-anomaly inject -spec spec.json -input bg.jsonl -out inj.jsonl -labels labels.json -max-injected 2000000 -max-line-bytes 65536
    Replay, arm A corescope-anomaly replay -config round1.json -input bg.jsonl -start <V.start> -end <V.end> -max-line-bytes 4096 -out report-a.json
    Replay, arm B the same with -input inj.jsonl -out report-b.json
    Score, arm A corescope-anomaly score -report report-a.json -rule p474_periodic -start <V.start> -end <V.end> -out metrics-a.json (no -labels: every episode is a false alarm)
    Score, arm B corescope-anomaly score -report report-b.json -labels labels.json -rule p474_periodic -bots b0-90s-clean,b1-120s-clean,b2-300s-clean,b3-600s-clean,b4-900s-clean,b5-90s-noisy,b6-300s-noisy,b7-900s-noisy -start <V.start> -end <V.end> -out metrics-b.json (minus any bot dropped by §6 or §10)
    Score, sanity the same with -bots b8-300s-sept -out metrics-sanity.json
    Episode One EventID. Its activation is its first transition to active; its end is its last transition to ended, or V.end if it never ended. Only episodes activated in E count.
    Capacity #457 §1a applies verbatim: count caps only (-max-rows and the six limits count caps), mechanical doubling, at most 4 doublings, coverage_reduced on the export voids the round. Every step is reported.

    Two arms, one background. Arm A is the fresh real traffic with the exclusions applied and nothing injected: it measures false alarms. Arm B is the same export with the synthetic panel overlaid: it measures recall and timing. Arm B's background is byte for byte arm A's.

    4. Rule config (round1.json)

    Fixed as written. It is the #457 round-2 periodic rule (the #130/#447 variant: burst gap 15 s, jitter 10 s, tracking K = 3, quiet 60 min as the outer bound), plus the exclusion list of §1. No rate or new-stream rule runs in this round. The file was checked with ParseReplayConfig and New on the pinned head.

    {
     "limits": {"max_keys_per_scope": 200000, "state_ttl": "48h", "dedup_horizon": "6h", "max_dedup_ids": 2000000,
      "reorder_delay": "0s", "max_reorder_buffer": 64, "max_window_entries": 200000,
      "max_candidates_per_call": 10000, "max_pending_candidates": 100000},
     "expected": "include",
     "periodic": [{"name": "p474_periodic", "scope": "stream", "payload_types": [5],
      "min_period": "1m", "max_period": "1h", "burst_gap": "15s", "jitter_abs": "10s", "jitter_rel": 0.02,
      "max_missing": 2, "min_pulses": 8, "history_len": 32, "min_coverage": 0.6, "max_jitter_fraction": 0.5,
      "max_chance": 0.01, "end_after_missed_pulses": 3, "exclude_channels": ["81"],
      "state": {"confirm_after": "0s", "end_after_quiet": "60m", "cooldown": "0s"}}]
    }

    sha256 5e5b5e7a6610a46192873872410aee59cc8b1690cc8f1f5447996c3ba0c6e0dd.

    5. Why injection

    Fresh data may contain no spam bot at all, and a blind run on real traffic alone would then be "inconclusive". So recall is measured on synthetic bots overlaid on the same real background, and false alarms are measured on the real background alone.

    Injected traffic is clearly synthetic and never enters a database: inject has no -db flag and opens no database, every injected line's id is inj-<bot>-<pulse>-<part> (an export numbers its lines tx1, tx2, …), and the only artefacts are files that stay with the coordinator. The injected first hops are the 3-byte values fdff00–fdff08. A real transmission would have to carry exactly that 3-byte prefix to share a stream key with an injected bot, which is possible but very unlikely; arm A reveals it if it happens, and §10's contamination rule covers it.

    6. Injection panel (fixed, generated mechanically)

    Nine bots, each 12 h long, 18 h apart, all stationary (one first hop). Shapes: clean = jitter only; noisy = jitter + 5 % missed pulses + 30 % multi-part pulses (1–2 later parts), which is the September bot's documented shape from #130; sept = noisy plus the one 35-minute outage. Jitter is scaled to the rule's own per-gap tolerance (sd = tol(P)/4, clamped at 2.5 sd), so no bot is placed outside the rule's tolerance by construction.

    bot channel first hop start (V.start + …) period jitter sd jitter max missed multi-part mp delay hole
    b0-90s-clean a0 3:fdff00 +1 h 90s 2s 5s 0 0 22s –
    b1-120s-clean a1 3:fdff01 +19 h 120s 2s 5s 0 0 30s –
    b2-300s-clean a2 3:fdff02 +37 h 300s 2s 5s 0 0 55s –
    b3-600s-clean a3 3:fdff03 +55 h 600s 3s 7s 0 0 55s –
    b4-900s-clean a4 3:fdff04 +73 h 900s 4s 10s 0 0 55s –
    b5-90s-noisy a5 3:fdff05 +91 h 90s 2s 5s 0.05 0.3 22s –
    b6-300s-noisy a6 3:fdff06 +109 h 300s 2s 5s 0.05 0.3 55s –
    b7-900s-noisy a7 3:fdff07 +127 h 900s 4s 10s 0.05 0.3 55s –
    b8-300s-sept a8 3:fdff08 +145 h 300s 2s 5s 0.05 0.3 55s 35 min at +6 h

    The panel spans V.start + 1 h to V.start + 157 h, which fits inside 7 days. If V is only 5 or 6 whole days, every bot whose 12-hour window does not lie entirely inside V is dropped from the -bots list and reported as out of window; its events are still injected. The rule is mechanical and uses V's length only.

    b8-300s-sept is the non-blind sanity check: #130 and #447 were tuned on that shape, so it is reported on its own (-bots b8-300s-sept) and is not part of any bar. The eight others (b0-90s-clean,b1-120s-clean,b2-300s-clean,b3-600s-clean,b4-900s-clean,b5-90s-noisy,b6-300s-noisy,b7-900s-noisy) are the blind panel.

    Spec seed 474202610150. The spec is generated by this script, which takes V.start as its only argument (stdlib only), sha256 f0fb7b84adab759a8a514c29d874ab9be5c07f49db9e89cb7011bc0ea0e7aeb6:

    # make_spec.py - builds the #474 round-1 injection spec deterministically.
    # python3 make_spec.py <V.start RFC3339> > spec.json      (stdlib only)
    import json, sys
    from datetime import datetime, timedelta, timezone
    
    V0 = datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
    def iso(t): return t.strftime("%Y-%m-%dT%H:%M:%SZ")
    
    # The rule's own per-gap tolerance, max(jitter_abs, jitter_rel * P).
    def tol(p): return max(10, 0.02 * p)
    
    # Panel: (period seconds, shape). Shapes:
    #   clean = jitter only
    #   noisy = jitter + 5 % missed pulses + 30 % multi-part pulses (#130 shape)
    #   sept  = noisy plus the one 35-minute outage of the September bot
    # Jitter is scaled to the rule's tolerance, so no bot is placed outside it
    # by construction: sd = tol(P)/4, clamped at 2.5 sd.
    PANEL = [(90, "clean"), (120, "clean"), (300, "clean"), (600, "clean"), (900, "clean"),
             (90, "noisy"), (300, "noisy"), (900, "noisy"), (300, "sept")]
    CHANNELS = ["a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8"]
    HOURS_EACH, HOURS_APART = 12, 18
    
    bots = []
    for i, (p, shape) in enumerate(PANEL):
        start = V0 + timedelta(hours=1 + HOURS_APART * i)   # non-overlapping
        stop = start + timedelta(hours=HOURS_EACH)
        sd = max(1, int(tol(p) / 4))
        jmax = min(int(0.1 * p), int(2.5 * sd))
        mpd = min(55, p // 4)
        noisy = shape in ("noisy", "sept")
        holes = [{"start": iso(start + timedelta(hours=6)), "duration": "35m"}] if shape == "sept" else []
        bots.append({
            "name": f"b{i}-{p}s-{shape}", "channel": CHANNELS[i], "payload_type": 5,
            "route_class": "flood", "route_kind": "relay",
            "first_hop": f"3:fdff{i:02x}", "full_route": f"3:fdff{i:02x}",
            "wire_bytes": 60, "payload_bytes": 35,
            "start": iso(start), "stop": iso(stop), "period": f"{p}s",
            "jitter_sd": f"{sd}s", "jitter_max": f"{jmax}s",
            "missed_fraction": 0.05 if noisy else 0.0,
            "multipart_fraction": 0.3 if noisy else 0.0,
            "multipart_max_extra": 2, "multipart_max_delay": f"{mpd}s",
            "holes": holes,
        })
    
    print(json.dumps({"seed": 474202610150, "final_after": "30s",
                      "labelling": {"pulse_merge_gap": "60s", "min_period": "1m", "max_period": "1h",
                                    "min_pulses": 8, "max_harmonic": 3,
                                    "tolerance_abs": "30s", "tolerance_rel": 0.1},
                      "bots": bots}, indent=1))

    With V.start = 2026-10-16T00:00:00Z this gives a spec of sha256 52a1e92f62ace2494d33af1580ac4b562bd53a0baf9a26099bc3800426ef0d20 and 2,224 injected events; the actual V.start shifts every time and is reported with the results.

    7. Ground truth

    The labels come from inject -labels, computed from the emitted pulses with the #457 §4 periodic recipe, fixed in the spec:

    1. events closer than pulse_merge_gap = 60 s are one pulse, timed at its first event;
    2. P is the median pulse gap inside [1 min, 1 h];
    3. an incident is a maximal run of at least 8 pulses whose every gap is within max(30 s, 0.1·P) of k·P for some k ∈ {1, 2, 3}.

    So the ground truth is what the recipe says, not "one bot, one incident": the sept bot's 35-minute hole is 7 periods, more than k = 3, so it is two incidents. With the example V.start the panel labels 10 incidents: one per bot, two for b8-300s-sept (eight blind, two sanity).

    The labels file holds bot names, channel hashes, times, periods and counts. No ids, routes, hops, observers, names, text or keys. Real traffic is never labelled in this round: no decryption and no sender names are used anywhere.

    8. Matching and metrics

    score implements the #457 §5 periodic column unchanged:

    • An episode is a true positive for an incident when the channel hashes are equal, [activation, end] overlaps [incident start, incident stop + 4P], and the episode's period P̂ (Periodic.Period of its activation candidate) is within max(30 s, 0.1·P) of k·P for some k ∈ {1, 2, 3}. An episode whose activation carries no periodic evidence is never a true positive.
    • Every episode that matches no labelled incident is a false positive. In arm A there are no labels, so every episode is one.
    • -bots narrows which incidents the recall and the medians cover; it never turns a matched episode into a false alarm.

    Reported per arm: episodes, true and false positives, false alarms per day, recall, median time to detect (also in units of P), median stop latency, median episodes per detected incident, the per-incident table and the false positives per channel hash. Also reported: period_mismatches (overlap and channel agree, period does not), episodes_without_period, and the export and replay coverage counters.

    9. Pass bars (all must hold; otherwise no-go)

    Arm Bar
    A — false alarms on fresh real traffic (primary) false alarms ≤ 0.2/day over E, with the exclusions applied
    B — recall on the eight blind injected bots (primary) recall ≥ 0.80 · median time to detect ≤ 12 P · median stop latency ≤ 4 P · median episodes per detected incident ≤ 2
    Sanity (b8-300s-sept) reported, no bar: #130 and #447 were tuned on that shape

    If fewer than 5 blind bots survive §6 (out of window) and §10 (contamination), arm B is inconclusive, which counts as a no-go for any claim; its numbers are still reported. A void round (§3 capacity) has no results. The results report states go or no-go, with each bar's measured value. A no-go is an acceptable outcome. No value from this round becomes a default or a production value, and integration stays a separate issue.

    10. Contamination check and limitations

    • Contamination. The §8 match keys on the channel hash, so a real train on one of a0–a8 could be counted as a true positive for an injected incident. Arm A detects exactly that: if any arm-A episode falls on one of those hashes, every incident of the bot on that hash is reported as contaminated and that bot is dropped from the arm-B -bots list. This rule is mechanical and fixed here; it uses arm A's channel hashes only, and every drop is reported.
    • Overlay, not interaction. Injection adds traffic; it cannot remove or reorder real traffic, and the real background is copied through byte for byte. The injected bots are stationary by design, which is the target; a moving spam bot would spread over route groups exactly as anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 round 4 showed, and this round does not measure that.
    • Synthetic shapes. The panel's shapes come from anomaly: hold a periodic bot as one episode and detect when it stops (evaluate Cooldown, then train tracking) #130's measurements of one real bot. If real spam looks different, recall on this panel says less than the number suggests.
    • No labelling of real traffic. Arm A counts every episode as a false alarm, including a real spam bot if one exists. That is deliberately conservative: it can only make the false-alarm number worse, never better. Any arm-A episode is published as (channel hash, activation, end, P̂) so a real bot can be recognised later.

    11. Pilot on synthetic background only (no real data)

    For the record, the pipeline was exercised before this comment with no real data at all: the panel above injected into an empty background, replayed with round1.json and scored. Blind panel: recall 8/8, median time to detect 7.0 P, median stop latency 15 min 10 s, 1 episode per incident, 0 false positives. The two noisy long-period bots are the slow ones (b6 at 43 P, b7 at 38 P); the clean bots all alarm at exactly 7 P, which is the earliest min_pulses = 8 allows. Sanity bot: 2/2 at 11 P and 25 P. PR #478's own tests show the same pattern on a different synthetic background and seed: a clean 5-minute bot alarms at 8 P, the same bot with 5 % missed and 30 % multi-part pulses at 36.9 P, both with 0 false alarms. This is a pipeline check, not a validation: it contains no real traffic, and the bars above are what the real background has to meet.

    12. Handling of the files

    bg.jsonl, inj.jsonl, report-a.json, report-b.json, labels.json and the metrics files stay with the coordinator. The export carries no content hashes, names, text, keys or observer identities, but a line can still be re-identified by its time, sizes and route, so only aggregates and (channel hash, start, stop, P) tuples are published. Injected lines are never written into any database.

  2. adminopenclaw8-sketch commented on Oct 15, 2026

    @adminopenclaw8-sketch
    Collaborator

    Pre-registration — round 2 for the channel-bot target (NEW, SUPERSEDING; fixed before any data is seen)

    This comment replaces round 1 (issuecomment-6200066715) in full. Round 1's two arms, its panel, its stream keys, its contamination rule and its bars are withdrawn and are not a pass of anything. Nothing in this round reuses round 1's numbers. Round 1 was withdrawn because the review of PR #478 showed its arm B could not be influenced by the real background: every injected bot sat on a channel byte and a 3-byte first hop that no real transmission carries, so arm B measured detection in isolation, not detection inside real traffic. The rule itself is unchanged (round2.json is byte-identical to round 1's round1.json, sha256 5e5b5e7a…).

    No validation data has been seen by the author of this comment: nothing from 2026-10-16 on has been exported, injected, calibrated, labelled, replayed or scored, and no snapshot has been taken. Everything below is fixed now. Changing a rule value, the injection panel, the key-selection rule, the labelling, a metric or a bar after the data is seen is a round 3, reported separately; it is never a pass of round 2. This comment is not edited after posting; a correction is a new comment and starts a new round.

    This round replaces the #457 periodic target (round 2/3/4 on #457), which measured wardriving. It does not touch the rate rule: #473 continues independently.

    Stream keys are observed route groups (payload type + channel hash + route class + route kind + first hop). They are never senders, and no result attributes traffic to a node. 1-byte hops and 1-byte channel hashes are globally ambiguous.

    1. Target definition (phase 1)

    An incident is a sender posting automated, regular-cadence messages on a channel that is not an expected-automation channel. Wardriving is not a target: an app posting a position every 75–123 s while someone drives is expected behaviour, and a moving sender does not count.

    Excluded channels for this round: exactly the 1-byte channel hash 81, the #wardriving byte all six #457 round-4 incidents fell on. The owner may add hashes to this list before the run; adding one after any data is seen is a new round.

    A channel hash is not a channel: excluding 81 also excludes every other channel name that hashes to 81 (about 1/256 of all names), including any spam on them. That is a stated cost of keying on an opaque 1-byte hash, not something the list can avoid.

    2. Tool

    cmd/anomaly from PR #478 at head 7f2f1a8d8884f48729ff0999d4612f860514904c, built with cd cmd/anomaly && go build -o corescope-anomaly .. The binary used for the run has its sha256 reported with the results (the macOS/arm64 build used for the §11 pipeline check is 15890b53fe1963e56029b7981e113ffeb8583cfafe19a7edeeed987040ac3a74). Phases 1–3 of this issue are that PR: the target wording, PeriodicRule.ExcludeChannels, and the offline keys, inject and score subcommands. The library has no default exclusions and the replay golden file is unchanged.

    3. Data and runs

    Item Fixed value
    Snapshot An offline copy made by scripts/backup-sqlite.sh (container stopped briefly), used as-is, read-only, with -immutable, with no -wal, -shm or -journal beside it; or, where disk allows, sqlite3 -readonly <db> "VACUUM INTO '<snap>/meshcore.db'". The choice is made before any export and is reported. .backup is not used (#457 round 3).
    Validation window V The first 7 whole UTC days present in the snapshot starting at or after 2026-10-16T00:00:00Z — the first whole UTC day that begins after this comment was posted, so no part of V exists yet. The choice uses row counts per UTC day only, never results. Fewer than 5 whole days voids the round; 5 or 6 days shorten the panel by §6.
    Evaluation window E E = V. There is no calibration day: no value in this round is derived from the data.
    Export corescope-anomaly export -db <snap>/meshcore.db -immutable -start <V.start> -end <V.end> -settle 30s -max-rows 50000000 -out bg.jsonl
    Key ranking corescope-anomaly keys -input bg.jsonl -payload-type 5 -top 16 -exclude-channels 81 -max-keys 5000000 -max-line-bytes 4096 -out keys.json (§6a)
    Full key ranking the same with -top 5000000 -out keys-full.json, used only for the §10 absence check of the isolated keys
    Injection spec python3 make_spec.py <V.start> keys.json > spec.json (§6)
    Inject corescope-anomaly inject -spec spec.json -input bg.jsonl -out inj.jsonl -labels labels.json -max-injected 2000000 -max-line-bytes 65536
    Replay, arm A corescope-anomaly replay -config round2.json -input bg.jsonl -start <V.start> -end <V.end> -max-line-bytes 4096 -out report-a.json
    Replay, arm B the same with -input inj.jsonl -out report-b.json
    Score, arm A corescope-anomaly score -report report-a.json -rule p474_periodic -start <V.start> -end <V.end> -out metrics-a.json (no -labels: every episode is a false alarm)
    Score, arm B shared corescope-anomaly score -report report-b.json -labels labels.json -baseline report-a.json -rule p474_periodic -bots <the 8 blind -shared bots> -start <V.start> -end <V.end> -out metrics-b-shared.json
    Score, arm B noisy subset the same with -bots b5-90s-noisy-shared,b6-300s-noisy-shared,b7-900s-noisy-shared -out metrics-b-noisy.json
    Score, arm B isolated control … -labels labels.json -rule p474_periodic -bots <the 8 blind -iso bots> … -out metrics-b-iso.json (no -baseline: an isolated key has no background)
    Score, sanity the same two runs with -bots b8-300s-sept-shared and -bots b8-300s-sept-iso
    Episode One EventID. Its activation is its first transition to active; its end is its last transition to ended, or V.end if it never ended. Only episodes activated in E count.
    Capacity #457 §1a applies verbatim: count caps only (-max-rows, -max-keys and the six limits count caps), mechanical doubling, at most 4 doublings, coverage_reduced on the export voids the round. Every step is reported.

    Two arms, one background. Arm A is the fresh real traffic with the exclusions applied and nothing injected: it measures false alarms. Arm B is the same export with the synthetic panel overlaid: it measures recall and timing. Arm B's background is byte for byte arm A's.

    4. Rule config (round2.json)

    Fixed as written, and unchanged from round 1: this round changes the panel and the scoring, not the rule. It is the #457 round-2 periodic rule (the #130/#447 variant: burst gap 15 s, jitter 10 s, tracking K = 3, quiet 60 min as the outer bound), plus the exclusion list of §1. No rate or new-stream rule runs in this round. The file was checked with ParseReplayConfig and New on the pinned head.

    {
     "limits": {"max_keys_per_scope": 200000, "state_ttl": "48h", "dedup_horizon": "6h", "max_dedup_ids": 2000000,
      "reorder_delay": "0s", "max_reorder_buffer": 64, "max_window_entries": 200000,
      "max_candidates_per_call": 10000, "max_pending_candidates": 100000},
     "expected": "include",
     "periodic": [{"name": "p474_periodic", "scope": "stream", "payload_types": [5],
      "min_period": "1m", "max_period": "1h", "burst_gap": "15s", "jitter_abs": "10s", "jitter_rel": 0.02,
      "max_missing": 2, "min_pulses": 8, "history_len": 32, "min_coverage": 0.6, "max_jitter_fraction": 0.5,
      "max_chance": 0.01, "end_after_missed_pulses": 3, "exclude_channels": ["81"],
      "state": {"confirm_after": "0s", "end_after_quiet": "60m", "cooldown": "0s"}}]
    }

    sha256 5e5b5e7a6610a46192873872410aee59cc8b1690cc8f1f5447996c3ba0c6e0dd — byte-identical to round 1's config.

    5. Why injection, and why on real keys

    Fresh data may contain no spam bot at all, and a blind run on real traffic alone would then be "inconclusive". So recall is measured on synthetic bots overlaid on the same real background, and false alarms are measured on the real background alone.

    Round 1's mistake was where the bots sat. A real spam bot on a busy channel shares its stream key with everyone else entering through the same repeater; a bot on a channel byte and a 3-byte hop that nothing else carries has the key to itself and gives the detector a clean train. So this round's panel is paired: each shape appears twice, at the same time, on two different keys.

    • shared — the key of real background traffic, taken from the keys ranking by row count. This is the arm that measures detection inside real traffic, and it is the primary one.
    • isolated — the same channel byte with the 3-byte first hop fdff0i, a key no real relayed line carries. This is round 1's situation, kept as a control, so the cost of key sharing is measured rather than assumed.

    The twins never interact: they are different stream keys, and no channel-scope rule runs in this round.

    Injected traffic is clearly synthetic and never enters a database: inject has no -db flag and opens no database, every injected line's id is inj-<bot>-<pulse>-<part> (an export numbers its lines tx1, tx2, …), and the only artefacts are files that stay with the coordinator. Since this round the injected lines also match the export's shape: whole-second times, a varying payload size and a whole observed route, so the only way to tell them apart is the id marker.

    6a. How the shared keys are chosen — mechanically, and blind to results

    corescope-anomaly keys reads bg.jsonl and counts rows. It never runs the detector, reads no report, no labels and no metrics, and cannot know which keys would alarm. Its ranking is row count, ties broken by the canonical key encoding. Its filter is fixed and is restated in its own output, with a counter per skip reason: payload type 5, a channel hash present, route kind relay (only then is the first hop part of the stream key), and the channel not in -exclude-channels.

    The assignment rule: panel position i (0-based, §6) takes the key of rank i+1. Nothing else is consulted. The shared bot's channel, route_class, route_kind, first_hop and full_route are that key's, its payload_bytes is the key's payload_bytes_median and its payload_bytes_spread the key's payload_bytes_spread ((p90 − p10)/2), its wire_bytes the key's wire_bytes_median. The isolated twin takes the same channel and sizes with first hop and route 3:fdff0i.

    If the ranking holds fewer than 9 keys, the panel shrinks to the ranks that exist and the missing pairs are reported as no key available; see §9 for when that makes an arm inconclusive.

    Running keys is the only thing done to the export before the panel is fixed, and keys.json's sha256 is reported with the results so the assignment can be re-derived.

    6. Injection panel (fixed, generated mechanically)

    Nine shapes, each 12 h long, 18 h apart, each as a shared/isolated pair starting at the same time: 18 bots. Shapes: clean = jitter only; noisy = jitter + 5 % missed pulses + 30 % multi-part pulses (1–2 later parts), which is the September bot's documented shape from #130; sept = noisy plus the one 35-minute outage.

    Jitter comes from #130's measurements, not from the rule. Round 1 scaled the jitter to the rule's own per-gap tolerance, which placed every bot inside that tolerance by construction and made recall conditional on the rule being evaluated. #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. So every bot gets jitter_sd 3 s and jitter_max 9 s, for every period, independent of any rule value. Pulses of the short-period bots therefore do fall outside a tight tolerance: that is deliberate, and it is the rule's job to cope.

    i shape shared key isolated hop start (V.start + …) period jitter sd / max missed multi-part hole
    0 b0-90s-clean rank 1 3:fdff00 +1 h 90s 3s / 9s 0 0 –
    1 b1-120s-clean rank 2 3:fdff01 +19 h 120s 3s / 9s 0 0 –
    2 b2-300s-clean rank 3 3:fdff02 +37 h 300s 3s / 9s 0 0 –
    3 b3-600s-clean rank 4 3:fdff03 +55 h 600s 3s / 9s 0 0 –
    4 b4-900s-clean rank 5 3:fdff04 +73 h 900s 3s / 9s 0 0 –
    5 b5-90s-noisy rank 6 3:fdff05 +91 h 90s 3s / 9s 0.05 0.3 –
    6 b6-300s-noisy rank 7 3:fdff06 +109 h 300s 3s / 9s 0.05 0.3 –
    7 b7-900s-noisy rank 8 3:fdff07 +127 h 900s 3s / 9s 0.05 0.3 –
    8 b8-300s-sept rank 9 3:fdff08 +145 h 300s 3s / 9s 0.05 0.3 35 min at +6 h

    Bot names are b<i>-<period>s-<shape>-shared and …-iso. The panel spans V.start + 1 h to V.start + 157 h, which fits inside 7 days. If V is only 5 or 6 whole days, every pair whose 12-hour window does not lie entirely inside V is dropped from the -bots lists and reported as out of window; its events are still injected. The rule is mechanical and uses V's length only.

    b8-300s-sept is the non-blind sanity check: #130 and #447 were tuned on that shape, so both its twins are reported on their own and are not part of any bar. The eight others are the blind panel, in each arm.

    Spec seed 474202610160. The spec is generated by this script, which takes V.start and keys.json as its only inputs (stdlib only), sha256 0f718b5c43b69e9e535e174b21d25cc3d9198cb4b9f3a1757ba332008766c324:

    # make_spec.py - builds the #474 round-2 injection spec deterministically.
    # python3 make_spec.py <V.start RFC3339> <keys.json> > spec.json   (stdlib only)
    #
    # The panel is paired. Each shape appears twice at the same time, on two
    # different stream keys:
    #   shared   - the key of real background traffic, taken from the keys ranking
    #              by row count (rank 1..9 in order). This is the arm that
    #              measures detection inside real traffic.
    #   isolated - the same channel byte with the 3-byte first hop fdff0i, a key
    #              no real relayed line carries. This is the round-1 control.
    # The two twins never interact: they are different keys.
    #
    # Jitter comes from #130's measurements of a real 5-minute bot over 85.2 h
    # (|gap - 300 s| median 3 s, p95 9 s), not from the rule's tolerance: a gap
    # offset is the difference of two per-pulse offsets, so sd(gap) = sqrt(2) *
    # sd(pulse), and p95 9 s gives sd(gap) ~ 9/1.96 ~ 4.6 s and sd(pulse) ~ 3.2 s.
    # So every bot gets jitter_sd 3 s and jitter_max 9 s, whatever its period and
    # whatever the rule's tolerance is.
    import json, sys
    from datetime import datetime, timedelta, timezone
    
    V0 = datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
    RANKING = json.load(open(sys.argv[2]))
    
    
    def iso(t):
        return t.strftime("%Y-%m-%dT%H:%M:%SZ")
    
    
    # Panel: (period seconds, shape). Shapes:
    #   clean = jitter only
    #   noisy = jitter + 5 % missed pulses + 30 % multi-part pulses (#130 shape)
    #   sept  = noisy plus the one 35-minute outage of the September bot
    PANEL = [(90, "clean"), (120, "clean"), (300, "clean"), (600, "clean"), (900, "clean"),
             (90, "noisy"), (300, "noisy"), (900, "noisy"), (300, "sept")]
    HOURS_EACH, HOURS_APART = 12, 18
    JITTER_SD, JITTER_MAX = 3, 9          # #130, measured
    SEED = 474202610160
    
    keys = RANKING["top"]
    if len(keys) < len(PANEL):
        sys.exit("keys ranking has %d keys, need %d" % (len(keys), len(PANEL)))
    
    bots = []
    for i, (p, shape) in enumerate(PANEL):
        k = keys[i]                        # rank i+1, by row count alone
        start = V0 + timedelta(hours=1 + HOURS_APART * i)
        stop = start + timedelta(hours=HOURS_EACH)
        mpd = min(55, p // 4)
        noisy = shape in ("noisy", "sept")
        holes = [{"start": iso(start + timedelta(hours=6)), "duration": "35m"}] if shape == "sept" else []
        payload = k.get("payload_bytes_median") or 35
        spread = min(k.get("payload_bytes_spread") or 0, payload - 1)
        for arm, hop in (("shared", k["first_hop"]), ("iso", "3:fdff%02x" % i)):
            bots.append({
                "name": "b%d-%ds-%s-%s" % (i, p, shape, arm),
                "channel": k["channel"], "payload_type": RANKING["payload_type"],
                "route_class": k["route_class"], "route_kind": k["route_kind"],
                "first_hop": hop, "full_route": k["full_route"] if arm == "shared" else hop,
                "wire_bytes": k["wire_bytes_median"] or 60,
                "payload_bytes": payload, "payload_bytes_spread": spread,
                "start": iso(start), "stop": iso(stop), "period": "%ds" % p,
                "jitter_sd": "%ds" % JITTER_SD, "jitter_max": "%ds" % JITTER_MAX,
                "missed_fraction": 0.05 if noisy else 0.0,
                "multipart_fraction": 0.3 if noisy else 0.0,
                "multipart_max_extra": 2, "multipart_max_delay": "%ds" % mpd,
                "holes": holes,
            })
    
    print(json.dumps({"seed": SEED, "final_after": "30s",
                      "labelling": {"pulse_merge_gap": "60s", "min_period": "1m", "max_period": "1h",
                                    "min_pulses": 8, "max_harmonic": 3,
                                    "tolerance_abs": "30s", "tolerance_rel": 0.1},
                      "bots": bots}, indent=1))

    Because the spec depends on the real export's key ranking, its sha256 cannot be published in advance; it is reported with the results, together with keys.json's. The §11 pipeline check used a synthetic background and gives keys.json sha256 c5d3d2034301cdb7eb7475db66cef5de0608e4646d1d5a8689f649b12f7da231 and spec.json sha256 47143f7199f1665c3b0fd20eef16e306c6d9c732f24c69c93b0e2538fbca1b4d.

    7. Ground truth

    The labels come from inject -labels, computed from the emitted pulses with the #457 §4 periodic recipe, fixed in the spec:

    1. events closer than pulse_merge_gap = 60 s are one pulse, timed at its first event;
    2. P is the median pulse gap inside [1 min, 1 h];
    3. an incident is a maximal run of at least 8 pulses whose every gap is within max(30 s, 0.1·P) of k·P for some k ∈ {1, 2, 3}.

    So the ground truth is what the recipe says, not "one bot, one incident": the sept bot's 35-minute hole is 7 periods, more than k = 3, so each sept twin is two incidents. A full panel labels 20 incidents: one per bot, two for each sept twin (16 blind, 4 sanity).

    Since this round each incident also carries the bot's stream key, which is what makes the shared arm scorable (§8). The labels file holds bot names, stream keys, channel hashes, times, periods and counts. No ids, no whole routes, no observers, no names, no text, no key material. Real traffic is never labelled in this round: no decryption and no sender names are used anywhere.

    8. Matching and metrics

    score implements the #457 §5 periodic column, with the series identity tightened for the shared arm:

    • An episode is a true positive for an incident when they are the same series, [activation, end] overlaps [incident start, incident stop + 4P], and the episode's period P̂ (Periodic.Period of its activation candidate) is within max(30 s, 0.1·P) of k·P for some k ∈ {1, 2, 3}. An episode whose activation carries no periodic evidence is never a true positive.
    • Same series means the same stream key, not the same channel hash. On a busy channel several route groups share the 1-byte hash, and matching on the hash alone would credit any of them to the bot.
    • Every episode that matches no labelled incident is a false positive. In arm A there are no labels, so every episode is one.
    • Differential reading of the shared arm. score -baseline report-a.json marks every arm-B episode that arm A already has on the same key and overlapping in time. Such an episode exists without the injection, so it is neither a detection of the bot nor a false alarm of arm B: it is reported as baseline_episodes. A detection in the shared arm therefore counts only when it is absent from arm A.
    • -bots narrows which incidents the recall and the medians cover; it never turns a matched episode into a false alarm.

    Reported per arm: episodes, baseline episodes, true and false positives, false alarms per day, recall, and for time to detect (also in units of P), stop latency and episodes per detected incident the median, the nearest-rank p75 and the maximum, plus the per-incident table and the false positives per channel hash. Also reported: period_mismatches (overlap and key agree, period does not), episodes_without_period, and the export and replay coverage counters.

    A median alone is not reported as a result. Five of the eight blind shapes are clean, and a clean train alarms at the floor min_pulses allows (7 P), so the median sits on that floor while the realistic shapes take many times longer. That is why there is a p75 bar and a separate report for the realistic subset (§9).

    9. Pass bars (all primary bars must hold; otherwise no-go)

    Every bar below is derived from a measurement that already existed when this comment was posted — #130's bot, round 1's own pilot, and the chatter experiment in the round-1 review of PR #478 (issuecomment-6200888329), which measured, on a bot's own key, recall 0.75 at 1 background message per 30 min and recall 0.50 with median time to detect 12.0 P at 1 per 10 min, and pushed the noisy 300 s shape from 43 P to 69.9 P. No bar comes from the §11 pipeline check.

    Arm Bar Where the number comes from
    A — false alarms on fresh real traffic (primary) false alarms ≤ 0.2/day over E, with the exclusions applied round 1, unchanged
    B-shared — the 8 blind bots on real keys (primary) recall ≥ 0.50 · median time to detect ≤ 24 P · p75 time to detect ≤ 70 P · median stop latency ≤ 4 P · median episodes per detected incident ≤ 2 recall: the lower of the two measured chatter points, since a rank-1 key is at least as busy as 1 msg/10 min. Median: round 1's 12 P doubled, the factor 1.6 the chatter experiment measured on the noisy shape rounded up. p75: the slowest shape measured under chatter, 69.9 P. Stop latency: structural, (K+1)·P with K = 3. Episodes: round 1, unchanged
    B-shared realistic subset (b5/b6/b7-…-shared, separate report) recall ≥ 0.33 (at least one of three), and recall, median, p75 and maximum time to detect reported in full no prior measurement exists of recall by shape under chatter, so the bar is the weakest non-trivial one and the numbers carry the information
    B-iso — the 8 blind twins on isolated keys (control) recall ≥ 0.80 · median time to detect ≤ 12 P exactly round 1's bars, so the control is comparable
    Sanity (b8-300s-sept-shared, b8-300s-sept-iso) reported, no bar #130 and #447 were tuned on that shape

    The primary bars are arm A and B-shared. B-iso is a control: if it fails while B-shared passes, the round is reported as inconsistent and no claim is made. The headline result of the round is the pair (B-shared, B-iso) — the difference between them is the cost of key sharing, which is the thing round 1 could not measure.

    If fewer than 5 blind bots survive §6 (out of window, no key available) in an arm, that arm is inconclusive, which counts as a no-go for any claim; its numbers are still reported. A void round (§3 capacity) has no results. The results report states go or no-go, with each bar's measured value. A no-go is an acceptable outcome. No value from this round becomes a default or a production value, and integration stays a separate issue.

    10. Contamination check and limitations

    • Shared arm: differential, no bot is dropped. Key sharing is the point of this arm, so contamination is handled by the -baseline rule of §8 rather than by exclusion: an arm-B episode that arm A already has on the same key, overlapping in time, is attributed to the background. This rule is mechanical and fixed here; it uses arm A's own report only, and the count is reported.
    • Isolated arm: round 1's rule. The isolated keys must carry no real traffic. That is checked mechanically against the full keys ranking of arm A (keys-full.json): if any of the nine isolated keys appears in it at all, that twin is reported as contaminated and dropped from the -iso -bots list. Every drop is reported.
    • Overlay, not interaction. Injection adds traffic; it cannot remove or reorder real traffic, and the real background is copied through byte for byte. The injected bots are stationary by design, which is the target; a moving spam bot would spread over route groups exactly as anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 round 4 showed, and this round does not measure that.
    • Synthetic shapes. The panel's shapes and its jitter come from anomaly: hold a periodic bot as one episode and detect when it stops (evaluate Cooldown, then train tracking) #130's measurements of one real bot. If real spam looks different, recall on this panel says less than the number suggests.
    • A shared key is not a shared sender. The shared bot enters through the same route group as real traffic, which is what a real bot on a busy channel does, but it is still an overlay: it does not contend for airtime and cannot displace a real transmission.
    • No labelling of real traffic. Arm A counts every episode as a false alarm, including a real spam bot if one exists. That is deliberately conservative: it can only make the false-alarm number worse, never better. Any arm-A episode is published as (channel hash, activation, end, P̂) so a real bot can be recognised later.

    11. Pipeline check on synthetic background only (no real data)

    For the record, the whole pipeline was exercised before this comment with no real data at all: a synthetic Poisson background over 12 route groups (3,711 lines over 7 days, busiest group 1 message per 10 min, the excluded byte 81 the busiest of all), ranked with keys, the paired panel generated from that ranking by make_spec.py, injected (4,423 events, 18 bots, 20 incidents), replayed with round2.json and scored in all four runs.

    Run recall median TTD p75 TTD max TTD median stop eps/incident median / max
    A (false alarms) – – – – – 0 episodes, 0 false alarms
    B-shared (8 blind) 8/8 13.5 P 16.0 P 94.0 P 15 m 10 s 1 / 3
    B-iso (8 blind, control) 8/8 7.0 P 7.0 P 35.0 P 15 m 08 s 1 / 2
    B-shared realistic (b5/b6/b7) 3/3 13.0 P 94.0 P 94.0 P 15 m 08 s 1 / 3

    So on this synthetic background key sharing roughly doubles the median time to detect (7.0 P → 13.5 P) and pushes the worst shape from 35 P to 94 P, while recall stays complete. That is a pipeline check, not a validation, and no bar in §9 was set from it: this background's chatter rate was chosen by hand, the real ranking will be different, and the bars above are what the real background has to meet. The §8 -baseline path was not exercised here, because arm A produced no episode at all on this background; it is covered by the tool's own tests.

    12. Handling of the files

    bg.jsonl, inj.jsonl, keys.json, keys-full.json, spec.json, labels.json, the two reports and the metrics files stay with the coordinator. The export carries no content hashes, names, text, keys or observer identities, but a line can still be re-identified by its time, sizes and route, so only aggregates and (channel hash, start, stop, P) tuples are published. The key ranking and the labels carry first-hop prefixes; those stay with the coordinator too and are not published per key. Injected lines are never written into any database.

  3. adminopenclaw8-sketch commented on Oct 15, 2026

    @adminopenclaw8-sketch
    Collaborator

    Pre-registration — round 3 for the channel-bot target (NEW, SUPERSEDING; fixed before any data is seen)

    This comment replaces round 2 (issuecomment-6202292768) in full, which in turn replaced round 1 (issuecomment-6200066715). Round 2's panel, its size rule, its bars and its §11 numbers are withdrawn and are not a pass of anything. Round 2 was withdrawn because the review of PR #478 showed its pre-registered pipeline could not be run at all 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 inject aborted on the first wide draw. A real GRP_TXT payload comes in 16-byte cipher blocks while wire − payload is only header plus path, so on a real key the spread beats the margin on nearly every key. Fixing it moves the head and make_spec.py, which is why this is a new round rather than a correction. The rule itself is still unchanged (round3.json is byte-identical to round 2's and round 1's config, sha256 5e5b5e7a…).

    No validation data has been seen by the author of this comment: nothing from 2026-10-17 on has been exported, injected, calibrated, labelled, replayed or scored, and no snapshot has been taken. Everything below is fixed now. Changing a rule value, the injection panel, the key-selection rule, the labelling, a metric or a bar after the data is seen is a round 4, reported separately; it is never a pass of round 3. This comment is not edited after posting; a correction is a new comment and starts a new round.

    This round replaces the #457 periodic target (round 2/3/4 on #457), which measured wardriving. It does not touch the rate rule: #473 continues independently.

    Stream keys are observed route groups (payload type + channel hash + route class + route kind + first hop). They are never senders, and no result attributes traffic to a node. 1-byte hops and 1-byte channel hashes are globally ambiguous.

    1. Target definition (phase 1)

    An incident is a sender posting automated, regular-cadence messages on a channel that is not an expected-automation channel. Wardriving is not a target: an app posting a position every 75–123 s while someone drives is expected behaviour, and a moving sender does not count.

    Excluded channels for this round: exactly the 1-byte channel hash 81, the #wardriving byte all six #457 round-4 incidents fell on. The owner may add hashes to this list before the run; adding one after any data is seen is a new round.

    A channel hash is not a channel: excluding 81 also excludes every other channel name that hashes to 81 (about 1/256 of all names), including any spam on them. That is a stated cost of keying on an opaque 1-byte hash, not something the list can avoid.

    2. Tool

    cmd/anomaly from PR #478 at head 743d0f33f6039c2b3e943a3a526304b2312872da, built with cd cmd/anomaly && go build -trimpath -o corescope-anomaly .. The binary used for the run has its sha256 and its go version reported with the results. The macOS/arm64 -trimpath build used for the §11 pipeline check is 7ba72d0c570555fc95ad3d01dac4fd55fe130e66cef339b5e3e391191b8efb4b under go1.27.0 darwin/arm64; -trimpath is pinned here because round 2 reported a bare go build hash that a reviewer's own build did not reproduce. Phases 1–3 of this issue are that PR: the target wording, PeriodicRule.ExcludeChannels, and the offline keys, inject and score subcommands. The library has no default exclusions and the replay golden file is unchanged.

    make_spec.py is now a file in the repository, cmd/anomaly/make_spec.py at the pinned head, sha256 10a306b0987806ae8f3ca5a802dd67842a988bf053e7c6635bc34444222b0f22. Round 2 kept it only inside its pre-registration comment, which is exactly why nothing exercised the step that was broken. It is stdlib-only, takes V.start and keys.json as its only inputs, reads no clock and no results, and the chain export → keys → make_spec.py → inject is now run by the tool's own test suite on a background whose payload sizes are block-quantised like real GRP_TXT traffic.

    3. Data and runs

    Item Fixed value
    Snapshot An offline copy made by scripts/backup-sqlite.sh (container stopped briefly), used as-is, read-only, with -immutable, with no -wal, -shm or -journal beside it; or, where disk allows, sqlite3 -readonly <db> "VACUUM INTO '<snap>/meshcore.db'". The choice is made before any export and is reported. .backup is not used (#457 round 3).
    Validation window V The first 7 whole UTC days present in the snapshot starting at or after 2026-10-17T00:00:00Z — the first whole UTC day that begins after this comment was posted, so no part of V exists yet. The choice uses row counts per UTC day only, never results. Fewer than 5 whole days voids the round; 5 or 6 days shorten the panel by §6.
    Evaluation window E E = V. There is no calibration day: no value in this round is derived from the data.
    Export corescope-anomaly export -db <snap>/meshcore.db -immutable -start <V.start> -end <V.end> -settle 30s -max-rows 50000000 -out bg.jsonl
    Key ranking corescope-anomaly keys -input bg.jsonl -payload-type 5 -top 16 -exclude-channels 81 -max-keys 5000000 -max-line-bytes 4096 -out keys.json (§6a)
    Full key ranking the same with -top 5000000 -out keys-full.json, used only for the §10 absence check of the isolated keys
    Injection spec python3 cmd/anomaly/make_spec.py <V.start> keys.json > spec.json (§6)
    Inject corescope-anomaly inject -spec spec.json -input bg.jsonl -out inj.jsonl -labels labels.json -max-injected 2000000 -max-line-bytes 65536
    Replay, arm A corescope-anomaly replay -config round3.json -input bg.jsonl -start <V.start> -end <V.end> -max-line-bytes 4096 -out report-a.json
    Replay, arm B the same with -input inj.jsonl -out report-b.json
    Score, arm A corescope-anomaly score -report report-a.json -rule p474_periodic -start <V.start> -end <V.end> -out metrics-a.json (no -labels: every episode is a false alarm)
    Score, arm B shared corescope-anomaly score -report report-b.json -labels labels.json -baseline report-a.json -rule p474_periodic -bots <the 8 blind -shared bots> -start <V.start> -end <V.end> -out metrics-b-shared.json
    Score, arm B realistic subset the same with -bots b5-90s-noisy-shared,b6-300s-noisy-shared,b7-900s-noisy-shared -out metrics-b-noisy.json
    Score, arm B isolated control … -labels labels.json -rule p474_periodic -bots <the 8 blind -iso bots> … -out metrics-b-iso.json (no -baseline: an isolated key has no background)
    Score, sanity the same two runs with -bots b8-300s-sept-shared and -bots b8-300s-sept-iso
    Episode One EventID. Its activation is its first transition to active; its end is its last transition to ended, or V.end if it never ended. Only episodes activated in E count.
    Capacity #457 §1a applies verbatim: count caps only (-max-rows, -max-keys and the six limits count caps), mechanical doubling, at most 4 doublings, coverage_reduced on the export voids the round. Every step is reported.

    Two arms, one background. Arm A is the fresh real traffic with the exclusions applied and nothing injected: it measures false alarms. Arm B is the same export with the synthetic panel overlaid: it measures recall and timing. Arm B's background is byte for byte arm A's.

    4. Rule config (round3.json)

    Fixed as written, and unchanged from rounds 1 and 2: this round changes the injected sizes and the scoring, not the rule. It is the #457 round-2 periodic rule (the #130/#447 variant: burst gap 15 s, jitter 10 s, tracking K = 3, quiet 60 min as the outer bound), plus the exclusion list of §1. No rate or new-stream rule runs in this round. The file was checked with ParseReplayConfig and New on the pinned head.

    {
     "limits": {"max_keys_per_scope": 200000, "state_ttl": "48h", "dedup_horizon": "6h", "max_dedup_ids": 2000000,
      "reorder_delay": "0s", "max_reorder_buffer": 64, "max_window_entries": 200000,
      "max_candidates_per_call": 10000, "max_pending_candidates": 100000},
     "expected": "include",
     "periodic": [{"name": "p474_periodic", "scope": "stream", "payload_types": [5],
      "min_period": "1m", "max_period": "1h", "burst_gap": "15s", "jitter_abs": "10s", "jitter_rel": 0.02,
      "max_missing": 2, "min_pulses": 8, "history_len": 32, "min_coverage": 0.6, "max_jitter_fraction": 0.5,
      "max_chance": 0.01, "end_after_missed_pulses": 3, "exclude_channels": ["81"],
      "state": {"confirm_after": "0s", "end_after_quiet": "60m", "cooldown": "0s"}}]
    }

    sha256 5e5b5e7a6610a46192873872410aee59cc8b1690cc8f1f5447996c3ba0c6e0dd — byte-identical to rounds 1 and 2.

    5. Why injection, on real keys, at real sizes

    Fresh data may contain no spam bot at all, and a blind run on real traffic alone would then be "inconclusive". So recall is measured on synthetic bots overlaid on the same real background, and false alarms are measured on the real background alone.

    Round 1's mistake was where the bots sat. A real spam bot on a busy channel shares its stream key with everyone else entering through the same repeater; a bot on a channel byte and a 3-byte hop that nothing else carries has the key to itself and gives the detector a clean train. So the panel is paired — each shape appears twice, at the same time, on two different keys:

    • shared — the key of real background traffic, taken from the keys ranking by row count. This is the arm that measures detection inside real traffic, and it is the primary one.
    • isolated — the same channel byte with the 3-byte first hop fdff0i, a key no real relayed line carries. This is round 1's situation, kept as a control, so the cost of key sharing is measured rather than assumed.

    Round 2's mistake was how big the bots' frames were. It described a bot's sizes as a fixed median wire_bytes plus a payload drawn from payload_bytes ± (p90 − p10)/2. That gets both halves wrong:

    • The two sizes are not independent. A frame is header plus path plus payload, so wire − payload is the route's own overhead (7–14 bytes on the fixture, 3 bytes on a one-hop path), while the payload itself comes in 16-byte cipher blocks. The observed GRP_TXT payload sizes step 35, 51, 67, 83, …, so the spread is many times the margin wire − 2 allows. The drawn payload therefore exceeded the frame and inject aborted — the pre-registered pipeline of round 2 does not complete on a real key.
    • Even where it fitted, a uniform draw produces sizes real GRP_TXT never carries (47, 59) and a constant wire makes wire − payload vary per line where a real line ties it to the route. So round 2's claim that "the only way to tell them apart is the id marker" was not true.

    This round draws whole observed (wire_bytes, payload_bytes) pairs from the key's own distribution, which keys now reports as size_mix (one entry per distinct pair with the number of lines that carried it). Every pair came off a real line, so it is valid by construction and carries the route's own overhead: an injected line cannot be told from a real one on size, and inject cannot abort on a size. Beyond that, every size a bot can draw is checked against the event validator while the spec is read — before a single event, line or file exists — so an impossible size is a spec error with a message naming the fields, never an abort thousands of lines into a run.

    Injected traffic is clearly synthetic and never enters a database: inject has no -db flag and opens no database, every injected line's id is inj-<bot>-<pulse>-<part> (an export numbers its lines tx1, tx2, …), and the only artefacts are files that stay with the coordinator. Injected lines otherwise match the export's shape: whole-second times (truncated, as the export's are), a whole observed route, and now the key's own size distribution.

    6a. How the shared keys are chosen — mechanically, and blind to results

    corescope-anomaly keys reads bg.jsonl and counts rows. It never runs the detector, reads no report, no labels and no metrics, and cannot know which keys would alarm. Its ranking is row count, ties broken by the smaller canonical key encoding (that order is part of the assignment rule and is pinned by a test since this round). Its filter is fixed and is restated in its own output, with a counter per skip reason: payload type 5, a channel hash present, route kind relay (only then is the first hop part of the stream key), and the channel not in -exclude-channels. -top reports exactly that many keys.

    The assignment rule: panel position i (0-based, §6) takes the key of rank i+1. Nothing else is consulted. The shared bot's channel, route_class, route_kind, first_hop and full_route are that key's, and its sizes are that key's size_mix, passed through verbatim. The isolated twin takes the same channel and the same size_mix with first hop and route 3:fdff0i. A rank whose size_mix is empty aborts the generator rather than falling back to a made-up size.

    If the ranking holds fewer than 9 keys, the panel shrinks to the ranks that exist and the missing pairs are reported as no key available; see §9 for when that makes an arm inconclusive.

    Running keys is the only thing done to the export before the panel is fixed, and keys.json's sha256 is reported with the results so the assignment can be re-derived.

    6. Injection panel (fixed, generated mechanically)

    Nine shapes, each 12 h long, 18 h apart, each as a shared/isolated pair starting at the same time: 18 bots. Shapes: clean = jitter only; noisy = jitter + 5 % missed pulses + 30 % multi-part pulses (1–2 later parts), which is the September bot's documented shape from #130; sept = noisy plus the one 35-minute outage. Unchanged from round 2 apart from the sizes and the seed.

    Jitter comes from #130's measurements, not from the rule. #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. So every bot gets jitter_sd 3 s and jitter_max 9 s, for every period, independent of any rule value. Pulses of the short-period bots therefore do fall outside a tight tolerance: that is deliberate, and it is the rule's job to cope.

    i shape shared key isolated hop start (V.start + …) period jitter sd / max missed multi-part hole
    0 b0-90s-clean rank 1 3:fdff00 +1 h 90s 3s / 9s 0 0 –
    1 b1-120s-clean rank 2 3:fdff01 +19 h 120s 3s / 9s 0 0 –
    2 b2-300s-clean rank 3 3:fdff02 +37 h 300s 3s / 9s 0 0 –
    3 b3-600s-clean rank 4 3:fdff03 +55 h 600s 3s / 9s 0 0 –
    4 b4-900s-clean rank 5 3:fdff04 +73 h 900s 3s / 9s 0 0 –
    5 b5-90s-noisy rank 6 3:fdff05 +91 h 90s 3s / 9s 0.05 0.3 –
    6 b6-300s-noisy rank 7 3:fdff06 +109 h 300s 3s / 9s 0.05 0.3 –
    7 b7-900s-noisy rank 8 3:fdff07 +127 h 900s 3s / 9s 0.05 0.3 –
    8 b8-300s-sept rank 9 3:fdff08 +145 h 300s 3s / 9s 0.05 0.3 35 min at +6 h

    Bot names are b<i>-<period>s-<shape>-shared and …-iso. The panel spans V.start + 1 h to V.start + 157 h, which fits inside 7 days. If V is only 5 or 6 whole days, every pair whose 12-hour window does not lie entirely inside V is dropped from the -bots lists and reported as out of window; its events are still injected. The rule is mechanical and uses V's length only.

    b8-300s-sept is the non-blind sanity check: #130 and #447 were tuned on that shape, so both its twins are reported on their own and are not part of any bar. The eight others are the blind panel, in each arm.

    Spec seed 474202610170. The spec is generated by cmd/anomaly/make_spec.py at the pinned head (sha256 10a306b0…, §2), whose only inputs are V.start and keys.json. Because the spec depends on the real export's key ranking, its own sha256 cannot be published in advance; it is reported with the results, together with keys.json's.

    7. Ground truth

    The labels come from inject -labels, computed from the emitted pulses with the #457 §4 periodic recipe, fixed in the spec:

    1. events closer than pulse_merge_gap = 60 s are one pulse, timed at its first event;
    2. P is the median pulse gap inside [1 min, 1 h];
    3. an incident is a maximal run of at least 8 pulses whose every gap is within max(30 s, 0.1·P) of k·P for some k ∈ {1, 2, 3}.

    So the ground truth is what the recipe says, not "one bot, one incident": the sept bot's 35-minute hole is 7 periods, more than k = 3, so each sept twin is two incidents. A full panel labels 20 incidents: one per bot, two for each sept twin (16 blind, 4 sanity).

    Each incident carries the bot's stream key, which is what makes the shared arm scorable (§8). The labels file holds bot names, stream keys, channel hashes, times, periods and counts. No ids, no whole routes, no observers, no names, no text, no key material. Real traffic is never labelled in this round: no decryption and no sender names are used anywhere.

    8. Matching and metrics

    score implements the #457 §5 periodic column, with the series identity tightened for the shared arm and the timing tail tightened against a missed bot:

    • An episode is a true positive for an incident when they are the same series, [activation, end] overlaps [incident start, incident stop + 4P], and the episode's period P̂ (Periodic.Period of its activation candidate) is within max(30 s, 0.1·P) of k·P for some k ∈ {1, 2, 3}. An episode whose activation carries no periodic evidence is never a true positive.
    • Same series means the same stream key, not the same channel hash. On a busy channel several route groups share the 1-byte hash, and matching on the hash alone would credit any of them to the bot.
    • Differential reading of the shared arm. score -baseline report-a.json marks every arm-B episode that arm A already has on the same key and overlapping in time (closed at both ends — a touching episode counts, which is pinned by a test since this round). Such an episode exists without the injection, so it is neither a detection of the bot nor a false alarm of arm B: it is reported as baseline_episodes. A detection in the shared arm therefore counts only when it is absent from arm A.
    • Masking is visible per incident. When the only episode of an incident's own series is absorbed by the baseline, the row 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 — round 2 reported both as a plain miss.
    • Every episode that matches no labelled incident is a false positive. In arm A there are no labels, so every episode is one.
    • -bots narrows which incidents the recall and the medians cover; it never turns a matched episode into a false alarm.

    The timing tail is reported twice, and the bar goes on the second. Round 2's medians and p75s were 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 (7 P) and whose four realistic ones are missed reports recall 0.50 and a p75 near 7 P, and passes every round-2 timing bar on a result that found nothing realistic. The review of PR #478 pointed this out (G3), and this round fixes it:

    • The detected tail (median_time_to_detect, p75_…, max_…, with _periods beside each) describes the shapes that were found. It is reported in full and carries no primary bar, because it cannot fail on a miss.
    • The selected tail (median_time_to_detect_selected, p75_time_to_detect_selected) is the nearest-rank percentile over every selected incident, with a missed incident ranked above every finite value — a miss counts as +∞. JSON has no infinity, so a percentile whose rank falls among the misses is reported as ..._selected_infinite: true with the value left out, which fails any bar placed on it. incidents_missed counts the misses.

    Also reported per arm: episodes, baseline episodes, true and false positives, false alarms per day, recall, stop latency and episodes per detected incident (median, p75, maximum), the per-incident table, the false positives per channel hash, period_mismatches, episodes_without_period, and the export and replay coverage counters.

    9. Pass bars (all primary bars must hold; otherwise no-go)

    Every bar below is derived from a measurement that already existed when this comment was posted — #130's bot, round 1's own pilot, and the chatter experiment in the round-1 review of PR #478 (issuecomment-6200888329), which measured, on a bot's own key, recall 0.75 at 1 background message per 30 min and recall 0.50 with median time to detect 12.0 P at 1 per 10 min, and pushed the noisy 300 s shape from 43 P to 69.9 P. No bar comes from any §11 pipeline check, of this round or of round 2.

    Arm Bar Where the number comes from
    A — false alarms on fresh real traffic (primary) false alarms ≤ 0.2/day over E, with the exclusions applied round 1, unchanged
    B-shared — the 8 blind bots on real keys (primary) recall ≥ 0.50 · median time to detect over the selected incidents ≤ 24 P (a miss = +∞) · median stop latency ≤ 4 P · median episodes per detected incident ≤ 2 recall: the lower of the two measured chatter points, since a rank-1 key is at least as busy as 1 msg/10 min. Median: round 1's 12 P doubled, the factor 1.6 the chatter experiment measured on the noisy shape rounded up — now over the selected incidents, so it fails on a missed bot. Stop latency: structural, (K+1)·P with K = 3. Episodes: round 1, unchanged
    B-shared realistic subset (b5/b6/b7-…-shared, separate report) recall ≥ 0.50 — on three incidents that means 2 of 3 · median time to detect over the selected subset ≤ 70 P (a miss = +∞) · the whole tail reported recall: the same measured chatter point as the primary recall bar, which on three incidents raises round 2's trivial 1-of-3 bar to 2-of-3 (the review's G3 asked for exactly this). Median: the slowest shape measured under chatter, 69.9 P
    B-iso — the 8 blind twins on isolated keys (control) recall ≥ 0.80 · median time to detect over the selected twins ≤ 12 P exactly round 1's bars, so the control is comparable
    Sanity (b8-300s-sept-shared, b8-300s-sept-iso) reported, no bar #130 and #447 were tuned on that shape

    Why the bar is on the median-over-selected and not on the p75-over-selected. With 8 selected incidents the nearest-rank median is the 4th value, so it is finite exactly when at least 4 of 8 are detected — i.e. exactly at recall ≥ 0.50, the recall bar. The two bars are therefore consistent, and neither implies the other: the recall bar says how many were found, the median bar says how fast the slower half of those was. A bar on the p75-over-selected would be the 6th of 8 values and so would silently require recall ≥ 0.75, which no prior measurement supports; it is reported in full instead. The same arithmetic gives the realistic subset's 2-of-3 (median of 3 = 2nd value).

    The primary bars are arm A and B-shared. B-iso is a control: if it fails while B-shared passes, the round is reported as inconsistent and no claim is made. The headline result of the round is the pair (B-shared, B-iso) — the difference between them is the cost of key sharing.

    If fewer than 5 blind bots survive §6 (out of window, no key available) in an arm, that arm is inconclusive, which counts as a no-go for any claim; its numbers are still reported. A void round (§3 capacity) has no results. The results report states go or no-go, with each bar's measured value. A no-go is an acceptable outcome. No value from this round becomes a default or a production value, and integration stays a separate issue.

    10. Contamination check and limitations

    • Shared arm: differential, no bot is dropped. Key sharing is the point of this arm, so contamination is handled by the -baseline rule of §8 rather than by exclusion, and it is now visible per incident (baseline_masked). This rule is mechanical and fixed here; it uses arm A's own report only, and the counts are reported.
    • Isolated arm: round 1's rule. The isolated keys must carry no real traffic. That is checked mechanically against the full keys ranking of arm A (keys-full.json): if any of the nine isolated keys appears in it at all, that twin is reported as contaminated and dropped from the -iso -bots list. Every drop is reported.
    • Overlay, not interaction. Injection adds traffic; it cannot remove or reorder real traffic, and the real background is copied through byte for byte. The injected bots are stationary by design, which is the target; a moving spam bot would spread over route groups exactly as anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 round 4 showed, and this round does not measure that.
    • Synthetic shapes, real sizes. The panel's shapes and its jitter come from anomaly: hold a periodic bot as one episode and detect when it stops (evaluate Cooldown, then train tracking) #130's measurements of one real bot; its frame sizes come from the key's own observed distribution. If real spam looks different in shape, recall on this panel says less than the number suggests.
    • A shared key is not a shared sender. The shared bot enters through the same route group as real traffic, which is what a real bot on a busy channel does, but it is still an overlay: it does not contend for airtime and cannot displace a real transmission.
    • No labelling of real traffic. Arm A counts every episode as a false alarm, including a real spam bot if one exists. That is deliberately conservative: it can only make the false-alarm number worse, never better. Any arm-A episode is published as (channel hash, activation, end, P̂) so a real bot can be recognised later.

    11. Pipeline check on synthetic background only (no real data)

    For the record, the whole pipeline was exercised before this comment with no real data at all: a synthetic Poisson background over 12 route groups (4,945 lines over 7 days, busiest group 1 message per 10 min, the excluded byte 81 the busiest of all), with GRP_TXT payloads in 16-byte cipher blocks and wire = payload + 2 + path width — the size structure that broke round 2. Ranked with keys (11 keys after the exclusion, keys.json sha256 9559608b…), the paired panel generated by make_spec.py (spec.json sha256 e7dcc9a4…), injected (4,434 events, 18 bots, 20 incidents), replayed with round3.json and scored in all four runs.

    Round 2's own make_spec.py was run on the same ranking first. Extracted from issuecomment-6202292768 it reproduces its published sha256 0f718b5c…, and its spec is refused: bot "b0-90s-clean-shared": payload_bytes + payload_bytes_spread must fit the frame (<= wire_bytes - 2), rc 1. Rank 1 has wire_bytes_median 38 with payload_bytes_median 35 ± 24, so a draw reaches 59 where the frame allows 36. That is G1, reproduced on this background; round 2 hit it thousands of lines into the run instead of at the spec.

    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 b3-600s-clean-shared and b4-900s-clean-shared, the two longest clean periods — on this background key sharing costs recall on the slow shapes while the isolated twins are all found. None of the nine isolated keys appears in the full ranking, so the §10 absence check passes with 0 contaminated.

    The -baseline path was exercised too, which round 2's pipeline check could not do (its arm A produced no episode at all). A second background adds one real 5-minute train of 120 lines on rank 1's own key for 10 h from V.start + 30 min, and nothing else changes. Arm A then has 1 episode (1 false alarm). In arm B that episode absorbs b0-90s-clean-shared's: without -baseline the run reads recall 6/8 = 0.75 and 0 baseline episodes; with -baseline it reads recall 5/8 = 0.62, baseline_episodes 1, incidents_baseline_masked 1, and b0's own row carries baseline_masked: true instead of reading as a plain miss. That is the §8 differential rule and the G5 flag, end to end.

    This is a pipeline check, not a validation, and no bar in §9 was set from it: this background's chatter rate and size distribution were chosen by hand, the real ranking will be different, and the bars above are what the real background has to meet.

    12. Handling of the files

    bg.jsonl, inj.jsonl, keys.json, keys-full.json, spec.json, labels.json, the two reports and the metrics files stay with the coordinator. The export carries no content hashes, names, text, keys or observer identities, but a line can still be re-identified by its time, sizes and route, so only aggregates and (channel hash, start, stop, P) tuples are published. The key ranking and the labels carry first-hop prefixes and now a full size distribution per key; those stay with the coordinator too and are not published per key. Injected lines are never written into any database.

  4. adminopenclaw8-sketch commented on Oct 15, 2026

    @adminopenclaw8-sketch
    Collaborator

    Note on the round-3 pre-registration — no fixed value changes

    issuecomment-6203588711 was posted at 2026-10-15T22:28:57Z. Its §3 gloss calls V's start "the first whole UTC day that begins after this comment was posted", which is off by one day against that timestamp: the first such day is 2026-10-16T00:00:00Z.

    The fixed value is unchanged and stands as written: V starts at or after 2026-10-17T00:00:00Z. It is strictly later than required, so the property the gloss exists to guarantee — that no part of V existed when the pre-registration was posted — holds with a full extra day of margin. Nothing about the window, the panel, the labelling, a metric or a bar moves, so this is not a round 4 and not a correction of a pre-registered value; it records an imprecise parenthetical in the rationale, and the binding date is the one in the table.

    The cause: the pre-registration was drafted against a local date of 2026-10-16 while GitHub stamped the comment on 2026-10-15.

  5. dborup commented on Oct 24, 2026

    @dborup
    OwnerAuthor

    Round 3 results: no-go. Arm A fails on false alarms; the detection arms pass.

    Run per the round-3 pre-registration (issuecomment-6203588711) and its note. Only aggregates and (channel hash, activation, end, P̂) tuples are published (§12).

    Setup (§2, §3)

    Item Value
    Snapshot Offline copy from scripts/backup-sqlite.sh (container stopped briefly), taken 2026-10-24T05:03:53Z. Used as-is, read-only, with -immutable. No -wal, -shm or -journal file beside it. This choice was made before any export.
    V = E [2026-10-17T00:00:00Z, 2026-10-24T00:00:00Z): the first 7 whole UTC days from 2026-10-17, chosen from per-day row counts only. All 7 days are present, so the full panel applies.
    Tool cmd/anomaly at 743d0f33f6039c2b3e943a3a526304b2312872da, go build -trimpath for linux/amd64 with go1.26.3. Binary sha256 540cc23d2ff6a77b929565f03f6058f8489e0b511260a960ad28c3d3f262dc30. make_spec.py sha256 10a306b0…, which matches §2.
    round3.json sha256 5e5b5e7a6610a46192873872410aee59cc8b1690cc8f1f5447996c3ba0c6e0dd, which matches §4.
    Excluded channels 81 only. No hashes were added.
    Export Exit code 0, coverage_reduced: false, rows 2,418,992, events 150,785. No capacity step was needed.
    keys 5,982 lines considered, 1,272 distinct keys, so all 9 ranks exist. keys.json sha256 549c8df7…, keys-full.json sha256 b060c14c….
    Spec / inject spec.json sha256 206a2c84…: 18 bots, 20 incidents. inject exit code 0. labels.json sha256 f32bcbb2….
    Contamination (§10) 0 of the 9 isolated keys (3:fdff00–08) appear in the full ranking, so no twin is dropped.
    Replays Exit code 0 for both, CoverageReduced: false. report-a.json sha256 f5b62a59…, report-b.json sha256 570af2c1….

    Bars (§9)

    Arm Bar Measured
    A: false alarms on real traffic (primary) ≤ 0.2/day 2.43/day (17 episodes / 7 days) ❌
    B-shared, 8 blind (primary) recall ≥ 0.50 1.00 (8/8) ✅
    median TTD over selected ≤ 24 P 7.0 P (none missed) ✅
    median stop latency ≤ 4 P ≈ 3.0 P (per bot 3.0–3.1 P) ✅
    median episodes per detected ≤ 2 1 (max 3) ✅
    B-shared realistic (b5, b6, b7) recall ≥ 2/3 3/3 ✅
    median TTD over selected ≤ 70 P 8.0 P (p75 12.0 P) ✅
    B-iso, 8 blind (control) recall ≥ 0.80 1.00 (8/8) ✅
    median TTD over selected ≤ 12 P 7.0 P (max 115 P, b6-300s-noisy-iso) ✅
    Sanity b8 (no bar) shared: 2/2 incidents (17 P and 8 P). iso: 2/2 (16 P and 42 P).

    Further results:

    • Baseline masking. B-shared has 16 baseline_episodes, which are arm A's episodes, and 0 incidents_baseline_masked. No period mismatches, and no episode without a period.
    • Arm B false positives. B-shared has 0 false positives. B-iso has 16 false positives; these are arm A's real-traffic episodes, because the iso scoring runs without -baseline by design.

    Round result: no-go, because primary arm A fails. B-shared and the B-iso control both pass, so the round is not inconsistent. On this panel, key sharing cost nothing in recall or in median time to detect.

    Arm A episodes (§10: published so a real sender can be recognised)

    All 17 are on channel hash 56, between 2026-10-18T22:16Z and 2026-10-19T12:02Z, each with P̂ ≈ 60 s:

    ch activation end P̂
    56 2026-10-18T22:16:51Z 22:23:16Z 60 s
    56 2026-10-18T22:53:52Z 22:58:14Z 63 s
    56 2026-10-18T23:37:56Z 23:46:02Z 60 s
    56 2026-10-19T00:06:54Z 00:39:06Z 60 s
    56 2026-10-19T01:07:56Z 02:00:06Z 60 s
    56 2026-10-19T03:00:55Z 03:04:06Z 60 s
    56 2026-10-19T03:17:58Z 03:51:04Z 60 s
    56 2026-10-19T04:26:02Z 04:43:10Z 60 s
    56 2026-10-19T04:59:59Z 05:26:07Z 60 s
    56 2026-10-19T05:55:22Z 06:13:31Z 60 s
    56 2026-10-19T07:58:33Z 08:12:47Z 62 s
    56 2026-10-19T08:17:35Z 08:26:50Z 60 s
    56 2026-10-19T08:35:38Z 08:40:47Z 60 s
    56 2026-10-19T09:02:57Z 09:08:14Z 60 s
    56 2026-10-19T10:25:26Z 11:02:28Z 64 s
    56 2026-10-19T11:17:24Z 11:32:27Z 60 s
    56 2026-10-19T11:46:04Z 12:02:14Z 60 s

    Reading

    • What fails. The bar fails on one ~14-hour stretch of a steady ~1-minute cadence on channel hash 56, entering through several route groups. Outside that stretch, arm A had zero episodes on the other 6 days. As fixed in §10, every arm-A episode counts as a false alarm, including a real spam bot if one exists, so this round cannot tell which one it is.
    • Possible real sender. The same stretch also holds all three GRP_TXT rate storms of anomaly round 2: rate rule with an ingest-backlog guard, blind validation on fresh data #473 (2026-10-19 05:35, 09:30 and 10:10Z). A real automated sender on that channel would explain both. Checking that would be a post-hoc look at real traffic (decryption, sender names) outside this pre-registration. If it is done, it is reported separately and does not change this result.
    • What passes. Detection of stationary bots inside real traffic: all 8 blind shared bots, all at the 7–12 P floor, with no baseline masking.

    No value from this round becomes a default or a production value. The files stay with the coordinator (§12).

  6. dborup commented on Oct 24, 2026

    @dborup
    OwnerAuthor

    Post-hoc check of channel hash 56 (outside the pre-registration; does not change the round-3 result)

    This check was done after the round-3 results were posted (issuecomment-6380350105), on the same offline snapshot. It reads only aggregate counts, timing and sizes of real traffic. Round 3 stays no-go as pre-registered.

    Decryption is not possible. All 966 GRP_TXT transmissions on hash 56 between 2026-10-18T20:00Z and 2026-10-19T14:00Z are decryption_failed, so this is a channel we hold no key for. No sender and no text can be seen.

    Aggregate pattern

    Value
    GRP_TXT on hash 56 per day 10-10: 1 · 10-12: 1 · 10-18: 152 · 10-19: 900 · 10-20: 46 · otherwise 0 (2026-10-10 … 10-24)
    Active period About 2026-10-18T20:00Z to 2026-10-20 around 12:00Z, starting and stopping abruptly
    Rate 35–57 per hour through the night, then 73–128 per hour 2026-10-19 05:00–11:00. That busier stretch holds the three #473 GRP_TXT storms.
    Gaps between consecutive transmissions (965 gaps, 10-18 20:00 to 10-19 14:00) Median 58 s. 477 gaps (49 %) lie at 50–69 s. Further clusters lie at ≈ 2P (110–129 s: 73), ≈ 3P (180 s: 10) and ≈ 4P (240 s: 5), which is the signature of missed pulses. 306 gaps are under 20 s (multi-part messages, or a second sender interleaved).
    Encrypted payload size 32 bytes on 965 of 966, 48 bytes on 1

    Reading

    • Likely a real automated sender. A channel silent for weeks suddenly carries ~1,100 messages in ~40 h, at a steady ~60 s cadence with missed-pulse multiples, with an almost constant message length, and then stops. That fits an automated sender on a private channel far better than human chat.
    • What the 17 arm-A episodes were. They are then the detector finding a real periodic sender, which is the target behaviour. Under §10 they count as false alarms, and that is fixed for this round.
    • The rest of E. Outside that sender's active period, arm A had 0 episodes over the other ~5.5 days.
    • Not a verdict on the sender. Without the channel key it cannot be confirmed whether this was spam, a test bot or a sensor feed.

    Suggestion for a later round, not a change to this one. A new pre-registration could define real-traffic ground truth for channels we cannot decrypt from aggregate signals fixed in advance, such as a silent-then-burst channel with a stable cadence and constant size. That would score arm A against it instead of counting every episode as a false alarm.

  7. added a commit that references this issue on Oct 24, 2026
  8. dborup commented on Oct 24, 2026

    @dborup
    OwnerAuthor

    Pre-registration — round 4 for the channel-bot target (NEW; fixed before any data is seen)

    Round 3 (issuecomment-6203588711 and its note issuecomment-6203595309) is complete and stays no-go (issuecomment-6380350105). This round is a new round, not a re-scoring of round 3: it runs on a new V (§3), and none of round 3's data files is reused. It changes only arm A's scoring.

    • Byte-identical to round 3: exactly two files, the rule round3.json (sha256 5e5b5e7a…) and make_spec.py (sha256 10a306b0…, seed 474202610170).
    • Same definition, new output over the new V: the export command, keys, the panel rule (make_spec.py applied to the new keys.json), inject, the arm-B scoring with -baseline, and the arm-B bars. The export bg.jsonl, keys.json, keys-full.json, spec.json, inj.jsonl, labels.json, every report and every metrics file are new.
    • Same behaviour: without -real-labels, the tool at 1066ef1 writes byte-identical artefacts to round 3's tool on the same input (an A/B on a synthetic round-3 chain and on the committed fixture).

    No validation data has been seen by the author of this comment. Nothing from V (§3) exists yet, since V starts after this comment is posted. Everything below is fixed now. Changing a rule value, the panel, the labelling, a metric or a bar after the data is seen is a round 5, reported separately. It is never a pass of round 4. This comment is not edited after posting; a correction is a new comment and starts a new round.

    The post-hoc look at hash 56 (issuecomment-6380380598) is why this round exists. It sets no value below. Every value comes from data dated before 2026-10-17, or from synthetic data (§5). Hash 56's active period lies in round 3's V, not in this round's.

    Stream keys are observed route groups (payload type + channel hash + route class + route kind + first hop). They are never senders, and no result attributes traffic to a node. 1-byte hops and 1-byte channel hashes are globally ambiguous.

    1. Target definition (unchanged)

    An incident is a sender posting automated, regular-cadence messages on a channel that is not an expected-automation channel. Wardriving is not a target. Excluded channels: exactly 81. Excluding a hash also excludes every other channel name that hashes to it.

    2. Tool

    cmd/anomaly at 1066ef1 (the reviewed head of this PR), built from a clean git checkout of 1066ef1 with cd cmd/anomaly && go build -trimpath -o corescope-anomaly . in the default build environment. The binary's sha256, go version and the build lines of go version -m corescope-anomaly are reported with the results.

    The reference sha256 is reproducible only from a clean git checkout of the head. The binary embeds the commit and tree state (vcs.revision, vcs.modified) and the build settings (CGO_ENABLED, GOAMD64). A build from a git archive export or a tarball of the same commit has no .git, so no VCS stamp, and hashes differently with no code difference; so does a build from a modified tree or with other build settings. For reference, the linux/amd64 -trimpath build of 1066ef1b under go1.27.1 with the defaults (CGO_ENABLED=1, GOAMD64=v1) is 1bd6f84bbeae32b53e96102832d9fe47365be16c21119e50cdfc650b3db7b1be from a clean git checkout, and a3ac9d87891c78336b0a8e708e8f13b726ce568739b2d94dad1304c4a89bb4a6 from a git archive export of the same commit. The checkout number must be refreshed whenever the head moves, even without a code change. make_spec.py at the pinned head has sha256 10a306b0987806ae8f3ca5a802dd67842a988bf053e7c6635bc34444222b0f22, unchanged from round 3.

    3. Data and runs

    Unchanged from round 3 except V and the arm-A rows marked new.

    Item Fixed value
    Snapshot As round 3: an offline copy made by scripts/backup-sqlite.sh, used as-is, read-only, -immutable, with no -wal, -shm or -journal beside it; or VACUUM INTO. The choice is made before any export and is reported.
    Validation window V Defined below the table. The day rule is new in round 4: a day counts only when it holds at least half the median day before V.
    Evaluation window E E = V
    Export corescope-anomaly export -db <snap>/meshcore.db -immutable -start <V.start> -end <V.end> -settle 30s -max-rows 50000000 -out bg.jsonl
    Key ranking corescope-anomaly keys -input bg.jsonl -payload-type 5 -top 16 -exclude-channels 81 -max-keys 5000000 -max-line-bytes 4096 -out keys.json; full ranking the same with -top 5000000 -out keys-full.json
    Real-sender labels (new) corescope-anomaly label -input bg.jsonl -payload-type 5 -exclude-channels 81 -start <V.start> -end <V.end> -pulse-merge-gap 60s -min-period 1m -max-period 1h -min-pulses 8 -max-harmonic 3 -tolerance-abs 30s -tolerance-rel 0.1 -min-size-share 0.95 -max-lines 50000000 -max-line-bytes 4096 -out labels-real.json
    Commit point (new) Before any replay is run, the coordinator posts on #474 the sha256 of labels-real.json and keys.json, the number of labelled senders, and V's inputs: n(D) for every day from T0 − 7 d to <V.end>, R, and the packet-retention setting (below the table). label reads bg.jsonl only, so its output cannot depend on any result.
    Spec, inject python3 cmd/anomaly/make_spec.py <V.start> keys.json > spec.json; corescope-anomaly inject -spec spec.json -input bg.jsonl -out inj.jsonl -labels labels.json -max-injected 2000000 -max-line-bytes 65536
    Replays corescope-anomaly replay -config round3.json -input bg.jsonl -start <V.start> -end <V.end> -max-line-bytes 4096 -out report-a.json; the same with -input inj.jsonl -out report-b.json
    Score, arm A (new) corescope-anomaly score -report report-a.json -real-labels labels-real.json -rule p474_periodic -start <V.start> -end <V.end> -out metrics-a.json
    Score, arm A strict (round 3's) corescope-anomaly score -report report-a.json -rule p474_periodic -start <V.start> -end <V.end> -out metrics-a-strict.json. Its false_positives equals metrics-a.json's strict_false_positives.
    Score, arm B Exactly round 3's four runs (metrics-b-shared.json, metrics-b-noisy.json, metrics-b-iso.json, sanity), with the same -bots lists, -baseline report-a.json on the shared runs, and the §10 contamination check against keys-full.json
    Episode One EventID, activated in E, as round 3
    Capacity #457 §1a verbatim, as round 3. label's -max-lines is a count cap under the same doubling rule.

    Validation window V.

    • T0 is the first 00:00:00Z strictly later than this comment's GitHub timestamp.
    • Day counts. For a UTC day D, n(D) is the number of observations rows whose timestamp lies in [D 00:00:00Z, D+1 00:00:00Z), the column and half-open range export selects on. The counts are read from the snapshot with exactly this query, where <T0-7d> is T0 − 7 days in unix seconds. A day missing from its output has n(D) = 0.
      sqlite3 'file:<snap>/meshcore.db?immutable=1' "SELECT timestamp/86400*86400 AS day, COUNT(*) FROM observations WHERE timestamp >= <T0-7d> GROUP BY day ORDER BY day"
      
    • Reference R is the median of n(D) over the 7 days before T0 (D = T0 − 7 d … T0 − 1 d), leaving out days with n(D) = 0. These days must lie inside the ingestor's packet retention (retention.packetDays is 0, or longer than the time from T0 − 7 d to the snapshot), so that pruning cannot lower R. The setting is reported with the snapshot. If the days are outside the retention, or fewer than 4 of them have n(D) > 0, the round is void.
    • Present. A day D is present when 2·n(D) ≥ R, i.e. it holds at least half of R. A day with fewer rows, empty or not, is not present. A day is whole when the snapshot was taken after D+1 00:00:00Z.
    • V is contiguous. It begins at the first present whole day at or after T0 and runs over consecutive present whole days, at most 7. The first day that is not present, or not whole, ends it. <V.start> and <V.end> in every command are V's first 00:00:00Z (T0 unless T0's day is not present) and the 00:00:00Z after its last day.
    • The choice uses row counts per UTC day only, never results. Fewer than 5 days voids the round; 5 or 6 days shorten the panel exactly as round 3 §6 says.
    • Why contiguous. Every command takes one [-start, -end), and the per-day rates divide by its length. A day with no rows inside V would count as a day without false alarms and flatter arm A.
    • Why a share of R, and why one half (data dated before 2026-10-17):
      • "At least one row" leaves a cliff in the lenient direction: a day with one row would count as a whole day in every per-day divisor and lower false alarms per day, by an amount the rule does not bound. With the cut, every day of V holds ≥ R/2 rows, so V holds ≥ |V|·R/2 rows. Measured in rows, the divisor then overstates the traffic seen by at most a factor 2: false alarms per R rows are at most twice the reported false alarms per day.
      • The only multi-day counts dated before 2026-10-17 are anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 round 4's (offline snapshot of 2026-10-14, issuecomment-6195938750): 6 whole days, 2026-10-08 … 10-13, weekend included, at 296k–337k observation rows per day. Every one of them is at least 0.87 of any median that week can have (296/337). A cut at one half therefore ends V only on a day that lost about half its rows (for example an ingest outage of about half a day), never on the day-to-day variation seen there.
      • A cut near 0.87 would be tighter, but it rests on one week of counts. Row counts also move with observer churn, because each observer adds its own rows of the same events, and that changes no event the rule sees. A cut that ends V on such a day voids the round below 5 days. One half bounds the leniency at a factor 2 and leaves a wide margin to every day seen before 2026-10-17.
      • R is taken from the 7 days before T0 so that no day of V moves it, and a median of 7 tolerates up to 3 short days among them.

    4. Rule config

    round3.json, byte-identical to rounds 1–3, sha256 5e5b5e7a6610a46192873872410aee59cc8b1690cc8f1f5447996c3ba0c6e0dd.

    5. Ground truth for arm A: real automated senders (new)

    A labelled real sender is a tuple (channel hash, start, stop, P, size share) written by label (§3). The recipe:

    1. Lines: payload type 5 with a channel hash, not on an excluded hash, inside V; per channel hash, over every route group.
    2. Segments: split at every gap > 3·max_period + max(30 s, 0.1·max_period) = 3 h 06 min. No §4 run can bridge such a gap, so splitting there never cuts a run; P is estimated per segment.
    3. Timing: anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 §4 per segment, with the pulse merge min(60 s, P0/2), where P0 is the median raw gap ≥ 30 s. P is the median pulse gap in [1 m, 1 h]. A run is a maximal stretch of ≥ 8 pulses, each gap within max(30 s, 0.1·P) of k·P, k ∈ {1, 2, 3}.
    4. Size: the run is kept only when one known payload size carries ≥ 0.95 of the run's lines (every line of its pulses; an unknown size counts in the denominator only).

    Why each value (all from data dated before 2026-10-17, or synthetic):

    • Timing values. These are anomaly phase 3: replay CLI, read-only export and blind validation (periodic, rate, new-stream) #457 §4's, fixed before any of these rules existed. The only change is the merge cap. A fixed 60 s merge cannot label the bottom of the recipe's own period range: inject's §4 labels cover 0.02 of five clean synthetic 60 s trains, while the capped merge covers 1.00. From a 2-minute cadence up the cap is inactive.
    • Size bar 0.95.
      • On the real 2026-05-17 fixture, human-looking channels with ≥ 10 lines have a modal size share of 0.32–0.79; the fixture's automated 81 is at 0.88.
      • On synthetic chatter with the fixture's own size histograms, timing alone forms 0.24–0.64 runs per channel-day. With sizes drawn iid, the size test admits 0 of them for the typical mixes, and for the tersest mix (0.79) ≤ 0.13/day at 0.90 and ≤ 0.038/day at 0.95.
      • That rate is model-dependent. With message length correlated inside a conversation (per-participant sizes, or one terse speaker), an independent re-implementation measures 0.29 and 0.22 mislabelled runs per channel-day, at both 0.90 and 0.95. No data before V measures how real chat correlates length, so no number here bounds the leniency.
      • 0.95 is chosen because it is never more lenient than 0.90: identical on every constant-size bot, lower on iid chatter, equal on correlated chatter.
      • Every synthetic constant-size bot from 60 s to 30 min, clean or anomaly: hold a periodic bot as one episode and detect when it stops (evaluate Cooldown, then train tracking) #130-noisy, is labelled 20/20 at either value.
    • Silent-before is not used.

    Circularity. The timing recipe alone is looser than the rule. At P = 60 s its k = 1..3 windows of ±30 s join into one interval, 30–210 s, so on its own it would excuse almost anything the rule fires on. The label therefore requires evidence the rule never uses: a constant payload size, aggregated at channel-hash level rather than per route group. A periodic train of varied size, such as a verse bot, is not a labelled sender, and its episodes stay false alarms. No label value is taken from the rule's config.

    The labels file holds tuples and counts only: no ids, hops, routes, size values, names or text. No decryption and no sender names are used anywhere.

    6. Matching and metrics for arm A (new)

    • A false alarm is an arm-A episode that matches no labelled real sender. A match needs:

      • the same channel hash (any route group);
      • [activation, end] overlapping [start, stop + 4P], closed at both ends;
      • the episode's P̂ (Periodic.Period of its activation candidate) within max(30 s, 0.1·P) of k·P for some k ∈ {1, 2, 3}.

      An episode whose activation carries no periodic evidence never matches.

    • False alarms per day = false alarms / days of E. This carries the bar.

    • Real-sender recall = labelled senders with ≥ 1 matching episode / labelled senders (recall in metrics-a.json). It is reported with the per-sender table and has no bar: a labelled sender can be one the rule is not meant to see, such as a moving sender. With no labelled sender it is 0/0: it is undefined, metrics-a.json leaves the field out, and it is reported as "undefined (no labelled sender)".

    • Strict count = every episode a false alarm, as round 3 counted (strict_false_positives, strict_false_alarms_per_day). It is reported beside the primary value in every statement of arm A's result, with whether it is ≤ 0.2/day, and has no bar.

    • Excused episodes X = strict_false_positives − false_positives: the episodes the label excused. The label is exercised when X > 0.

    Arm B's matching and metrics are round 3 §8, unchanged.

    7. Pass bars (all primary bars must hold; otherwise no-go)

    Arm Bar
    A — false alarms on fresh real traffic (primary) false alarms (§6) ≤ 0.2/day over E, with the exclusions applied. The threshold is rounds 1–3's; the quantity it bounds is not. Rounds 1–3 counted every episode; round 4 counts only the episodes that match no labelled sender. Since false alarms ≤ strict count always, the bar is easier to meet, and a round-4 pass on this count is not comparable with an arm-A pass in rounds 1–3. The comparable number is the strict count beside it; when the label excuses nothing the two are equal (arm A's class, below).
    A — strict count (rounds 1–3's quantity) reported beside the primary value: strict_false_alarms_per_day and whether it is ≤ 0.2/day. No bar.
    A — real-sender recall reported, no bar; undefined with no labelled sender (§6)
    B-shared — 8 blind bots on real keys (primary) recall ≥ 0.50 · median TTD over the selected incidents ≤ 24 P (a miss = +∞) · median stop latency ≤ 4 P · median episodes per detected incident ≤ 2 — round 3, unchanged
    B-shared realistic subset (b5/b6/b7) recall ≥ 2/3 · median TTD over the selected subset ≤ 70 P — round 3, unchanged
    B-iso — control recall ≥ 0.80 · median TTD over the selected twins ≤ 12 P — round 3, unchanged
    Sanity (b8) reported, no bar

    Arm A's result class (fixed now; a function of counts in labels-real.json and metrics-a.json only). Let S be the number of labelled senders (posted at the commit point, §3), N the arm-A episodes, X the excused episodes (§6), FA the false alarms per day and ST the strict count per day. Arm A passes iff FA ≤ 0.2/day, in every class. The class depends on N, S and X only, and exactly one applies:

    Class Condition Then Arm A is reported as
    A-unex-0 N = 0 X = 0, FA = ST = 0 "pass, label unexercised (no episode)"
    A-unex-S N > 0, S = 0 X = 0, FA = ST "pass, label unexercised (no labelled sender)", or "fail"
    A-unex-X N > 0, S > 0, X = 0 FA = ST "pass, label unexercised (nothing excused)", or "fail"
    A-ex X > 0 FA < ST "pass, label exercised; strict count passes too" if ST ≤ 0.2/day; "pass on the labelled count only" if FA ≤ 0.2/day < ST; or "fail"

    What this means for go/no-go and for the claim:

    • The verdict is still decided by the primary bars only. An unexercised label does not make arm A inconclusive. It does not void, shorten or extend the round, and no further data is collected for it. Arm A bounds false alarms on fresh real traffic. In every A-unex class FA = ST, so a pass also passes rounds 1–3's stricter reading: it is stronger evidence for the rule, not weaker. Calling it inconclusive would make the rule's best outcome a non-result, and it would invite a choice after the data (waiting for another week).
    • The claim names arm A's class.
      • A-unex-*: stated as "go; arm A passes on the strict count; the real-sender label was not exercised". Round 4 then gives no evidence about the label: not that it separates real automated senders from chat, and not round 3's post-hoc reading of hash 56. Both stay untested, and no later run is part of round 4. This arm-A pass is comparable with a round 1–3 pass, because FA = ST.
      • A-ex, strict count passes too: as above; the label's excusals did not change the verdict.
      • A-ex, labelled count only: the go rests on the label. It is stated as "go on the labelled count only; the strict count is ST/day and fails rounds 1–3's reading". It is not comparable with a round 1–3 pass, and the matched senders are published (§9) so every excusal can be inspected.
    • In every class S, N, X, FA, ST and the recall (or "undefined") are reported.

    Inconclusive, void and inconsistent outcomes otherwise follow round 3 §9. The result states go or no-go with each measured value and arm A's class. A no-go is an acceptable outcome. No value from this round becomes a default or a production value, and integration stays a separate issue.

    8. Limitations and error directions

    • A missed real sender makes arm A stricter. Its episodes count as false alarms, as in round 3. This applies to:
      • a bot of varied size (verse bot);
      • a bot drowned in chatter on a busy hash (interleaved lines break the channel-level run);
      • a sender whose multi-part lines differ in size;
      • an automated sender below 0.95, such as the fixture's 81 at 0.88.
    • A mislabelled human run makes arm A more lenient. It can excuse an episode. How often chatter is mislabelled depends on the size model (§5): ≤ 0.038 runs per channel-day on iid terse chatter, but 0.22–0.29 when message length is correlated inside a conversation, at 0.90 and 0.95 alike. A mislabel alone excuses nothing. An excusal also needs a rule episode on the same hash, overlapping the run, with P̂ within the tolerance of k·P. Every labelled sender is published (§9), so any excusal can be inspected, and the strict count is always reported beside the primary one (§7).
    • A short day still counts as a whole day above half of R (§3). Measured in rows, this can overstate V's length by at most a factor 2, so the reported false alarms per day can be at most a factor 2 lower than false alarms per R rows. A day below half of R ends V (stricter, and void below 5 days).
    • A hash is not a channel. Two senders on one 1-byte hash are one series here. Interleaving usually breaks the run (stricter); a sender on a hash shared with a channel that is otherwise silent is labelled as that hash.
    • A label is not a verdict. A labelled sender is an automated-looking sender, not necessarily spam. §1 makes any automated regular sender on a non-excluded channel the target, so the rule firing on one is target behaviour.

    9. Handling of the files

    These stay with the coordinator, as in round 3 §12: bg.jsonl, inj.jsonl, keys.json, keys-full.json, spec.json, labels.json, labels-real.json, the reports and the metrics files.

    Published:

    • aggregates;
    • (channel hash, activation, end, P̂) tuples of the arm-A episodes;
    • the labelled real senders' (channel hash, start, stop, P, size share) tuples, with each one's matched-episode count (per_incident);
    • S, N, X and arm A's class (§7).
  9. dborup commented on Nov 1, 2026

    @dborup
    OwnerAuthor

    Round 4 commit point: snapshot, V, labels and keys (posted before any replay)

    Per the round 4 pre-registration (issuecomment-6382957525, §2 and §3). No replay has run yet, and no report or metrics file exists.

    Snapshot and tool

    Item Value
    Snapshot An offline copy made by scripts/backup-sqlite.sh (the container was stopped for about 55 s), taken 2026-11-01T07:16:58Z. It is used as-is, read-only, with -immutable. No -wal, -shm or -journal file sits beside it.
    Packet retention retention.packetDays = 30. T0 − 7 d (2026-10-18) to the snapshot is 14 days, so it lies inside the retention.
    Tool cmd/anomaly at 1066ef1be2477feb23761df2bd449662587749ee, built from a clean git checkout with cd cmd/anomaly && go build -trimpath -o corescope-anomaly . and the default environment (linux/amd64).
    Binary sha256 1bd6f84bbeae32b53e96102832d9fe47365be16c21119e50cdfc650b3db7b1be. This matches the reference in §2.
    go version go1.27.1 linux/amd64
    make_spec.py sha256 10a306b0987806ae8f3ca5a802dd67842a988bf053e7c6635bc34444222b0f22 (matches)
    round3.json sha256 5e5b5e7a6610a46192873872410aee59cc8b1690cc8f1f5447996c3ba0c6e0dd (matches)

    go version -m corescope-anomaly, build lines:

    build	-buildmode=exe
    build	-compiler=gc
    build	-trimpath=true
    build	DefaultGODEBUG=containermaxprocs=0,cryptocustomrand=1,decoratemappings=0,gotestjsonbuildtext=1,httpcookiemaxnum=0,httpservecontentkeepheaders=1,multipathtcp=0,randseednop=0,rsa1024min=0,tlsmlkem=0,tlssecpmlkem=0,tlssha1=1,tracebacklabels=0,updatemaxprocs=0,urlmaxqueryparams=0,urlstrictcolons=0,winreadlinkvolume=0,winsymlink=0,x509negativeserial=1,x509rsacrt=0,x509sha256skid=0,x509sslcertoverrideplatform=0,x509usepolicies=0
    build	CGO_ENABLED=1
    build	GOARCH=amd64
    build	GOOS=linux
    build	GOAMD64=v1
    build	vcs=git
    build	vcs.revision=1066ef1be2477feb23761df2bd449662587749ee
    build	vcs.time=2026-10-24T08:42:22Z
    build	vcs.modified=false
    

    Validation window V (§3)

    T0 = 2026-10-25T00:00:00Z: the pre-registration was posted at 2026-10-24T10:12:05Z.

    The day counts come from the §3 query, run on the snapshot with <T0-7d> = 1792281600. One deviation: the snapshot host has no sqlite3 CLI. The query text was therefore run unchanged through Python's sqlite3 module (SQLite 3.46.1), opened with the URI file:<snap>/meshcore.db?immutable=1.

    Day D (UTC) n(D) Role
    2026-10-18 390,380 R window
    2026-10-19 410,618 R window
    2026-10-20 359,991 R window
    2026-10-21 368,840 R window
    2026-10-22 352,625 R window
    2026-10-23 355,752 R window
    2026-10-24 387,652 R window
    2026-10-25 351,035 present, whole
    2026-10-26 403,369 present, whole
    2026-10-27 363,342 present, whole
    2026-10-28 387,049 present, whole
    2026-10-29 326,097 present, whole
    2026-10-30 315,753 present, whole
    2026-10-31 343,813 present, whole
    • R = the median of 2026-10-18 … 10-24 = 368,840. All 7 days have n(D) > 0. R/2 = 184,420.
    • V = [2026-10-25T00:00:00Z, 2026-11-01T00:00:00Z). That is 7 present whole days, the maximum, so the panel is not shortened. E = V.

    Export, keys and labels (§3 commands, exactly)

    Item Value
    Export Exit code 0, coverage_reduced: false. Rows 2,332,732, events_written 151,369, events_out_of_range 37, bad_frame 1. No capacity step was needed.
    bg.jsonl sha256 f82a76eff4fbea93f77e80b0525c0bebab04d3e8572b460874e6811d50342a2b
    keys.json sha256 903943d928314e23ac0eb94341eb3a6e45cd1030e3a7abd85f2f6faff384ed19 (16 keys)
    keys-full.json sha256 45d00ef45641b44268c8b837548003472153fd705e00c854fda354602517e5e9
    labels-real.json sha256 0b68347f535e743a1ab3c81d6cb9de7825390c14c1e73ff99c16496397526fa7
    Labelled real senders S 0

    Label counts:

    • 4,294 payload-type-5 lines were considered; 2,437 were skipped on the excluded hash 81.
    • They fall on 138 channel hashes and 492 segments.
    • timing_runs 0 and size_rejected 0, so senders = 0.

    With S = 0, arm A falls in class A-unex-0 or A-unex-S (§7), depending on N, and real-sender recall is undefined. Both follow from counts fixed above, before any replay.

    The next steps are spec, inject, both replays and scoring, exactly as §3 describes. The results follow in a separate comment.

  10. dborup commented on Nov 1, 2026

    @dborup
    OwnerAuthor

    Round 4 results: go. Arm A passes on the strict count, and the real-sender label was not exercised.

    The round followed the round 4 pre-registration (issuecomment-6382957525). Snapshot, V, tool, keys.json and labels-real.json were fixed in the commit point (issuecomment-6554907492) before any replay ran. Only aggregates and tuples are published (§9).

    Setup (§2, §3)

    Item Value
    Snapshot, V = E, tool As in the commit point: offline copy of 2026-11-01T07:16:58Z, V = [2026-10-25T00:00:00Z, 2026-11-01T00:00:00Z) (7 present whole days, R = 368,840). The binary sha256 1bd6f84b… matches §2.
    Excluded channels 81 only
    keys 4,185 lines considered, 1,166 distinct keys, all 16 ranks present. keys.json sha256 903943d9…, keys-full.json 45d00ef4….
    Spec / inject spec.json sha256 25a69444…: 18 bots, 20 incidents. inject exit code 0, 4,434 injected events. labels.json sha256 6278c9aa….
    Contamination (round 3 §10) 0 of the 9 isolated keys (3:fdff00–08) appear in the full ranking (1,166 keys), so no twin is dropped.
    Replays Both exit code 0 with CoverageReduced: false. Arm A has 0 candidate transitions and arm B has 72. report-a.json sha256 13a7d1a4…, report-b.json bfc49e2a….
    Scores Exit code 0 for all 7 runs: A, A-strict, B-shared, B-noisy, B-iso, sanity-shared, sanity-iso. metrics-a.json sha256 699f3cb9…, metrics-a-strict.json f97ddb9e…, metrics-b-shared.json a09ee650…, metrics-b-iso.json 510c7e47….

    Bars (§7)

    Arm Bar Measured
    A: false alarms on fresh real traffic (primary) ≤ 0.2/day 0.00/day (0 false alarms / 7 days) ✅
    A: strict count (no bar) reported 0.00/day (0 episodes), so ≤ 0.2/day —
    A: real-sender recall (no bar) reported undefined (no labelled sender) —
    B-shared, 8 blind (primary) recall ≥ 0.50 0.875 (7/8) ✅
    median TTD over selected ≤ 24 P (miss = +∞) 7.0 P ✅
    median stop latency ≤ 4 P 3.04 P (per bot 3.01–3.13 P) ✅
    median episodes per detected ≤ 2 1 (max 3) ✅
    B-shared realistic (b5, b6, b7) recall ≥ 2/3 2/3 (b7-900s-noisy-shared missed) ✅
    median TTD over selected ≤ 70 P 12.0 P ✅
    B-iso, 8 blind (control) recall ≥ 0.80 1.00 (8/8) ✅
    median TTD over selected ≤ 12 P 7.0 P (max 115 P, b6-300s-noisy-iso) ✅
    Sanity b8 (no bar) shared: 2/2 incidents (17 P and 47 P); iso: 2/2 (16 P and 42 P)

    Arm A's class: A-unex-0 (§7).

    • S = 0 labelled senders, N = 0 arm-A episodes, X = 0 excused episodes.
    • FA = ST = 0.00/day.

    Further results:

    • Baseline masking. B-shared has 0 baseline_episodes (arm A has none) and 0 incidents_baseline_masked.
    • Clean scoring. There are no period mismatches and no episodes without a period, in any run.
    • Arm B false positives. B-shared has 0 and B-iso has 0.
    • The one miss. It is b7-900s-noisy-shared (P = 15 m): 0 episodes on its shared key. Its isolated twin is detected at 19 P. The panel picks its shared keys from this round's keys.json, so its shared key differs from round 3's, where this recipe was detected. The cause of the miss was not investigated, because that would be a post-hoc look.

    Round result: go. Every primary bar holds. B-shared and the B-iso control both pass, so the round is not inconsistent.

    What this go claims, and what it does not (§7)

    As fixed in §7 for class A-unex-*, the claim is: "go; arm A passes on the strict count; the real-sender label was not exercised".

    • The arm-A pass is comparable with rounds 1–3. FA = ST, and on V the rule fired zero episodes on real channel traffic, with 81 excluded.
    • Round 4 gives no evidence about the label. Nothing shows that the label separates real automated senders from chat, and round 3's post-hoc reading of hash 56 is not confirmed. In V, no channel hash produced even a timing run (timing_runs 0), so the label had nothing to judge. Both questions stay untested, and no later run is part of round 4.
    • Detection still works. Inside the same real traffic, 7 of 8 blind shared bots and 8 of 8 isolated twins were detected at about the 7 P floor.

    Arm A episodes and labelled senders (§9)

    • Arm-A episodes (channel hash, activation, end, P̂): none.
    • Labelled real senders (channel hash, start, stop, P, size share): none. per_incident is empty.

    Data notes

    • V holds 4,294 GRP_TXT lines on non-excluded hashes, on 138 channel hashes and 492 segments. keys considered 4,185 relay lines, against 5,982 in round 3's V.
    • The round 3 burst on hash 56 (2026-10-18/19) lies outside V and is not part of this round.

    No value from this round becomes a default or a production value, and integration stays a separate issue. The files stay with the coordinator (§9).

  11. dborup commented on Nov 1, 2026

    @dborup
    OwnerAuthor

    Closed as go on round 4 (issuecomment-6554920412), on owner decision. Arm A passed on the strict count, with 0 episodes in 7 days (class A-unex-0). The real-sender label was not exercised, so it remains untested. No value from this round becomes a default; integration, if wanted, goes in a separate issue.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions