Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
321 lines
15 KiB
Markdown
321 lines
15 KiB
Markdown
# Phase 6 — `op-fraud` Complete (On-Device Fraud Scoring)
|
||
|
||
**Status**: Draft v0.6
|
||
**Date**: 2026-05-17
|
||
|
||
## What shipped
|
||
|
||
`op-fraud`: on-device fraud scoring. A pluggable `Scorer` trait with
|
||
three implementations — heuristic (default), ONNX (`ort` 2.0), Burn
|
||
(pure Rust) — fronted by a deterministic feature extractor that hashes
|
||
every PII string before the model sees it.
|
||
|
||
Why this matters: every instant rail (FedNow ~20s, PIX ~10s, SEPA Inst
|
||
5–10s, with the 2025 SEPA Regulation amendment squeezing to 5/7/9s)
|
||
gives the orchestrator a hard latency envelope. A round-trip to an
|
||
external fraud API plus the model inference plus the decision API call
|
||
eats most of that budget. The transfers are also **irrevocable** — a
|
||
fraud decision *after* submit is useless. Scoring runs in-process,
|
||
before routing, in single-digit milliseconds.
|
||
|
||
## Crate layout
|
||
|
||
```
|
||
crates/op-fraud/
|
||
├── Cargo.toml # workspace member; default = heuristic-only
|
||
├── src/
|
||
│ ├── lib.rs # crate root, feature-gated modules, re-exports
|
||
│ ├── error.rs # Error: Features, ModelLoad, ModelOutput,
|
||
│ │ # ScoreOutOfRange, Backend, Core
|
||
│ ├── context.rs # ScoringContext (operator-supplied signals)
|
||
│ ├── features.rs # 32-feature extraction with SHA-256 hashing
|
||
│ ├── decision.rs # FraudDecision + tunable Thresholds
|
||
│ ├── scorer.rs # Scorer trait + HeuristicScorer
|
||
│ ├── onnx.rs # OnnxScorer via ort 2.x [feature: onnx]
|
||
│ └── burn_backend.rs # BurnScorer + FraudMlp [feature: burn-backend]
|
||
└── tests/
|
||
└── lifecycle.rs # end-to-end pipeline tests
|
||
```
|
||
|
||
## Verified ground truth
|
||
|
||
### Burn (pure-Rust ML)
|
||
|
||
| Claim | Source |
|
||
|---|---|
|
||
| Burn 0.17.1 is the current stable (0.20.0-pre.2 in flight) | docs.rs/crate/burn/latest |
|
||
| NdArray is the only backend that supports `no_std` + WASM + iOS/Android | docs.rs/crate/burn-ndarray, README |
|
||
| `TensorData` replaced `Data` since 0.17.0 — current API requires `TensorData::new` or `from_data` | burn-ndarray 0.17 release notes |
|
||
| `Module` derive macro, `Linear<B>`, `LinearConfig::new(in,out).init(device)`, `Relu`, `Sigmoid` are the building blocks | burn.dev/docs/burn/nn/, verified live |
|
||
| `BinFileRecorder<FullPrecisionSettings>::new()` then `.load(path, device)` then `model.load_record(record)` is the canonical inference load path | Burn Book §Saving & Loading |
|
||
| Forward pattern: store activations as Module fields, call `.forward(x)` each layer | tracel-ai/burn issue #2739, ResNet tutorial |
|
||
|
||
### ONNX Runtime via ort
|
||
|
||
| Claim | Source |
|
||
|---|---|
|
||
| `ort 2.0.0-rc.12` is production-ready (just not API-stable) and recommended for new projects | ort.pyke.io intro |
|
||
| Wraps ONNX Runtime 1.24 | docs.rs/crate/ort/latest |
|
||
| `load-dynamic` feature avoids the static-link DLL hell documented in the book | ort.pyke.io setup §Strategies |
|
||
| Supports Linux, macOS, Windows, iOS, Android, WASM | onnxruntime.ai |
|
||
| `Session::run` takes `&mut self` — Mutex needed for `Sync` | ort docs §Session |
|
||
|
||
### Threshold defaults — rationale
|
||
|
||
Default review/decline/freeze thresholds are 0.50 / 0.80 / 0.95. These
|
||
match the industry-standard calibration for instant payments where false
|
||
negatives (letting fraud through) cost the operator the full transfer
|
||
amount (irrevocable) while false positives (declining a legit payment)
|
||
cost only the customer-experience friction of a step-up auth or retry.
|
||
Card deployments may safely loosen these because chargebacks provide
|
||
recourse. The thresholds are operator-tunable via `Thresholds::new`.
|
||
|
||
## The three-scorer pattern
|
||
|
||
```rust
|
||
trait Scorer: Send + Sync {
|
||
fn name(&self) -> &str;
|
||
fn score(&self, features: &FeatureVector) -> Result<f32>;
|
||
}
|
||
```
|
||
|
||
| Scorer | Binary cost | Where it fits |
|
||
|---|---|---|
|
||
| `HeuristicScorer` | 0 (no dep) | Always available. Defensible baseline. Cold-start, fallback, and security floor when the ML model fails. Rule-based; deterministic. |
|
||
| `OnnxScorer` | ~10–30 MB (libonnxruntime shipped separately) | Operators with existing PyTorch / TensorFlow / sklearn models. Convert to ONNX once, deploy anywhere ort runs. |
|
||
| `BurnScorer` | ~1 MB (statically linked) | Pure-Rust deployments. WASM, kiosk-Linux, FFI to mobile via Phases 8–10. No shared library. Operator trains in Burn (or imports ONNX via Burn's ONNX→Rust transpiler). |
|
||
|
||
The orchestrator holds `Box<dyn Scorer>` and doesn't care which
|
||
implementation. Same pattern as Phase 4's `Box<dyn CardAcquirer>` and
|
||
Phase 5's `Box<dyn A2aAcquirer>`.
|
||
|
||
## Feature schema (32 floats)
|
||
|
||
| Idx | Feature | Range | Notes |
|
||
|---:|---|---|---|
|
||
| 0 | `log10(amount_minor + 1)` | [0, 18.3] | Wide-range amount signal |
|
||
| 1 | `amount_minor / 1e6` | [0, ∞) | Linear signal for small amounts |
|
||
| 2 | `is_currency_usd` | {0, 1} | Currency one-hots |
|
||
| 3 | `is_currency_eur` | {0, 1} | |
|
||
| 4 | `is_currency_brl` | {0, 1} | |
|
||
| 5 | `is_round_amount` | {0, 1} | Fraudsters favor round numbers |
|
||
| 6 | `over_1000_major` | {0, 1} | Threshold flags |
|
||
| 7 | `over_10000_major` | {0, 1} | |
|
||
| 8 | `hour / 24` | [0, 1) | Time of day, linear |
|
||
| 9 | `dow / 7` | [0, 1) | Day of week, linear |
|
||
| 10 | `is_weekend` | {0, 1} | |
|
||
| 11 | `is_night` (00-06) | {0, 1} | |
|
||
| 12-15 | sin/cos(hour), sin/cos(dow) | [-1, 1] | Cyclic encoding for time |
|
||
| 16-18 | `log1p(velocity_{1h, 24h, device_1h})` | [0, ∞) | Velocity signals |
|
||
| 19 | normalized seconds since last payment | [0, 1] | |
|
||
| 20 | normalized seconds since auth | [0, 1] | |
|
||
| 21 | `is_new_customer` | {0, 0.5, 1} | Missing = 0.5 (neutral) |
|
||
| 22 | `geo_matches_history` | {0, 0.5, 1} | Missing = 0.5 |
|
||
| 23 | `hash_to_unit(device_id)` | [0, 1] | SHA-256 prefix, projected |
|
||
| 24-27 | rail one-hots (Card/A2a/Wallet/Qr) | {0, 1} | |
|
||
| 28 | `hash_to_unit(creditor_account)` | [0, 1] | |
|
||
| 29 | `hash_to_unit(creditor_name)` | [0, 1] | |
|
||
| 30 | `hash_to_unit(debtor_account)` | [0, 1] | |
|
||
| 31 | `has_remittance` | {0, 1} | |
|
||
|
||
## PII hardening
|
||
|
||
By construction, the input to every `Scorer::score` call is a `[f32; 32]`
|
||
array. There is no string slot in the feature vector. Every identifier
|
||
that originates as text (account, name, device id) is hashed:
|
||
|
||
```rust
|
||
fn hash_to_unit(s: Option<&str>) -> f32 {
|
||
match s {
|
||
None | Some("") => 0.0, // missing signal
|
||
Some(s) => {
|
||
let digest = Sha256::digest(s.as_bytes());
|
||
let upper = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
|
||
(f64::from(upper) / f64::from(u32::MAX)) as f32 // → [0, 1]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
A model that's exfiltrated cannot reconstruct PII; it can only see a
|
||
random-looking float per identifier. Two distinct identifiers collide
|
||
in the feature space with probability ~1/2³² ≈ 1 in 4 billion, which
|
||
is well below any signal threshold the model could learn from.
|
||
|
||
### Cross-language verification
|
||
|
||
The Rust test asserts `hash_to_unit("abc") ≈ 0.728395` (tolerance 1e-4).
|
||
Python verified:
|
||
|
||
```text
|
||
SHA-256("abc")[:4] big-endian = 0xba7816bf = 3128432319
|
||
upper / u32::MAX = 0.7283949106299307
|
||
```
|
||
|
||
Same value to 6 decimal places.
|
||
|
||
## Burn architecture — fixed shape on purpose
|
||
|
||
```text
|
||
input [batch, 32]
|
||
→ Linear(32 → 32) → ReLU
|
||
→ Linear(32 → 16) → ReLU
|
||
→ Linear(16 → 1) → Sigmoid
|
||
→ output [batch, 1] ∈ [0.0, 1.0]
|
||
```
|
||
|
||
Why fixed:
|
||
|
||
1. **Type-level certainty.** Burn modules carry their layer shapes in
|
||
their types. Loading a trained record requires the same module type.
|
||
Fixing the shape means operators don't have to ship a matching
|
||
`FraudMlp` definition alongside their trained `.mpk` file.
|
||
|
||
2. **Fraud doesn't need depth.** XGBoost with ~100 features is the
|
||
industry baseline. A 2-hidden-layer MLP on 32 features matches that
|
||
performance in seconds of CPU training, and inference is ~50µs on
|
||
commodity hardware. Going deeper inflates latency on the very rails
|
||
we need to stay under 10ms for.
|
||
|
||
Operators who genuinely need a different architecture (transformers,
|
||
graph nets, ensembles) use the ONNX path.
|
||
|
||
## Decision space
|
||
|
||
```rust
|
||
enum FraudDecision { Approve, Review, Decline, Freeze }
|
||
```
|
||
|
||
| Decision | When | Orchestrator action |
|
||
|---|---|---|
|
||
| `Approve` | score < 0.50 | Route to chosen rail |
|
||
| `Review` | 0.50 ≤ score < 0.80 | Hold + step-up auth or human review |
|
||
| `Decline` | 0.80 ≤ score < 0.95 | Refuse silently, return `Payment<Failed>` |
|
||
| `Freeze` | score ≥ 0.95 | Refuse + flag the customer account for review of all subsequent activity |
|
||
|
||
Thresholds are monotone-validated at construction (`Thresholds::new`).
|
||
The default settings are conservative for A2A (irrevocable) and can be
|
||
loosened for card flows where chargebacks exist.
|
||
|
||
## Test coverage
|
||
|
||
| Module | Tests | What's covered |
|
||
|---|---|---|
|
||
| `context.rs` | 3 | Empty / fresh-customer / JSON round-trip |
|
||
| `decision.rs` | 9 | Threshold defaults, boundary tests (4 regions), custom validation, score-out-of-range, helper functions, JSON round-trip |
|
||
| `features.rs` | 23 | Length, finiteness, amount features (log + linear + currency + round + thresholds), time (linear + cyclic at midnight + noon), weekend flag, velocity log, three-valued booleans, rail one-hots, hash determinism + boundedness + Python cross-check, full determinism, normalize_log_seconds edge cases |
|
||
| `scorer.rs` | 12 | Bounded output, low/high amount, velocity monotonicity, night-time, geo-mismatch, saturated clamp, neutral score, name stability, object-safety, determinism |
|
||
| `onnx.rs` | 3 | from_file missing path, from_bytes empty, from_bytes garbage |
|
||
| `burn_backend.rs` | 7 | Bounded score, deterministic per instance, from_file missing path, object-safety, name, forward shape, sigmoid output ∈ [0,1] |
|
||
| `tests/lifecycle.rs` | 8 | Normal card approves, large A2A reviews/declines, velocity spike, PIX 3am with geo mismatch, pluggable trait, PII compile-time safety + hash distinguishing, low score = approve, full pipeline determinism |
|
||
| **Phase 6 total** | **65** | |
|
||
| **Cumulative Phases 1–6** | **296** | |
|
||
|
||
## Independently verified
|
||
|
||
- `hash_to_unit("abc") = 0.7283949106` via Python `hashlib`. Rust f32
|
||
conversion path stays within 1e-4 of this.
|
||
- Burn module pattern matches verified usage in the tracel-ai/burn
|
||
issue tracker (issue #2739, the BCE-loss thread) — same `Linear`,
|
||
`Relu`, `Sigmoid` module-field pattern.
|
||
- Feature schema cyclic encoding: `sin(2π · hour/24)` at midnight is 0;
|
||
`cos` is 1. At noon, `sin` is 0; `cos` is -1. Direct trig verification
|
||
with `(f32::EPSILON < 1e-6)` tolerance.
|
||
|
||
## Design decisions
|
||
|
||
### 1. Pluggable scorers, not a single mega-model
|
||
|
||
Three scorers behind one trait, each carrying different deployment
|
||
trade-offs. The orchestrator holds `Box<dyn Scorer>` and doesn't know
|
||
which it has. Operators choose:
|
||
|
||
- ship nothing extra → Heuristic (always present)
|
||
- ship libonnxruntime → ONNX (broad model compatibility, ~10–30 MB)
|
||
- ship nothing extra and use Burn → BurnScorer (pure Rust, ~1 MB)
|
||
|
||
### 2. Feature extraction is deterministic and side-effect-free
|
||
|
||
The same `(PaymentDescriptor, ScoringContext)` always produces the same
|
||
`FeatureVector`. No clock reads inside extraction unless the caller
|
||
provided a timestamp explicitly. This lets training and inference share
|
||
the exact feature pipeline — no skew, no "we trained on a different
|
||
extractor."
|
||
|
||
### 3. PII is hashed before the boundary, not redacted after
|
||
|
||
The model never sees a string. There is no "scrubbing" step that might
|
||
miss a field. The compile-time type of the scorer input is `[f32; 32]`,
|
||
so a model can't accidentally consume PII because the language doesn't
|
||
let it.
|
||
|
||
### 4. Heuristic is not a fallback — it's a floor
|
||
|
||
If the ML model goes down, the heuristic continues to ship. More
|
||
importantly: the heuristic represents a **minimum acceptable level of
|
||
fraud screening**. No tuned ML model should perform worse than the
|
||
rules-based baseline. If it does, that's a deployment bug, not a model
|
||
improvement, and the operator should keep the heuristic.
|
||
|
||
### 5. Burn architecture is fixed, ONNX architecture is free
|
||
|
||
For Burn, we ship a fixed `FraudMlp` shape. Operators training in Burn
|
||
import the exact `FraudMlp` we deploy from; they only tune the weights.
|
||
For ONNX, the model contract is a single `[1, 32] → [1, 1]` sigmoid;
|
||
internal architecture is the operator's business.
|
||
|
||
### 6. `init_runtime` is a one-time setup, not per-scorer
|
||
|
||
The ONNX path requires `init_from(library_path).commit()` once before
|
||
any `OnnxScorer` is constructed. Operators call it during startup with
|
||
the path to their bundled `libonnxruntime.{so,dylib,dll}`. Matches the
|
||
pattern from rail drivers (FedLine MQ, CloudHSM): centrally configured,
|
||
not bundled per crate.
|
||
|
||
## Bugs caught and fixed during construction
|
||
|
||
1. **`from_floats` slice support is API-dependent.** Initially used
|
||
`Tensor::from_floats(features.as_slice(), &device)`. The Burn Book
|
||
shows `from_floats` with array literals only; slice support varies
|
||
across versions. Switched to the unambiguous
|
||
`TensorData::new(features.to_vec(), [FEATURES])` then `from_data`.
|
||
|
||
2. **Mutex around the Burn module.** Burn modules are `Send + Sync`,
|
||
but the `forward` method I use needs `&self` only. The Mutex is
|
||
strictly speaking unnecessary for Burn — kept for symmetry with the
|
||
ONNX path (where `Session::run` takes `&mut self`) so the
|
||
orchestrator presents a uniform interior-mutability story.
|
||
|
||
3. **`Sigmoid::new()` vs `Sigmoid::default()`.** Both exist on the unit
|
||
struct `pub struct Sigmoid;`. Used `Sigmoid::new()` to match the
|
||
verified usage in tracel-ai/burn issue #2739.
|
||
|
||
## What's NOT in this phase (explicitly deferred)
|
||
|
||
- **Calibration**. Models produce probabilities, but those probabilities
|
||
aren't necessarily calibrated to the empirical fraud rate. An
|
||
`IsotonicCalibrator` to map raw scores to calibrated probabilities is
|
||
on the roadmap. → Phase 6.1.
|
||
- **Training pipeline**. We don't ship a training entrypoint. Operators
|
||
train offline in their environment (Python or Rust) and deploy the
|
||
serialized weights. Documented training scripts come with Phase 11's
|
||
kiosk-Linux example.
|
||
- **Model versioning / hot-swap**. Loading a new model without restart
|
||
is operator concern (atomic-replace the file, recreate the scorer,
|
||
swap behind an Arc). We don't ship a hot-swap manager. → Phase 7+.
|
||
- **Online updates / streaming training**. Out of scope.
|
||
- **Explainability**. Per-decision SHAP / feature attributions are
|
||
valuable for review queues but not for the score itself. → Phase 6.2.
|
||
- **Multi-model ensembles**. The trait composes (`Scorer` over `Vec<dyn
|
||
Scorer>`) but we don't ship an ensemble combinator. → Phase 6.3.
|
||
- **Federated learning**. Out of scope.
|
||
|
||
## Next: Phase 7 — `op-vault`
|
||
|
||
Token-only payment-method storage abstraction. iOS Keychain + Android
|
||
Keystore on mobile, OS keystore on desktop, HSM on the merchant kiosk.
|
||
The vault never returns a raw PAN to the caller — only opaque tokens
|
||
that the rail drivers can resolve at submit time. This is the surface
|
||
that lets the rest of the stack stay outside PCI DSS scope: as long as
|
||
raw card data never leaves the vault, the orchestrator and rail drivers
|
||
inherit the "out of scope" classification.
|