S2b: send-volume, webmail, recipient and subject-brand features - #7
Merged
Merged
Conversation
Two pre-existing comments referenced "the real incident corpus" and "real incident dates" in prose. Reworded to describe the same thing generically (confirmed abuse activity / fictional dates), keeping this public repo's data-boundary hygiene check clean going forward. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
… matching
Adds the v0 feature families addressing common bulk-phishing shapes:
send-volume bursts (sends_10m_max, sends_1h, sends_first_day),
consumer-webmail concentration (webmail_recipient_share,
webmail_sends_1h), distinct-recipient fan-out in a trailing window
(distinct_recipients_1h), resource-kind spelling aliases, and a brand
match against the message subject line (subject_brand_match) in
addition to the existing resource/agent name match.
Review fixes folded in from the start (fresh implementation, not
carried over from any prior branch):
- B1: every send-volume feature is scoped to a subject's first 7 days
(a hard young-account gate) so an established sender's ordinary
volume can never read like a brand-new signup's; sends_10m_max
searches a real bounded history instead of an unbounded lifetime
maximum, so it decays once an account matures.
- N4: resource.created's `kind` field also accepts common spelling
variants ("api key", "API Key", "api_keys", "keys") as aliases for
"key".
- N5: every new feature excludes future-dated events from its count.
- S1: subject_brand_match is no longer suppressed by words inside the
subject line itself (a bulk-phishing subject routinely and
legitimately contains "tracking" or "api"); it is suppressed only
when the SENDING ACCOUNT's own resource/agent name carries an
integration token.
- S2: subject_brand_match excludes any brand already counted by
name_brand_match, capping the combined per-brand contribution.
- S7: webmail_sends_1h is computed directly from the trailing window,
not as webmail_recipient_share * sends_1h (a lifetime ratio times a
trailing sum conflates two different timescales); every sum caps its
per-event recipient_count.
- B3: brand-name tokenizing splits on any Unicode punctuation/symbol
rune, not a hand-picked separator list, so a brand followed by
':', ',', '!', ')', '"' or '/' matches, and a possessive 's no
longer glues onto the brand word.
- N1: a brand entry can be marked case-sensitive, for a short brand
token that doubles as an ordinary English word (added config/brands.yaml's UPS on this basis).
- N2: a brand mention inside ordinary community-gathering text ("...
group meetup", "... fan club") does not match.
- N3: soft hyphen (U+00AD) and invisible separator (U+2063) are
stripped alongside the existing zero-width characters.
config/webmail.yaml is a new public list of consumer webmail provider
domains, including common country-variant domains (hotmail.co.uk,
outlook.fr, yahoo.de, mail.ru, gmx.de, t-online.de, libero.it, etc).
config/brands.yaml adds marketplace/social/shipping brands commonly
impersonated in bulk-phishing lures, reorganised alphabetically within
category. BrandSet gains MergeBrandSets for an optional private
brands_extra list, and cmd/abusekit gains --brands-extra/--webmail
flags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
…weight-tuned S2b fixtures
internal/event/redact.go (bumps RedactionSchemaVersion to 2):
- S4: content.sent's recipient_hash must match
^[A-Za-z0-9_:+/=-]{8,128}$ (rejecting anything containing '@' or
'%', any whitespace, or anything outside that set) instead of only
a length cap.
- S5: subject_line masks an email-shaped substring (replacing it with
"@") instead of rejecting the whole event; every other field keeps
rejecting an embedded email outright.
- S6: a recipient_hash paired with recipient_count > 1 is rejected —
a set recipient_hash represents exactly one recipient.
- N6: recipient_count, if present, must be a positive integer.
internal/feature/windows.go:
- Excludes self-sends (recipient_is_own_identity=true) from every
send-volume/webmail/distinct-recipient feature — these measure
reach to other recipients, and a self-send would otherwise
double-count the same rehearsal behaviour self_send_before_external
already captures.
Weights and fixtures (config/rules.yaml wires the 7 new S2b features
into new_account_velocity's inputs; config/local_weights.yaml adds
their weights):
- sends_10m_max is the main burst-intensity signal; sends_1h and
distinct_recipients_1h are small companions (correlated with it in
a genuine burst, the same relationship resource_velocity_1h/
resource_total already have); sends_first_day is a separate,
permanent first-day anchor.
- webmail_recipient_share (a normalized ratio) and webmail_sends_1h
(the largest of the new weights, gated to 0 for an established
sender by youngAccountFactor) capture consumer-webmail
concentration; subject_brand_match is sized like name_brand_match.
Four new fixtures addressing common bulk-phishing shapes, each
bounding at least one new weight (sensitivity windows and which
fixture bounds which weight are in the PR body):
- webmail_blast.jsonl: a brand-new account, 100 recipients on one
consumer webmail domain in 10 minutes, neutral subjects — no brand
signal at all — reaches at least medium.
- single_brand_blast_45m.jsonl: a brand-new account, 240 webmail
recipients over 45 minutes, every subject mentioning the identical
fictional brand (eval/fixtures/test_brands.yaml) — reaches high.
- established_newsletter_burst.jsonl: a 60-day-old paid newsletter
with a real sending history whose most recent send happens to burst
300 webmail recipients in 10 minutes — stays below medium
(youngAccountFactor; B1).
- day0_marketplace_seller.jsonl: a brand-new account named after a
fictional shop brand, no integration token, sending to 30 webmail
buyers over an hour — also exercises S2 (its own name already
credits the brand, so subject_brand_match must not double-count
it) — stays below high.
internal/worker/mutation_test.go extends the mutation-sensitivity
sweep (zero, 0.5x, 2x) to all 7 new weights, via the four fixtures
above plus isolated synthetic scenarios for the small companion
weights (sends_1h, sends_first_day, distinct_recipients_1h,
subject_brand_match) that no realistic fixture is sensitive enough to
bound alone — the same pattern this repo already uses for its own
weak companion weights (resource_total, key_total).
benign_transactional.jsonl: dropped recipient_hash from three
multi-recipient batch sends (S6 now rejects pairing a hash — which
represents exactly one recipient — with recipient_count > 1); this
was a pre-existing data-quality issue in the fixture, not a behavior
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
- README: a new "Configuration flags" section documenting --webmail and --brands-extra (and the existing --rules/--vendors/--weights/ --brands/--keys for context). - docs/design: [S2b] amendments to §4.3 (redaction: recipient_hash format, subject_line masking, the recipient_hash/recipient_count cross-field check, positive-integer recipient_count), §4.5 (the seven new inputs, young-account gating, subject-line matching, double-count capping, resource-kind aliases) and §5 (brand-matching tokenizer/case-sensitivity/community-context fixes). - docs/plans: a new S2b row summarizing this fix round on S2's feature set. - eval/fixtures/README: documents the four new fixtures and test_brands.yaml. - cmd/abusekit: tests proving --brands-extra and --webmail are actually wired (rejected when missing, merged when present) rather than only documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Replaces the hard 7-day calendar-age gate (youngAccountFactor) on sends_10m_max, sends_1h, webmail_sends_1h and distinct_recipients_1h with a history-relative measure: burstFactor(current volume, the subject's own prior 10-minute peak over the preceding 30 days, excluding the trailing 24h "current" period, floored at 1) times a smooth ageDecayFactor (clamp(1 - (age_days-3)/27, 0.2, 1) - full weight through day 3, ramping to a 0.2 floor by ~day 25, continuous in age with no cliff and never a hard 0). Review found the old gate evadable (an account that waited past 7 days read as fully established regardless of whether it had ever sent anything before) and blind to a subject's own sending history. sends_10m_max's own current-burst search is now bounded to a trailing 24h window instead of the whole account history, so a burst stops contributing once it ages out. Four required outcomes, each a new or modified replay fixture: - dormant_branded_burst_8d.jsonl: an account dormant 8 days (one day past the old gate) then bursting 100 branded webmail recipients in 10 minutes - reaches high (a calendar gate must not be evadable by waiting). - established_newsletter_burst.jsonl (unchanged): a 60-day newsletter with a real sending history bursting 300 webmail recipients in 10 minutes - stays low (a real prior baseline discounts the ratio, and age decay is near its floor). - paid_launch_5d.jsonl: a 5-day-old paid account with a modest prior sending history pushing a neutral-subject launch announcement to 250 webmail recipients - stays below high. - dormant_then_blast.jsonl (rebuilt): stripped of its brand-named agent and resource-creation burst, now reaches high on the volume/ recipient signal alone (100 distinct-domain sends in 10 minutes, no prior history). A dedicated continuity probe (6d23h/7d1h/8d, TestR1_AgeDecayContinuityProbe) and a new webmail_spread_1h.jsonl fixture (80 webmail recipients spread across a full hour, isolating webmail_sends_1h's own contribution from sends_10m_max's) round out the mutation-sensitivity coverage. benign_receipts_fanout.jsonl's and webmail_blast.jsonl's bands moved (documented in their own test comments) as a direct, acknowledged consequence of retuning these weights against the new mechanism. Hygiene check clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Replaces the whole-account "any resource name anywhere carries an integration token" suppression with a precise, per-brand exemption: - Only a LIVE (not later deleted) resource counts; the event vocabulary has no resource id, so liveness is a name-based heuristic (a created resource is live unless some resource.deleted event anywhere shares its exact name). - Only an agent-kind resource counts (via normalizeResourceKind, so N4's kind aliases apply here too) - a key named with an integration token and a brand (e.g. "Stripe API Key") never suppresses anything. - Only the brand(s) matched IN THAT AGENT'S OWN NAME are exempted from subject-line matching, not every brand the account has ever mentioned - an account with an unrelated "Stripe Webhook Relay" agent still gets flagged for a subject line naming a different brand. New internal/feature.exemptSubjectBrands replaces accountHasIntegrationName; BrandSet.MatchedBrandNamesForSubject drops its accountHasIntegrationName parameter (the exemption decision now lives entirely in exemptSubjectBrands/subjectBrandMatch). Hygiene check clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Replaces the community-context gate's bare single-word list ("chat",
"group", "fans", "club", "community", "meetup" individually - too
broad, false-positiving on ordinary subjects like "<brand>: chat with
support") with three whole phrases: "group meetup", "fan club",
"community event". Also restricts the gate to subject-line matching
only - an earlier round applied it to resource/agent names too,
which suppressed name_brand_match for a name like "<brand> Support
Chat".
Adds the required benign fixture (community_group_photo_walk.jsonl:
a day-0 community-group account posting an ordinary update with no
community phrase, to 80 webmail members in 10 minutes) and documents
the trade-off it lands on: this fixture reaches high, since it is
structurally close to indistinguishable, on this feature set alone,
from a genuine brand-impersonation blast (day-0, no prior history,
webmail-concentrated burst, a subject line matching a curated
brand). R1's blocker-level outcomes were kept intact rather than
weakened to spare it - documented in the test's own comment
(TestReplay_CommunityGroupPhotoWalkKnownGap).
Hygiene check clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
subjectBrandMatch was the one send-volume/brand feature that did not exclude a self-send (recipient_is_own_identity: true), unlike every sibling feature (sends_1h, sends_10m_max, webmail_sends_1h, distinct_recipients_1h, webmail_recipient_share) - matching the design's own [S2b] amendment: these features measure reach to OTHER recipients, and a self-test rehearsal mentioning a brand in its own subject line is not evidence of a lure reaching anyone. Hygiene check clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Adds two stated-tier fixtures, each with margin >= 0.05 from the tier cut it lands on: - single_brand_100_45m.jsonl: a brand-new account, 100 webmail recipients over 45 minutes, one repeated fictional brand - high. - brand_colon_country_variant_20m.jsonl: a brand-new account, 60 recipients on a country-variant consumer webmail domain (hotmail.co.uk) over 20 minutes, brand immediately followed by a colon - high. Documents a known gap rather than claiming a tier it doesn't reach: slow_sender_15_per_hour_6h.jsonl sends the SAME total volume and brand mention as single_brand_100_45m.jsonl but paced at 15/hour over 6 hours instead of one burst, and reaches only medium - sends_10m_max/webmail_sends_1h (this model's most heavily-weighted volume signals) can only ever see one hour's worth at any scoring instant, so a deliberately-paced sender evades a windowed-burst detector by construction. Recorded in docs/design's own §8 open questions and in the fixture's own replay test. Hygiene check clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Replaces the isolated synthetic scenarios that used to bound sends_1h, sends_first_day, distinct_recipients_1h and subject_brand_match with three DISTINCT real replay fixtures, so a wiring swap between two features would fail a test (every other committed fixture happens to give sends_1h and distinct_recipients_1h identical values): - moderate_volume_single_brand.jsonl: bounds subject_brand_match (volume alone is far too small to reach high). - repeat_recipient_resend.jsonl: bounds sends_1h AND distinct_recipients_1h with different values (the same 5 recipients sent to twice within the hour). - first_day_burst_then_quiet.jsonl: bounds sends_first_day, evaluated 26 hours after the subject's first event so the burst has aged out of every sibling feature's current window. distinct_recipients_1h's weight moved from 0.002 to 0.0025 (it previously matched sends_1h's weight exactly, which would make a wiring swap between exactly those two features mathematically undetectable regardless of fixture choice). Commits the x0.5/x2 sensitivity sweep as an actual test (TestWeightMutation_NewWeightsSurviveHalfAndDoubleSweep) rather than only reporting it informally, and corrects docs/plans' S2b row (which claimed "every new weight bounded by a fixture" when four were in fact bounded only by a synthetic scenario). Hygiene check clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
R7: an established, paid account whose routine product copy mentions a
big-tech brand ("...integrates with X Calendar") should not accrue a
permanent subject_brand_match lift just for using that brand's name in
ordinary marketing. subject_brand_match now scales by the same
ageDecayFactor already used for the volume features (R1), rather than
carrying a flat capped count regardless of account age. This was chosen
over an alternative of exempting a fixed generic-brand allowlist
(apple/google/microsoft/amazon/stripe), since a static list only covers
brands anticipated in advance and doesn't generalize to newly-added
public/private brand entries; age/history-scale treats all brands
uniformly and composes with the existing R1 mechanism instead of adding
a second, parallel exemption path. Documented in the design doc.
New replay fixture: a 2-month-old paid account sending the same
brand-mentioning product-update subject line three times over two
months stays low (previously this pattern - fictional brand name,
neutral send cadence - would have kept accruing full-weight
subject_brand_match forever).
Hygiene check clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
R8: firstSeenAt is derived as "the earliest event abusekit itself has ingested for this subject" — correct for a subject that genuinely started with abusekit, but wrong for an already-established account onboarded onto abusekit well after its real signup. Un-backfilled, such an account's very first ingested event (or first event after onboarding) reads as day zero, defeating every one of R1's history-relative/age-decay features and R7's age-decayed subject_brand_match exactly for the accounts they exist to protect against a false positive. subject.created now accepts an optional account_created_at (RFC 3339), validated exactly at redaction time (a malformed value is rejected at ingest, not silently dropped or left to fail deep inside feature extraction). Extract prefers it over the derived firstSeenAt whenever present; every firstSeenAt-keyed feature inherits the override automatically since they all read the same local variable. Documented as the precondition (alongside a true historical backfill, which this makes unnecessary) for history-relative scoring to behave correctly on a pre-existing account. New replay fixture: a real, 60-day-old paid account whose only ingested history is its signup plus one routine newsletter send (no backfilled prior sends). The committed test replays it twice, with and without account_created_at on the identical event stream, and asserts the field alone moves the outcome from high down to low. Hygiene check clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Three of brands.yaml's category comments (marketplace, social, shipping) paired that category's brands with a specific quoted lure theme observed for them. Category comments now name only the category, matching every other category in the file (financial, retail, tech, travel, ...) — consistent with the same hygiene rule applied to fixtures, PR text and commit messages throughout this PR. New test (TestBrandsYAML_CategoryCommentsStayGeneric, internal/feature) reads the raw file text and fails if any of the removed lure phrases reappear. Hygiene check clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
Four small hardening fixes, each with a failing test first:
- canonicalise now strips every rune in Unicode category Cf ("Format")
rather than a hand-enumerated allowlist that only grew one invisible
character at a time as each was separately discovered. The old list
(zero-width space/ZWNJ/ZWJ, BOM, word joiner, soft hyphen, invisible
separator) was itself a strict subset of Cf, so this is a superset fix
with no behavior loss — it additionally catches any Cf character no
round happened to enumerate yet, e.g. U+2064 (INVISIBLE PLUS) and
U+180E (MONGOLIAN VOWEL SEPARATOR). stripZeroWidth is retired; its job
is now folded directly into canonicalise.
- The case-sensitive brand-token check (N1) now NFKC-normalizes the
candidate text before comparing, so a fullwidth Unicode look-alike
spelling ("UPS") is recognized the same as its plain-ASCII spelling
("UPS") instead of silently failing the byte-exact comparison and
evading a case-sensitive brand entirely.
- config/webmail.yaml gains hotmail.fr/de/it/es, outlook.de, live.fr,
yahoo.es and yahoo.com.br — additional country-variant consumer
webmail domains alongside the ones already listed.
- content.sent's subject_line is now NFKC-folded unconditionally at
redaction time, not only on the branch where an embedded email-shaped
substring happened to be found and masked. The two previously
diverged: the computed subject_line_skeleton was always folded (via
event.Skeleton), but the raw stored subject_line was folded only when
masking triggered, so the identical logical subject line could be
stored as two different byte sequences depending on whether it also
happened to contain something email-shaped.
Hygiene check clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
jiashuoz
added a commit
that referenced
this pull request
Sep 29, 2026
Owner decision: the compiled binary carries no domain knowledge. Email becomes a YAML reference pack (packs/email) loaded like custom features; content.sent, email_hash, email_domain_class and address_domain move into its declared vocabulary with byte-compatible wire handling (pack extensions of built-in types, flat declared links map). New generic DSL primitives (baseline override, distinct.on_missing, versioned compat options, lifetime share, brand_match, cross-field constraints) give a bit-for-bit parity table for every PR #7 email feature; P-E1 loads the pack in shadow, P-E2 proves parity and deletes the Go email code. Core audit, brand pack lists as data, neutrality CI, non-email reference packs, new decisions on pack location, visibility and pinning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
jiashuoz
added a commit
that referenced
this pull request
Sep 29, 2026
…custom features (#8) * docs(design): generic feature packs, neutral event vocabulary, declarative custom features Splits the built-in features into namespaced core/email/brand packs enabled per tenant, adds a neutral delivery.sent event with a read-side view over content.sent, product-declared resource kinds, channels and custom types with kind-driven redaction, and a closed declarative feature DSL (count, distinct, share, peak, time_between, history-relative modifier) with mandatory caps and a static cost model. A frozen canonical-key alias table keeps input hashes, local-scorer summation order and version hashes identical, proven by a golden replay of every fixture. Adds a pointer in the main design and a plan row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 * docs(design): rework generic feature packs after adversarial review (rev 2) Drops the canonical-key bridge for a one-time rename with a per-consumer test list and a semantic-identity golden (derived ulp bound). Replaces the event-count bound and wall-clock deadline with time-bounded loading, full onboarding loads, byte caps, a deterministic step budget and truncation as a positive signal. Closes redaction channels: re-HMAC of every hash (length-prefixed, join domains), undeclared values dropped, PSL-checked domains, card/IP/phone scanning, skeleton-only custom text. Adds group_by, sequence, ratio, neighbours with declared link kinds, before_first, and subject kinds, and walks five fictional scenarios. Drops delivery.sent for declared types with field roles. Re-slices into P0-P7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 * docs(design): generic feature packs revision 3 after re-review Onboarding from ingest-maintained subject_facts and lifetime totals from subject_counters; evaluation classes F/N/A/R/G/D with a fixed pass order and per-feature/per-pack budgets; exact per-feature aggregates; truncation becomes a one-sided `partial` flag, not a weighted feature; flood property restated against an unbounded reference with a specified generator. Rename is bit-exact via registry-order summation; fake scorer and corpus loader covered. Redaction: author-trusted bounded numbers, domain eTLD+1 with allowlist-or-HMAC, name grammar for undeclared fields, pseudonymised non-account ids, HKDF per-tenant keys, egress scan. DSL: absence indicators, pre-transform ratio, exact group_by, hash_quantum, as-of neighbours. Slices re-split (P1s, P3a-d, P4a-d, P5b). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 * docs(design): generic feature packs revision 4 after verification Peak saturation sized in raw units per transform (and after the baseline), with partial+degraded when the row budget binds first; neighbours exact by saturation with where-before-limit; partial/degraded direction table (fixes the backwards fan-in claim); start defined by precedence (account_created_at, first accepted subject.created, server first_received_at) with anchored-fact invalidation; lock-first fact updates and bounded decline recount on inf->t and earlier moves; webmail counters subtract (now, +inf); facts/counters subject assignment for also/via_parent and type/at on event_subjects; ratio partial propagation. Plus the listed text fixes, slice fixes and a section 13 revision-4 addendum. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 * docs(design): generic feature packs revision 5 after final check Recount-backing counters (decline, before_first) are primary-subject-only, with a parent/child recount test; "first accepted" is the smallest (received_at, producer, id) and determinism holds with received_at fixed; peak worked examples corrected (streams complete; binding is decided at run time; x_sat fixed); READ COMMITTED ingest with bounded retry and hourly decline counters plus a boundary-hour recount; class N features rejected as ratio den and negative-sign ratios over partial-capable nums flagged degraded; start clamped to first_received_at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 * docs(design): generic feature packs revision 6, domain-neutral binary Owner decision: the compiled binary carries no domain knowledge. Email becomes a YAML reference pack (packs/email) loaded like custom features; content.sent, email_hash, email_domain_class and address_domain move into its declared vocabulary with byte-compatible wire handling (pack extensions of built-in types, flat declared links map). New generic DSL primitives (baseline override, distinct.on_missing, versioned compat options, lifetime share, brand_match, cross-field constraints) give a bit-for-bit parity table for every PR #7 email feature; P-E1 loads the pack in shadow, P-E2 proves parity and deletes the Go email code. Core audit, brand pack lists as data, neutrality CI, non-email reference packs, new decisions on pack location, visibility and pinning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 * docs(design): generic feature packs revision 7 after focused review brand_match gains declarable exemptions computed at score time from a display-name fact table, per-role matcher variants, match after masking, and standalone age_decay; self-send uses eq:false with before_first include_future; one explicit monotone baseline shared by the four history-relative email features; embedded SHA-addressed reference packs and fail-closed 503 ingest on config_error; hash_quantum 0 and a rescore legacy_v0 mode for bit-exact NextRescoreAt; canonical links serialisation with a byte-identity test; derived column; completed neutrality audit with a P-N0 cleanup slice; stricter neutrality tests; closed compat enum; shadow-mismatch gate before P-E2; emailshadow namespace bound at load; decisions updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 * docs(design): Q32 decided, replace the Go SDK links type in place The owner decided to replace pkg/abusekit Links with a map in place. It is pre-GA with no external consumers, so this ships as a breaking change noted in the release notes, with no v2 module path and no retirement window. Updates the audit row, P-N0 done-when, decision list and a revision-7a note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ures-v2 # Conflicts: # eval/fixtures/README.md
Merges origin/main (PR #5's S4 eval harness + PR #8's design doc) into this branch. The merge itself was clean except for a doc-only conflict in eval/fixtures/README.md (both sides appended a new section); resolved by keeping both sides' content, generic wording only. The merge combined cleanly at the text level but not at the type level: eval/replay.go's feature.Extract call predates S2b's WebmailSet parameter, so the merged tree didn't build. Fixed by threading a feature.WebmailSet parameter through LoadReplayDataset (added right after brands, mirroring internal/feature.Extract's own parameter order) and every call site, test helper, and CLI flag that constructs one: - eval.LoadReplayDataset(in, brands, webmail, benignLabel) — webmail flows straight into every feature.Extract call, no package-level global, no panic on a zero value (feature.WebmailSet{} just matches no domain, same as passing no webmail config at all). - `abusekit eval` gains --brands-extra and --webmail, both named, env-var'd, and defaulted exactly like `serve`'s own flags (main.go's parseServeFlags) — the harness now loads brands/brands-extra/webmail the identical way a real deployment does, not a silently different subset. - New test: TestLoadReplayDataset_WebmailRecipientShareIsNonZero proves a webmail-heavy replay subject scores a non-zero webmail_recipient_share through the harness, and that the SAME subject scored against an empty WebmailSet reads back to 0 — proving the parameter is load-bearing, not merely accepted (verified by temporarily reverting the wiring and confirming the test catches it). F9 TODO: added two new eval/gen families (abusive_webmail_blast, abusive_subject_lure) — every other family's recipient_domain is a synthetic .example.test name and no family ever sets subject_line at all, so webmail_recipient_share/webmail_sends_1h/subject_brand_match otherwise read 0 across the ENTIRE synthetic corpus regardless of the harness wiring above. webmail_blast sends to real consumer webmail domains (config/webmail.yaml's own list, a public fact); subject_lure uses a fictional brand in its subject line (eval/fixtures/ test_brands.yaml, never a real one — this repo's hygiene rule for fabricated lure prose, a stricter bar than a bare resource name). Regenerated eval/fixtures/synthetic/{events,labels}.jsonl deterministically from the documented seed (20260927) — 20 families, 297 subjects (was 18 families, 286). Makefile's gate target now passes --brands-extra eval/fixtures/test_brands.yaml so the fictional lure brand is recognized when scoring this corpus. The new features and families change scores, so eval/floors.yaml was re-derived against a fresh run, same margin policy the file documents, weights untouched: precision 0.8036->0.8169, recall 0.8333->0.8788, AUROC 0.9735->0.9819, high-tier recall 0.7222->0.7879 (all IMPROVED or held family-steady: burst 0.8333, churn_incarnation_ge3 1.0, dormant_then_blast 1.0 unchanged) -- min_precision/min_recall/min_auroc/min_high_tier_recall/ family_min_high_tier_recall floors are UNCHANGED, now with MORE margin, not less. ONLY max_ece broke: baseline ECE moved 0.1108->0.1265 (an expected calibration cost of seven new hand-set, unfitted weight dimensions, not a regression), already past the old 0.115 floor before any weight was touched. Re-derived 0.115->0.132 (baseline+0.0055, same tight-margin policy T5 documented), confirmed to still catch subject_age_h (zeroed ECE 0.1509) and upgrade_delay_min (zeroed ECE 0.1378) -- TestGate_NegativeWeightRegressionCaughtByTightECEFloor passes unmodified. Hygiene check clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds abusekit's second wave of v0 scoring features, addressing common bulk-phishing shapes:
sends_10m_max,sends_1h,sends_first_daywebmail_recipient_share,webmail_sends_1h(config/webmail.yaml, extended with common country-variant domains)distinct_recipients_1h, plus a closedrecipient_hashformatconfig/brands.yaml, reorganized alphabetically by category) plus an optional privatebrands_extrafile (--brands-extra), and a newsubject_brand_matchfeature (brand mentioned in the message subject, not just the resource/agent name)This is a fresh implementation on a new branch from
origin/main— it does not merge, rebase onto, or cherry-pickfeat/s2b-send-and-brand-features(withdrawn/superseded, PR #6, left untouched).Review fixes folded in
highon volume alone: every send-volume/webmail-volume feature is gated to 0 once a subject is more than 7 days old (youngAccountFactor), andsends_10m_maxsearches bounded history rather than an unbounded lifetime maximum, so the signal decays instead of persisting forever once set.:,,,!,),"or/matches, and a possessive'sno longer glues onto the brand word.subject_brand_matchis no longer suppressed by integration-adjacent words ("tracking", "api") inside the subject line itself; it is suppressed only when the SENDING ACCOUNT's own resource/agent name carries an integration token.subject_brand_matchexcludes any brand already credited byname_brand_match, capping the combined per-brand contribution.recipient_hashmust match^[A-Za-z0-9_:+/=-]{8,128}$.subject_linemasks an email-shaped substring (@) instead of rejecting the whole event; every other field still rejects one outright.recipient_hashpaired withrecipient_count > 1is rejected (a hash represents exactly one recipient).webmail_sends_1his computed directly from the trailing window, never aswebmail_recipient_share * sends_1h; every sum caps its per-eventrecipient_count.config/webmail.yamlextended with common country-variant domains (hotmail.co.uk,outlook.fr,live.co.uk,yahoo.fr,yahoo.de,yahoo.co.jp,mail.ru,gmx.de,t-online.de,libero.it).config/brands.yaml'sUPS).kindalso accepts "api key", "API Key", "api_keys", "keys" (and a few more common variants) as aliases for "key".recipient_count, if present, must be a positive integer.--webmailand--brands-extra.Fixture score table
reference_operator.jsonlbenign_transactional.jsonlburst.jsonl(before first send)burst.jsonl(final)benign_fast_onboarding.jsonlbenign_integration_heavy.jsonldormant_then_blast.jsonlbenign_self_send_only.jsonlbenign_receipts_fanout.jsonlbenign_selfsend_brandname.jsonlbenign_variant_a.jsonlwebmail_blast.jsonl(new)single_brand_blast_45m.jsonl(new)established_newsletter_burst.jsonl(new)day0_marketplace_seller.jsonl(new)Every existing fixture keeps passing its previously-committed band; none needed retuning except
benign_transactional.jsonl, which droppedrecipient_hashfrom three multi-recipient batch sends (a pre-existing data-quality issue S6 now correctly catches — a hash paired withrecipient_count > 1is invalid).Which fixture bounds which weight (sensitivity windows)
Measured against the shipped weights; "zero"/"half"/"double" are the resulting risk with that one weight set to
0x/0.5x/2x, everything else unchanged. The committed mutation test (TestWeightMutation_EveryWeightIsLoadBearing) checks the zeroing case for every weight inconfig/local_weights.yaml, including these seven; it passes only because at least one scenario per weight below moves outside its band.sends_10m_max(0.012)webmail_blastfixturewebmail_recipient_share(1.1)webmail_blastfixturewebmail_sends_1h(0.014)webmail_blastfixturesubject_brand_match(1.5)subject_brand_match=1)sends_1h(0.0008)sends_1h=300)sends_first_day(0.0008)sends_first_day=300)distinct_recipients_1h(0.0008)distinct_recipients_1h=300)sends_1h/sends_first_day/distinct_recipients_1hare deliberately small "companion" weights (correlated withsends_10m_max/webmail_sends_1hin every committed fixture that exercises them at all — the sameresource_velocity_1h/resource_totalrelationship this repo already has), so no realistic fixture's band is tight enough to prove any one load-bearing alone; each gets its own isolated synthetic scenario, the same pattern already used for this repo's existing weak companion weights (resource_total,key_total, etc.). No weight was chosen to make a band's edge land exactly at the base score: every band above was set with a margin on both sides,single_brand_blast_45m's upper bound (0.9916 vs 1.0, margin 0.0084) being the tightest, since that fixture is already deep in the sigmoid's saturated tail — everywhere else the margin is >= 0.02.day0_marketplace_seller.jsonl(base 0.7170, band [0.6, 0.78]) additionally exercises S2 in a realistic combined scenario: its agent is named after the fixture's fictional brand, sosubject_brand_matchcorrectly reports 0 (excluded byname_brand_matchalready crediting it) rather than double-counting.Hygiene check
A repository-wide check for incident-specific phrasing ran before every commit and returned nothing.
Gates
gofmt -l .,go vet ./...,go test -short ./...,go test ./...(Postgres,ABUSEKIT_REQUIRE_DB=1),go test -race ./internal/...,go mod tidy,make lint,make test,make test-dball clean.Round 2
Applied every item from review: blocker R1 (history-relative volume, no calendar-age cliff), should-fix R2–R8, hygiene H1–H2, and four nits. Each item shipped as its own commit with a failing test first. Full commit list:
3c822f3R1,c15b0eeR2,79f0e12R3,abded4fR4,77412edR5,f871d36R6,78eca33R7,e79cd50R8,98110f5H1,68197a8nits.R1 — history-relative volume, no calendar-age cliff
The hard 7-day gate is replaced:
burst_factor = current 10m/1h volume ÷ max(1, prior daily-average or prior 10m peak over the preceding 30 days, excluding the trailing 24h), combined withageDecayFactor = clamp(1 − (age_days − 3) / 27, 0.2, 1)— smooth, continuous, floored at 0.2, never a hard 0.Continuity probe (synthetic account, fixed burst, only age varies):
No cliff: each step moves by ≤ 0.011, versus the old gate's 0.98→0.01 swing across the same boundary.
Required outcomes (all four replay fixtures,
ABUSEKIT_REQUIRE_DB=1 go test ./internal/worker/...):dormant_branded_burst_8d.jsonl(R1a)established_newsletter_burst.jsonl(R1b, pre-existing)paid_launch_5d.jsonl(R1c)dormant_then_blast.jsonl(R1d, rebuilt — no brand-named agent, no resource burst)sends_10m_max's current-burst search is now bounded to a trailing 24h window (was whole-history).Accepted trade-off:
benign_receipts_fanout.jsonlmoved fromlow(0.05–0.4) into low-medium(0.4771, new band [0.4, 0.55]) — required to givedormant_then_blastenough volume signal to reachhighwithout a brand/resource burst. Documented ininternal/worker/replay_test.goandmutation_test.go.R2 — precise subject-suppression on integration names
Replaced the old whole-account "any resource name anywhere carries an integration token suppresses everything" rule with
exemptSubjectBrands: only a live (not deleted), agent-kind resource's name is considered, and only the brand adjacent to the integration token in that name is exempted — never all subject matching. New tests: a key namedapino longer suppresses anything; a deleted agent namedsyncno longer suppresses anything.R3 — community-word gate as whole phrases, subjects only
Bare single words (
chat,group,fans,club,community,meetup) replaced with contiguous phrases (group meetup,fan club,community event), applied only toMatchedBrandNamesForSubject— never toMatchedBrandNames, restoringname_brand_matchfor an agent literally named "<brand>Support Chat"."<brand>: chat with support"now matches;"<brand> group meetup"does not.Known gap, documented and accepted:
community_group_photo_walk.jsonl(day-0, 80 webmail recipients/10 min, "Fictabook photo walk this Saturday" — no community phrase) scores high (0.9993), same as any other day-0 branded burst. Getting it below high would require either muting fresh-account volume (breaks R1's dormant/8-day-old requirements) or adding a phrase this subject doesn't contain — chose to keep R1's outcomes and document the gap inTestReplay_CommunityGroupPhotoWalkKnownGap.R4 — subject_brand_match excludes self-sends
subjectBrandMatch's loop now skipsisSelfSendevents, matching design's own contract.R5 — tier envelope
single_brand_100_45m.jsonlbrand_colon_country_variant_20m.jsonlhotmail.co.uk/20 min, brand followed by:slow_sender_15_per_hour_6h.jsonlThe slow-sender case is documented as a known gap of the local scorer in both
internal/worker/replay_test.go'sTestReplay_SlowSenderKnownGapanddocs/design's §8 open-questions.R6 — bound the new weights by real fixtures, commit the sweep
Replaced isolated synthetic scenarios for
sends_1h,sends_first_day,distinct_recipients_1h,subject_brand_matchwith real replay fixtures, using distinct fixtures so a wiring swap between two features fails a test:moderate_volume_single_brand.jsonl— boundssubject_brand_match(15 webmail recipients, far too small alone, plus one brand mention).repeat_recipient_resend.jsonl— boundssends_1hANDdistinct_recipients_1hwith genuinely DIFFERENT values (40 recipients contacted twice each;sends_1h's sum exceedsdistinct_recipients_1h's deduplicated count).first_day_burst_then_quiet.jsonl— boundssends_first_day, evaluated 26h after the subject's first event so the burst has aged out of every other current-window feature, isolating the one permanent-fact feature that hasn't.TestWeightMutation_NewWeightsSurviveHalfAndDoubleSweepcommits the ×0.5/×2 sweep as a test (not just a doc claim). Per-weight sweep results — for each of the 7 new weights, at least one scenario leaves its band at 0.5x or 2x:sends_10m_maxdormant_then_blast,dormant_branded_burst_8d,webmail_spread_1h,repeat_recipient_resenddormant_then_blast,day0_marketplace_seller,benign_receipts_fanout,paid_launch_5d,webmail_spread_1h,moderate_volume_single_brand,repeat_recipient_resendsends_1hrepeat_recipient_resendsends_first_dayfirst_day_burst_then_quietfirst_day_burst_then_quietwebmail_recipient_sharewebmail_spread_1h,moderate_volume_single_brand,repeat_recipient_resend,first_day_burst_then_quietestablished_newsletter_burst,day0_marketplace_seller,paid_launch_5d,webmail_spread_1h,moderate_volume_single_brand,repeat_recipient_resend,first_day_burst_then_quietwebmail_sends_1hrepeat_recipient_resendday0_marketplace_seller,webmail_spread_1h,repeat_recipient_resenddistinct_recipients_1hrepeat_recipient_resendsubject_brand_matchmoderate_volume_single_brandmoderate_volume_single_brandCorrected docs/plans' "every new weight bounded by a fixture" claim to describe the real mix (real fixtures plus a small isolated-synthetic residual before this round; now fully real-fixture-bounded).
R7 — generic brands in subjects
subject_brand_matchis now multiplied by the sameageDecayFactorR1's volume features use, instead of exempting a fixed brand allowlist (apple/google/microsoft/amazon/stripe) — chosen because age-decay generalizes to every brand, not just five named ones, and reuses an already-reviewed mechanism.established_product_copy_brand_mention.jsonl(2-month-old paid account routinely sending "Our product now integrates with Glowbank Calendar") stays low (0.1023).R8 — rollout: producer-supplied account age
subject.createdaccepts an optionalaccount_created_at(RFC 3339), validated exactly at redaction.Extractprefers it over the derived "earliest ingested event"firstSeenAtwhenever present, so every R1/R7 history-relative feature is correct from day one without a full event backfill.unbackfilled_established_account.jsonl— a real, 60-day-old paid account whose only ingested history is its signup plus one 100-recipient newsletter send (no backfilled prior sends):account_created_ataccount_created_at(identical events otherwise)H1 — generic category comments
config/brands.yaml's marketplace/social/shipping category comments no longer pair a category with a specific quoted lure theme; a new test (TestBrandsYAML_CategoryCommentsStayGeneric) fails if the removed phrases reappear.Nits
canonicalisenow strips every Unicode category Cf rune (was a hand-enumerated 7-character allowlist) — also catches U+2064 (INVISIBLE PLUS) and U+180E (MONGOLIAN VOWEL SEPARATOR).config/webmail.yamlgainshotmail.fr/de/it/es,outlook.de,live.fr,yahoo.es,yahoo.com.br.content.sent'ssubject_lineis now NFKC-folded unconditionally at redaction, not only on the branch where an email-shaped substring happened to be masked.Full fixture scores (existing + all round-2 additions)
reference_operator.jsonldormant_then_blast.jsonl(rebuilt, R1d)benign_receipts_fanout.jsonl(band widened, documented)webmail_blast.jsonlestablished_newsletter_burst.jsonlday0_marketplace_seller.jsonldormant_branded_burst_8d.jsonl(R1a)paid_launch_5d.jsonl(R1c)webmail_spread_1h.jsonl(mutation coverage)community_group_photo_walk.jsonl(R3, known gap)single_brand_100_45m.jsonl(R5)brand_colon_country_variant_20m.jsonl(R5)slow_sender_15_per_hour_6h.jsonl(R5, known gap)moderate_volume_single_brand.jsonl(R6)repeat_recipient_resend.jsonl(R6)first_day_burst_then_quiet.jsonl(R6, +26h)established_product_copy_brand_mention.jsonl(R7)unbackfilled_established_account.jsonl(R8, with field)Hygiene check
Hygiene check clean.
Gates (round 2)
gofmt -l .,go vet ./...,go build ./...,go test ./...(short and DB-backed viaABUSEKIT_REQUIRE_DB=1),go test -race ./internal/...,go mod tidy(no diff),make lintall clean before every commit.Updated for main
Merged
origin/main(PR #5's S4 eval harness + PR #8's design doc) viagit merge(no rebase, no force-push). One conflict, doc-only:eval/fixtures/README.md(both sides had appended a new section) — resolved by keeping both sides' content, generic wording only.The merge combined cleanly at the text level but not the type level:
eval/replay.go'sfeature.Extractcall predates S2b'sWebmailSetparameter. Fixed by threading afeature.WebmailSetthrough the whole harness rather than patching just the one call site.WebmailSet threading
eval.LoadReplayDataset(in, brands, webmail, benignLabel)—webmailflows straight into everyfeature.Extractcall. No package-level global, no panic on the zero value (feature.WebmailSet{}matches no domain, same as configuring none).abusekit evalgains--brands-extraand--webmail, named/env-var'd/defaulted identically toserve's own flags (main.go'sparseServeFlags) — the harness now loads brands/brands-extra/webmail the same way a real deployment does.TestLoadReplayDataset_WebmailRecipientShareIsNonZero: a webmail-heavy replay subject scores a non-zerowebmail_recipient_sharethrough the harness, and the identical subject against an emptyWebmailSetreads back to 0 — verified load-bearing by temporarily reverting the wiring and confirming the test catches it.F9 TODO — webmail-blast and subject-lure corpus families
Every other
eval/genfamily'srecipient_domainis a synthetic.example.testname and no family ever setssubject_lineat all, sowebmail_recipient_share/webmail_sends_1h/subject_brand_matchread 0 across the entire synthetic corpus regardless of the wiring fix above. Two new abusive families close that gap:abusive_webmail_blast— same fast decline/success/upgrade/resource-burst shape asabusive_fast, then acontent.sentblast to REAL consumer webmail domains (config/webmail.yaml's own list — a public fact, not customer data).abusive_subject_lure— same shape, then a blast whosesubject_linecarries a FICTIONAL brand lure (eval/fixtures/test_brands.yaml's Fictabook/Fictashop/Glowbank — never a real one; a full lure sentence is a stricter hygiene bar than a bare resource name).Regenerated
eval/fixtures/synthetic/{events,labels}.jsonldeterministically from the documented seed (20260927) — confirmed byte-identical across two runs. 20 families, 297 subjects (was 18 families, 286).Makefile'sgatetarget now passes--brands-extra eval/fixtures/test_brands.yamlso the fictional lure brand is recognized when scoring this corpus.Floors: old -> new
The new features and families change scores. Every floor was checked against a fresh run, same margin policy
eval/floors.yamldocuments, weights untouched:Every floor except
max_eceheld with MORE margin than before, not less — none were re-derived beyond confirming they still clear (per "do not tune weights," none were tightened either).max_ecebroke on its own, before any weight was touched: the feature-set change alone moved baseline ECE from 0.1108 to 0.1265 (seven new hand-set, unfitted weight dimensions — an expected calibration cost, not a regression), already past the old 0.115 floor. Re-derived to 0.132 (baseline + 0.0055, the same deliberately-tight margin T5 documented), confirmed to still catchsubject_age_hzeroed (ECE 0.1509) andupgrade_delay_minzeroed (ECE 0.1378) —TestGate_NegativeWeightRegressionCaughtByTightECEFloorpasses unmodified against the new corpus.min_high_tier_recall's old three-way exact-tie observation (zeroingburst_ratio_24h_vs_lifetime/first_day_distinct_domains/linked_labelled_abusive_neach landing on 34/54) no longer holds against the new corpus — those three now give 0.7273 (48/66), 0.6970 (46/66), 0.7121 (47/66) respectively (different corpus composition, no longer coincidentally equal). The floor stays at 0.60 regardless: it sits safely below all three (wider margin than before) while still catching the one weight this corpus's floors independently catch when zeroed,resource_velocity_1h(0.5152, 34/66).first_day_distinct_domains,self_send_before_external, andlinked_deleted_nremain — honestly, same as before — not independently caught by any current floor when zeroed alone.New synthetic-corpus metrics (2026-09-29 run,
--brands-extra eval/fixtures/test_brands.yaml)Full weight-zeroing sweep (25 weights, new corpus)
Every
config/local_weights.yamlweight zeroed one at a time, re-run against the regenerated corpus (--brands-extra eval/fixtures/test_brands.yaml):Baseline (no weight zeroed): precision 0.8169, recall 0.8788, ECE 0.1265, AUROC 0.9819, high-tier recall 0.7879 (52/66).
The two bolded ECE values (subject_age_h, upgrade_delay_min) are the negative-weight regressions
max_ece's tight margin exists to catch — both exceed the new 0.132 floor.resource_velocity_1h's bolded high-tier recall (0.5152) is the one weight this corpus'smin_high_tier_recallfloor independently catches on its own. Every S2b weight this PR added (sends_10m_max,sends_1h,sends_first_day,webmail_recipient_share,webmail_sends_1h,distinct_recipients_1h,subject_brand_match) shows little-to-no aggregate-corpus movement when zeroed alone — expected and already documented ineval/floors.yaml's own note that several existing weights (first_day_distinct_domains,self_send_before_external,linked_deleted_n) aren't independently caught by any corpus-level floor either; these seven weights' load-bearing-ness is proven at the fixture level instead, viainternal/worker's own mutation tests (TestWeightMutation_EveryWeightIsLoadBearing,TestWeightMutation_NewWeightsSurviveHalfAndDoubleSweep), not by this aggregate gate.Test counts
evalpackage: 35 tests, all passing (34 before this update, +1 new:TestLoadReplayDataset_WebmailRecipientShareIsNonZero; every otherLoadReplayDatasetcall site across the package's tests updated in place for the newWebmailSetparameter).eval/genpackage: 5 tests, all passing, unchanged count (TestGenerate_Deterministic,TestGenerate_FamilyCoverage,TestGenerate_EventsValidateAndRedact,TestGenerate_RoundTripsThroughLoadReplayDataset,TestGenerate_RecallVariesWithSeed) —TestGenerate_RecallVariesWithSeednow also loads and threadsconfig/webmail.yaml.cmd/abusekitpackage: all 10TestRunEval_*tests passing. 6 were broken by webmail loading becoming unconditional inrunEval(each ran from a tmp dir with noconfig/subdirectory to resolve the relative default against) — fixed by adding an explicit--webmailflag, the same way--brandsalready was; a 7th (TestRunEval_UnknownRuleAndScorerAreBadInput) was passing only because it happens to fail on an UNRELATED check with the same exit code, so it got the flag added too so it actually exercises what it claims to.go build ./...,go vet ./...,go test -short ./...,go test ./...(Postgres,ABUSEKIT_REQUIRE_DB=1),go test -race ./internal/... ./eval/...,go mod tidy(no diff),make lint,make test,make test-db,make gateall clean.Hygiene check
Hygiene check clean.
🤖 Generated with Claude Code
https://claude.ai/code/session_014cdM7WyRc3mD3vQNXMTDB8