Fraud output contract (MOD-STD)¶
Single source of truth for ML/rule prediction shapes, validation, score normalization, and dashboard handoff. Implementation lives in src/report_team/schemas.py, validation.py, normalize.py, evidence.py, policy.py, and agent.py.
Related repos:
| Repo | Role | Contract doc |
|---|---|---|
fraud-detection-ai-backend-stream/ml_service |
Emits typed MlPredictionOutput |
../fraud-detection-ai-backend-stream/docs/fraud-output-contract.md |
fraud-detection-ai-backend-stream/rule_service |
Emits typed PredictionResult |
same |
kyc-kyb-ai-backend |
KYC onboarding (separate domain) | ../kyc-kyb-ai-backend/docs/kyc-output-contract.md |
Pipeline¶
TransactionEvent → checks baselines → lastCheckReport
→ ml_service /call-and-predict OR rule_service
→ Pub/Sub → checks /report → compose_report → dashboard create-report
Report generation uses a hybrid model. The model may explain a fraud decision. It may never make the fraud decision.
prediction → validate contract → normalize score/category
→ derive evidence → evaluate fraud-decision-v1
→ LLM aiSummary only → invariant-checked report
| Field | Source |
|---|---|
fraudScore, riskCategory |
Python (normalize.py) |
indicatorRecords, aiChecks, reasonCodes |
Python (evidence.py) |
decision, escalations |
Python (policy.py, fraud-decision-v1) |
fraudIndicators, aiRecommendation |
Derived views of the structured decision (dashboard-compatible) |
aiSummary |
Gemini, or a deterministic fallback if Gemini fails |
provenance |
contract/policy/narrative versions + prediction hash |
Routing uses envelope check_type plus mutually exclusive identity keys: ML = probability_score, rule = flagged + score. predicted_level is shared (ML and rule both emit it) and is not an identity key. Mixed identity (probability_score together with flagged+score) fails with AMBIGUOUS_PREDICTION_CONTRACT.
Decision policy (fraud-decision-v1, provisional)¶
Primary decision (approve | pend | block) is mutually exclusive. Escalations are separate (investigator_review, sar_review).
riskCategory |
Primary | Escalation |
|---|---|---|
low |
approve |
none |
medium |
pend |
none |
high |
block |
investigator_review if any triggered indicator |
Named overrides (confirm with compliance):
| Signal | Effect |
|---|---|
BLACKLISTED_* |
at least pend |
SANCTIONS_* / KNOWN_FRAUD_* |
at least block |
SAR_* / SAR_TRIGGER |
add sar_review |
Blacklist cannot produce approve. Adding a material indicator cannot lower primary severity.
predicted_level must match the documented ML score bands or the payload is rejected (ML_SCORE_TIER_CONFLICT). This is contract enforcement, not a scoring-algorithm change (CH-SYNC).
Unflagged rule scores still map to riskCategory=low with fraudScore=round(score×100) and reason RULE_NOT_FLAGGED. Do not treat that as comparable to ML probability until a later product decision.
Input validation¶
TransactionEvent¶
Required for baseline checks. Validated via TransactionEvent in src/application/data_models/transaction.py.
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Transaction UUID |
time |
string | yes | HH:MM:SS |
date |
string | yes | YYYY-MM-DD |
amount |
float | yes | |
currency |
string | yes | e.g. NGN |
description |
string | null | no | |
type |
string | null | no | e.g. bill payment |
device |
string | null | no | |
device_type |
string | null | no | e.g. Online |
balance |
float | null | no | Account balance after txn |
Gap: channel is not on TransactionEvent today. POL-01 documents omni-channel scaffolding; wiring is separate.
Use validate_transaction_event() before baseline checks when validating inbound payloads.
lastCheckReport¶
Minimum structure for ML /call-and-predict. Validated via LastCheckReportInput in schemas.py.
| Field | Required | Notes |
|---|---|---|
id |
yes | Report id |
customerProfileId |
yes | |
temporalChecks.prediction |
no (defaults {}) |
Dict of temporal anomaly flags |
transactionChecks.prediction |
no | |
locationChecks.prediction |
no | |
*LastProcessedDate |
no | ISO-ish date strings |
createdAt |
no |
Each check section stores a flat prediction dict of boolean anomaly keys consumed by scoring.
ML prediction output¶
Produced by fraud-detection-ai-backend-stream/ml_service (ThresholdLabelling / ModelInference in ml_service/src/models/scoring.py).
| Field | Type | Description |
|---|---|---|
probability_score |
float 0–1 | Weighted anomaly score (old algorithm: sum weights / (sum − 32.5), cap 0.9) |
predicted_level |
"Low Risk" | "Mid Risk" | "High Risk" |
Mapped from score bands: >0.66 High, >0.33 Mid, else Low |
interpretation |
object | Human-oriented explanations per check |
observations |
object | Per-check {status, description} flags |
Pydantic model: MlPredictionOutput.
ML → dashboard normalization¶
fraudScore = round(probability_score × 100) # Python 3 banker's rounding
riskCategory = high if probability_score > 0.66
= medium if probability_score > 0.33
= low otherwise
predicted_level must equal the band for probability_score. A payload with 0.90 and "Low Risk" is rejected (ML_SCORE_TIER_CONFLICT). The reporting layer does not silently recompute the producer's tier.
indicatorRecords use stable codes (e.g. BLACKLISTED_LOCATION_TRANSACTION). fraudIndicators is the list of those labels. interpretation is not used for evidence or policy. severity is set only when the observation code carries a known signal (UNUSUAL_* / BLACKLISTED_* → high, HIGH_* / LARGE_* → medium). Unknown codes omit severity rather than defaulting to low.
aiChecks is one item per {status, description} observation. Stakeholder status is triggered (observation true) or not_triggered (observation false). Producer passed/failed language is not used on the dashboard payload.
Rule prediction output¶
Produced by fraud-detection-ai-backend-stream/rule_service (rules_based_engine.py).
| Field | Type | Description |
|---|---|---|
flagged |
bool | Whether any rule group failed |
score |
float 0–1 | Rule severity score |
passed_conditions |
list | Nested condition trees that passed |
failed_conditions |
list | Nested condition trees that failed |
predicted_level |
"Low Risk" | "Mid Risk" | "High Risk" |
Advisory tier from the rule engine; ignored for identity and dashboard riskCategory |
Pydantic model: RulePredictionOutput.
Rule → dashboard normalization¶
fraudScore = round(score × 100)
riskCategory = low if flagged == false
= high if flagged && score >= 0.66
= medium if flagged && score < 0.66
The high band is inclusive (>= 0.66) on the rule path. ML uses exclusive > 0.66. A flagged rule score of 0.659 can therefore round to fraudScore 66 with riskCategory medium.
fraudIndicators / aiChecks come from failed_conditions / passed_conditions (producer buckets). Those buckets mean predicate matched vs did not match, not “the analyst check passed.” Stakeholder aiChecks.status is therefore triggered (failed_conditions) or not_triggered (passed_conditions). Keep passed_conditions / failed_conditions only on rawAnalysisResponse.
Stakeholder labels describe the rule in business language (Transaction amount exceeds 5,000,000). Details separate observed value from threshold (20,000,000 was observed against a 5,000,000 threshold). Amounts are grouped with thousands separators. Currency symbols are not invented when the /report envelope has no currency. Missing actual_value is actual value was unavailable, never a silent pass.
indicatorRecords.code / reasonCodes remain stable machine identity (including condition UUIDs). severity is omitted unless the code matches a known prefix — UUID rule conditions must not show low as a fake default.
Dashboard output (AIReportResponse)¶
Final payload to {backend_api}/transaction/create-report:
| Field | Type | Source |
|---|---|---|
transactionId |
UUID | Pub/Sub envelope |
aiSummary |
string | LLM |
fraudScore |
int 0–100 | Normalizer |
riskCategory |
low | medium | high |
Normalizer (from score, not LLM) |
fraudIndicators |
list[string] | Labels derived from indicatorRecords |
indicatorRecords |
list[FraudIndicator] |
Stable code/label/source; severity only when authoritative |
aiRecommendation |
list[string] | Derived [decision] + escalations |
decision |
approve | pend | block |
Policy |
escalations |
investigator_review | sar_review |
Policy |
reasonCodes |
list[string] | Policy + evidence codes |
aiChecks |
list[FraudCheckResult] |
Python from observations / rule conditions; status is triggered | not_triggered |
provenance |
object | contract/policy versions, prediction hash, sourceScore |
rawAnalysisResponse |
object | Full Pub/Sub payload |
timestamp |
string | Server time |
Merged model: FraudDashboardReport.
Risk tier mapping¶
Code (implemented today)¶
Three tiers everywhere scoring and dashboard agree:
| Internal ML label (input, advisory) | Dashboard riskCategory |
Score band (probability_score) |
|---|---|---|
| Low Risk | low |
≤ 0.33 |
| Mid Risk | medium |
> 0.33 and ≤ 0.66 |
| High Risk | high |
> 0.66 |
Dashboard riskCategory is derived from probability_score and must agree with predicted_level. Rule path uses flagged + score as documented above.
Product plan (not implemented)¶
Some planning docs reference Critical / High / Mid / Low (four tiers). That mapping is not in production code. Adding a Critical tier requires coordinated changes in ml_service, rule_service, dashboard UI, and this contract — tracked under CH-SYNC, not MOD-STD.
Checks-service vs ml_service drift (document only)¶
MOD-STD does not fix scoring drift. Gaps for CH-SYNC:
| Area | checks-service (fraud-detection-ai-backend) |
ml_service (stream) |
|---|---|---|
| Scoring algorithm | Tier-group max per group (ANOMALY_TIER_GROUPS) in src/models/scoring.py — not wired into live checks path |
Legacy sum / (sum − 32.5) in ml_service/src/models/scoring.py — live ML path |
constant.py weights |
Includes channel_velocity_anomaly, tier groups, CRITICAL_FEATURES |
Missing channel weight and tier-group constants |
| Critical override | CRITICAL_FEATURES → score 1.0 in checks scoring module |
Blacklist keys hard-coded in anomaly_weighted_score() |
| Channel | DB column + temporal helper scaffolded; not end-to-end | No channel feature |
Until CH-SYNC lands, ML predictions on /call-and-predict use the stream ml_service algorithm. Baseline generation in checks may diverge if the unused checks scoring module is enabled later.
Verification¶
Unit tests: tests/test_fraud_contract.py
- Route detection (ML vs rule); mixed bodies rejected
- Score/tier bands; ML score/tier conflicts rejected
- Observation-derived indicator codes and checks
- Versioned decision policy, monotonicity, blacklist cannot approve
- Identical predictions: only
aiSummarymay differ - Provenance (
fraud-output-v3,fraud-decision-v1)
Run from repo root:
Related tickets¶
| Ticket | Scope |
|---|---|
| MOD-STD | Contract, validation, normalization, structured LLM narrative |
| CH-SYNC | Align checks-service and ml_service scoring + constants |
| CH-BASELINE | Historical replay (blocked on MOD-STD) |
| POL-01 | Channel field end-to-end wiring |