OpenPay (1 crate, 2487 LOC, 33 tests)
op-fraud-graph Graph-based fraud detection. EntityKind (card/email/
device/IP/address/phone/BIN+last4/account) with
canonicalization + hashed EntityKey + billing↔shipping
mismatch detection. Adjacency-list FraudGraph with
idempotent upsert + edge-merge. Double-hashing Bloom
filter (capacity+FPR-sized). Union-find connected
components. PageRank with dangling-sink handling.
Louvain community detection. Shared-instrument hub
rings. Sliding ring-buffer velocity counters. Synthetic
identity heuristics (freshness + heavy-use + mail-drop).
Structuring / smurfing / mule-chain DFS. EntityRisk +
TransactionRisk + RiskBand combiner.
Smart Byte (1 crate, ~1844 LOC, 44 tests)
smart-byte-ilp Interledger Protocol v4. OER codec (length-determinant,
var-octet-string, BE u32/u64). Prepare/Fulfill/Reject
packet roundtrip with all 19 IL-RFC-27 reject codes.
SHA-256 Condition + Fulfillment with constant-time
verify. ILP address validator (7 schemes, 1-128 char
segments, 1023-byte cap, segment-aligned prefix match).
STREAM (15 frame types). BTP. SPSP payment-pointer
resolver. Open Payments API. Longest-prefix routing
Connector with fixed-point quote conversion + balance +
rate-limit checks.
EOC (1 crate, 1921 LOC, 36 tests)
eoc-quantize Quantization primitives. int8 (symmetric/asymmetric
with zero-inclusion fix), int4 (GPTQ + AWQ group quant),
int2 (BitNet), fp8 (E4M3/E5M2), nf4 (QLoRA). GGUF v3
header parse. safetensors read sketch. Calibration
datasets + per-channel scales. KV-cache quant. Energy
tradeoff curves (joules-per-token across schemes).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
173 lines
5.1 KiB
Rust
173 lines
5.1 KiB
Rust
//! Combiners that turn the various graph signals into one risk score.
|
|
//!
|
|
//! These are deliberately small, transparent functions: every operator
|
|
//! ends up tuning them, so making the formulas opaque buys nothing. A
|
|
//! more sophisticated downstream learner (in `op-fraud`) can ingest the
|
|
//! raw component scores instead of the combined value.
|
|
|
|
use crate::components::ConnectedComponents;
|
|
use crate::pagerank::PageRankResult;
|
|
use crate::ring::{Ring, RingDetector};
|
|
use crate::synthetic::SyntheticIdentityScorer;
|
|
use crate::graph::{FraudGraph, VertexId};
|
|
|
|
/// Coarse risk band for human-readable output.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum RiskBand {
|
|
/// `[0.00, 0.30)`
|
|
Low,
|
|
/// `[0.30, 0.60)`
|
|
Medium,
|
|
/// `[0.60, 0.85)`
|
|
High,
|
|
/// `[0.85, 1.00]`
|
|
Severe,
|
|
}
|
|
|
|
impl RiskBand {
|
|
/// Bucket a `[0,1]` score.
|
|
pub fn from_score(s: f32) -> Self {
|
|
if s < 0.30 {
|
|
Self::Low
|
|
} else if s < 0.60 {
|
|
Self::Medium
|
|
} else if s < 0.85 {
|
|
Self::High
|
|
} else {
|
|
Self::Severe
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Combined risk for a single entity vertex.
|
|
#[derive(Debug, Clone)]
|
|
pub struct EntityRisk {
|
|
/// The vertex this score belongs to.
|
|
pub vertex: VertexId,
|
|
/// Component-size contribution (`[0, 1]`): saturates at 100 members.
|
|
pub component_score: f32,
|
|
/// PageRank centrality (`[0, 1]`): proportional to PR / max(PR).
|
|
pub centrality_score: f32,
|
|
/// Ring participation (`[0, 1]`): max of [`Ring::score`] across
|
|
/// rings this vertex belongs to.
|
|
pub ring_score: f32,
|
|
/// Synthetic-identity score (`[0, 1]`).
|
|
pub synthetic_score: f32,
|
|
/// Weighted combination in `[0, 1]`.
|
|
pub combined: f32,
|
|
/// Coarse band.
|
|
pub band: RiskBand,
|
|
}
|
|
|
|
/// Combined risk for one transaction (a small set of co-occurring entities).
|
|
#[derive(Debug, Clone)]
|
|
pub struct TransactionRisk {
|
|
/// The participating vertices.
|
|
pub vertices: Vec<VertexId>,
|
|
/// Per-entity risk, in the same order as `vertices`.
|
|
pub per_entity: Vec<EntityRisk>,
|
|
/// Worst-of combiner. Most fraud teams use max() across the
|
|
/// involved entities — one bad neighbour ruins the whole tx.
|
|
pub combined: f32,
|
|
/// Coarse band derived from `combined`.
|
|
pub band: RiskBand,
|
|
}
|
|
|
|
/// Score a single vertex by combining the four component scores.
|
|
///
|
|
/// Weights are deliberately uniform-ish:
|
|
/// - ring_score: 0.40 (the loudest signal)
|
|
/// - synthetic: 0.25
|
|
/// - component_score: 0.20
|
|
/// - centrality: 0.15
|
|
pub fn score_entity(
|
|
g: &FraudGraph,
|
|
v: VertexId,
|
|
components: &ConnectedComponents,
|
|
pagerank: &PageRankResult,
|
|
rings: &[Ring],
|
|
synthetic: &SyntheticIdentityScorer,
|
|
now_unix: i64,
|
|
) -> EntityRisk {
|
|
let component_score = components
|
|
.component_of(v)
|
|
.and_then(|c| components.component_size(c))
|
|
.map(|n| (n.min(100) as f32) / 100.0)
|
|
.unwrap_or(0.0);
|
|
|
|
let max_pr = pagerank
|
|
.scores
|
|
.iter()
|
|
.copied()
|
|
.fold(0.0_f32, f32::max)
|
|
.max(1e-12);
|
|
let centrality_score = pagerank.score(v).unwrap_or(0.0) / max_pr;
|
|
|
|
let ring_score = rings
|
|
.iter()
|
|
.filter(|r| r.hub == v || r.members.contains(&v))
|
|
.map(Ring::score)
|
|
.fold(0.0_f32, f32::max);
|
|
|
|
let synthetic_score = synthetic.score(g, v, now_unix);
|
|
|
|
let combined =
|
|
(0.40 * ring_score + 0.25 * synthetic_score + 0.20 * component_score + 0.15 * centrality_score)
|
|
.clamp(0.0, 1.0);
|
|
let band = RiskBand::from_score(combined);
|
|
|
|
EntityRisk {
|
|
vertex: v,
|
|
component_score,
|
|
centrality_score,
|
|
ring_score,
|
|
synthetic_score,
|
|
combined,
|
|
band,
|
|
}
|
|
}
|
|
|
|
/// Score a transaction = max over participating entities.
|
|
pub fn score_transaction(
|
|
g: &FraudGraph,
|
|
vertices: &[VertexId],
|
|
components: &ConnectedComponents,
|
|
pagerank: &PageRankResult,
|
|
rings: &[Ring],
|
|
synthetic: &SyntheticIdentityScorer,
|
|
now_unix: i64,
|
|
) -> TransactionRisk {
|
|
let per: Vec<EntityRisk> = vertices
|
|
.iter()
|
|
.map(|&v| score_entity(g, v, components, pagerank, rings, synthetic, now_unix))
|
|
.collect();
|
|
let combined = per
|
|
.iter()
|
|
.map(|r| r.combined)
|
|
.fold(0.0_f32, f32::max);
|
|
let band = RiskBand::from_score(combined);
|
|
TransactionRisk {
|
|
vertices: vertices.to_vec(),
|
|
per_entity: per,
|
|
combined,
|
|
band,
|
|
}
|
|
}
|
|
|
|
/// Convenience: drive the full pipeline against a graph with default
|
|
/// detectors, and score every vertex.
|
|
pub fn score_all(g: &FraudGraph, now_unix: i64) -> Vec<EntityRisk> {
|
|
let components = ConnectedComponents::from_graph(g);
|
|
let pagerank = crate::pagerank::PageRank::default()
|
|
.run(g)
|
|
.unwrap_or(crate::pagerank::PageRankResult {
|
|
scores: vec![0.0; g.vertex_count()],
|
|
iterations: 0,
|
|
converged: false,
|
|
});
|
|
let rings = RingDetector::default().detect(g);
|
|
let synthetic = SyntheticIdentityScorer::default();
|
|
g.vertices()
|
|
.map(|v| score_entity(g, v, &components, &pagerank, &rings, &synthetic, now_unix))
|
|
.collect()
|
|
}
|