btc-intel / docs /detection_unit_protocol.md
Anish-g's picture
Deploy btc-intel on free ZeroGPU Gradio Space
824aa03 verified
|
Raw History Blame Contribute Delete
7.57 kB
# Detection unit protocol
**Pre-registered. Written and committed before the actor-level label was built,
the stacker retrained, or any of the numbers below were seen.**
## Why this exists
Every number this project has quoted so far has been *per wallet*: what share of
illicit-labelled wallets did we alert on. That is not what the product does and
not what an investigator asks for. A ransomware operation is one case. It owns a
collector address and a dozen change addresses in a peel chain; a layering
scheme owns a source wallet, tens of hop wallets and a sink. Wallet recall
scores a system for how many of those it enumerates, which rewards the
uninteresting half of the job (the hops are near-identical one-transaction
wallets) and says nothing about the interesting half: did we find the operation
at all, and once found, can we walk out to the rest of it.
Choosing the unit after seeing which unit flatters the numbers is how a metric
becomes marketing. So the unit is fixed here, in advance.
## The unit of detection: the actor
**An actor is one ground-truth illicit operation** β€” the criminal enterprise
behind one typology instance, not each address it touched.
Derived from `ground_truth.json` (`eval/actors.py`), never from anything the
pipeline produces:
- **ransomware_collector** β€” one actor per instance: the collector actor. Its
wallets are the collector address plus every change address in its peel chain.
Victims are not part of the actor (they are the complainants) and neither are
the `cashout` counterparties, which are separate actors receiving the peel.
- **layering** β€” one actor per instance, identified by the originating cluster
of that instance's `layering_split` / `layering_merge` transactions. Its
wallets are the source, every intermediate hop wallet and the sink. The
generator models each hop as its own `Actor` object because each needs an
owner; in the world being simulated they are one launderer's wallets, and
they are grouped back together here.
Patterns that are *not* actors, and why: `normal` and `exchange` are not
illicit; `same_actor_cluster` is a clustering test, not a crime; `coinjoin` is
mixing, which is suspicious but not by itself illegal (consistent with
`engines.gnn.illicit_typologies`); `ransomware_victim` and `cashout` are
counterparties of an actor, not the actor.
An **entity** (our clustering's output) is illicit **iff at least one of its
wallets belongs to an illicit actor**. That is the label the stacker is
retrained on. A wallet is **alerted** iff the entity holding it is in the final
alert list at the configured `fusion.alert_threshold`.
## Metrics
Reported per typology, on the standard and the shifted set, both at the
canonical observation rate.
1. **`case_detection_rate`** β€” fraction of illicit actors with at least one
alerted wallet. *Did we find the operation?* This is the headline number.
2. **`alert_precision`** β€” fraction of alerts whose entity holds at least one
wallet of any illicit actor. *Of what we put in front of an investigator, how
much was a real case?* Alerts on victim or cash-out wallets count as
imprecise here even though they are adjacent to crime; adjacency is what
taint reports, not what an alert claims.
3. **`trace_coverage@N`**, N = 2 and 4 β€” for each *detected* actor, start from
its alerted wallets and walk the entity graph outward as taint does
(`fusion.taint`'s direction and hop budget), N hops. Score the fraction of
that actor's *other* wallets that are reached. *Once we have one thread, how
much of the operation can we pull in?* Undetected actors are excluded β€” this
measures expansion, not detection, and averaging a zero over cases we never
found would confound the two.
4. **`wallet_recall_broad`** β€” the old metric, kept as **secondary**: recall over
every wallet of every illicit-patterned cluster including pass-through hops,
under the broad label. Reported so this pass is comparable with the previous
one, not because it is what the product optimises.
Precision has no actor-level denominator by design: an alert is a unit of an
investigator's attention, so it is counted per alert.
## Origin attribution: outcomes and costs
Accuracy treats every origin error alike. It is not alike: naming a public relay
operator's address as a suspect's is a different kind of wrong from having no
answer. Each non-degraded origin estimate falls into exactly one outcome, and
the score is the mean weight (`engines.propagation.origin_filter.cost_weights`,
defaults below):
| outcome | when | weight |
| --- | --- | --- |
| `correct_actionable` | not abstained, correct, and the named address is residential/unclassified β€” a lead an ISP request can act on | +1 |
| `correct_infrastructure` | not abstained, correct, but the address is a Tor exit, hosting/VPN or relay β€” a true *anonymized entry point*, worth recording, not directly actionable | +0.3 |
| `wrong_uninvolved_third_party` | not abstained and wrong β€” we named an address that did not send the transaction | -3 |
| `abstained` | `low_confidence_origin` is set: we declined to name anyone | 0 |
The asymmetry is the point: abstaining is free, being wrong out loud is three
times as costly as being right is valuable.
Three filter configurations are reported side by side, on the same data:
`off` (no class weighting), `combined` (the old filter: relay, Tor and hosting
all penalised in the ranking) and `split` (relay penalised in the ranking; Tor
and hosting not penalised, but reported as an anonymized entry point with a
reduced *attribution* confidence handed to the correlation engine). The split
exists because a masked broadcast genuinely does originate at the Tor exit or
hosting address β€” penalising it in the ranking pushes the estimator off the
right answer β€” whereas a Bitnodes-listed relay forwards other people's traffic
and is never the sender.
## `low_confidence_origin`
The flag formerly named `origin_likely_unobserved`. It was renamed because the
name asserted something it cannot know β€” that the true origin is absent from the
data β€” while what it actually measures is that this estimate is weak. It is now
called what it is, everywhere: API, alerts, config, docs.
**Cutoff selection rule, fixed here in advance:**
> The confidence cutoff is chosen on **seed A** (`eval.seed`, 41) as the value in
> {0.10, 0.15, … 0.90} maximising the cost-weighted origin score above, under
> the split filter, at the canonical observation rate. Ties go to the lower
> cutoff, which abstains less. The chosen value is then reported **unchanged**
> on **seed B** (`eval.seed_b`, 43), together with the flag's precision, recall
> and the accuracy split (accuracy with the flag clear vs. raised).
Seed B is never used to choose anything. If the seed B numbers are worse than
seed A's, that difference is the honest estimate of the selection bias, and it
gets written down rather than tuned away.
## Datasets
Unchanged from `docs/origin_eval_protocol.md`: the canonical `eval:` block in
`config.yaml` β€” 200 actors, 1200 transactions, observation rates 0.1 / 0.3 /
0.6, decisions at 0.3. Every table states its dataset (standard or shifted),
its seed and its label definition. The shifted set is the headline wherever
both are shown.
## What would invalidate this
If the actor-level numbers come out worse than the wallet-level ones, that is
the answer, and both go in the report. Any deviation from this document must be
written into `eval/results.md` with its reason.