How to wire a python_ref scorer¶
When you need this: A typology that can't be expressed as
aggregation_windowthresholds — multi-source heuristics, ML scorers, graph traversal, or anything algorithmic. Thepython_reflogic type is the framework's escape hatch from spec-as-data.Prereqs: How to add a rule read — same spec workflow, just a different
logic.type. Python ≥3.10.xgboost/scikit-learnif you're loading a real model (pip install -e ".[ml]").Time: ~30 min for a deterministic heuristic. ~1-2h if you're packaging an ML model with
model_id/model_versionfor SR 11-7 evidence.
python_ref invokes Python you write. The framework gives it a sandboxed view of the run's data + audit ledger, and expects an alerts list back. Everything else — versioning, lineage, manifest-hash pinning — comes for free if you follow the convention.
Steps¶
1 · Declare the rule in aml.yaml¶
rules:
- id: mule_return_burst
name: "Mule-return burst (pacs.004 amber-flag clustering)"
severity: high
risk_tier: tier_1
regulation_refs:
- citation: "ISO 20022 pacs.004"
description: "Payment Return — reason codes AC03/AC04/MD07 indicate suspected mule activity"
business_intent: >
Detect customers whose recent ISO 20022 return-message activity shows
a burst of pacs.004 returns with AC03/AC04/MD07 reason codes, especially
with cross-border / shell-name beneficiaries.
logic:
type: python_ref
module: aml_framework.models.mule_return_burst_scorer
func: score
# Optional — any parameters the scorer reads from spec
params:
return_count_threshold: 3
country_fanout_threshold: 2
shell_density_threshold: 2
# Optional — for SR 11-7 / E-23 audit evidence
model_id: "mule_return_burst_v3"
model_version: "2026-05-24"
escalate_to: l2_aml_investigator
environments: ["dev", "test", "prod"]
evidence:
- "All pacs.004 return messages for the customer in the lookback window"
- "Beneficiary jurisdiction map"
- "Shell-name match against the BVI/PA registries"
2 · Implement the scorer¶
Create src/aml_framework/models/mule_return_burst_scorer.py:
"""Mule-return burst detection.
Layered qualification per the spec's `business_intent`:
Path A — snippet-equivalent ≥3 pacs.004 returns with AC03/AC04/MD07
Path B — cross-signal: count ≥2 + beneficiary-country fan-out ≥2
+ shell-name density ≥2
Either path opens an alert. Both paths score against the SAME alert
payload schema so downstream lineage / audit-pack treat them uniformly.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
@dataclass
class _Alert:
customer_id: str
rule_id: str
window_start: datetime
window_end: datetime
matched_return_ids: list[str]
return_count: int
country_fanout: int
shell_density: int
score_path: str # "A" or "B"
def score(
*,
spec_params: dict[str, Any],
data: dict[str, list[dict[str, Any]]],
as_of: datetime,
lookback_days: int = 30,
**_,
) -> list[dict[str, Any]]:
"""Required signature — kwargs are framework-injected.
Args:
spec_params: the `params:` block from `aml.yaml`
data: dict of {contract_id: list-of-rows} as a DuckDB-loaded view
as_of: the run's `as_of` datetime
lookback_days: optional, defaults to 30
**_: future-proof against the runner adding new kwargs
Returns:
list of alert dicts. Each must contain at minimum:
rule_id, customer_id, window_start, window_end,
matched_row_ids (or matched_return_ids).
Everything else flows into the alert's metadata.
"""
return_count_t = spec_params.get("return_count_threshold", 3)
country_t = spec_params.get("country_fanout_threshold", 2)
shell_t = spec_params.get("shell_density_threshold", 2)
window_start = as_of - timedelta(days=lookback_days)
txn_returns = data.get("txn_return", [])
by_customer: dict[str, list[dict]] = {}
for r in txn_returns:
if r.get("reason_code") not in ("AC03", "AC04", "MD07"):
continue
if r.get("returned_at") < window_start:
continue
by_customer.setdefault(r["customer_id"], []).append(r)
alerts: list[_Alert] = []
for customer_id, returns in by_customer.items():
n = len(returns)
countries = {r.get("beneficiary_country") for r in returns}
shells = sum(1 for r in returns if r.get("beneficiary_is_shell"))
if n >= return_count_t:
path = "A"
elif n >= 2 and len(countries) >= country_t and shells >= shell_t:
path = "B"
else:
continue
alerts.append(_Alert(
customer_id=customer_id,
rule_id="mule_return_burst",
window_start=window_start,
window_end=as_of,
matched_return_ids=[r["return_id"] for r in returns],
return_count=n,
country_fanout=len(countries),
shell_density=shells,
score_path=path,
))
# Framework expects list-of-dict, not list-of-dataclass.
return [a.__dict__ for a in alerts]
3 · Validate the spec + run¶
The runner:
- Imports your module via
importlib, sandboxed to theaml_framework.models.*namespace prefix (override withAML_PYTHON_REF_PREFIXfor institution-specific scorers) - Calls your
score()with the kwargs above - Captures returned alerts, normalizes (adds
thresholdsnapshot +reference_data_versionper PR-PAY-1), writes toalerts/<rule_id>.jsonl - Opens cases per alert + writes to
decisions.jsonlwithrule_version_hash(rule) - If your scorer raises: in strict mode (default) → run aborts with
PythonRefFailureafter recording the error indefect_log.jsonl; in permissive mode (AML_STRICT_PYTHON_REF=0) → run continues, defect is logged
4 · Verify it fired correctly¶
# 1. Alerts file present
cat .artifacts/run-.../alerts/mule_return_burst.jsonl | head -3
# 2. defect_log.jsonl is empty for this rule (no scorer errors)
grep mule_return_burst .artifacts/run-.../defect_log.jsonl
# 3. rule_version flows through
jq '.rule_version' .artifacts/run-.../cases/*.json | sort -u
5 · Add SR 11-7 / E-23 / SS1/23 model evidence¶
Because you set model_id + model_version on the rule, the framework adds them to every alert payload AND to the program_intent.md artifact in the regulator-ready ZIP. For the full model-risk evidence pack, add:
# In your aml.yaml
program:
nfrs:
notes: |
`mule_return_burst_v3` model card stored at
`s3://bank-mrm/aml/models/mule_return_burst/v3/model_card.pdf`.
Champion-challenger results: docs/model-cards/mule-return-burst.md.
Validation cadence: every 6 months per SR 11-7.
Verify it worked¶
Five checks:
alerts/<rule_id>.jsonlhas rows, each withcustomer_id,window_start,window_end,matched_return_ids,threshold,reference_data_version.defect_log.jsonlhas zero entries for this rule (scorer didn't error).monitoring_digest.json::alerts_per_ruleincludes your rule with a non-zero count.- Re-run with
--seed 42produces identical bytes — proven bytest_run_is_reproducible. - Case file has
rule_versionpopulated (PR-PAY-1 ensures this).
Common problems¶
| Symptom | Cause | Fix |
|---|---|---|
ImportError: scorer module not on allowed prefix |
Your module is outside aml_framework.models.* |
Either move it, or set AML_PYTHON_REF_PREFIX=your_org.aml.scorers,aml_framework.models |
Strict-mode abort with PythonRefFailure |
Scorer raised — check defect_log.jsonl for the traceback |
Fix the exception or run with AML_STRICT_PYTHON_REF=0 if you want permissive |
| Output bytes differ across runs (determinism break) | Iteration order over a set / dict not stable, or you hit datetime.now() somewhere |
Sort everything before emit; only use as_of (passed in), never wallclock |
Alerts missing threshold field |
You returned the alert before the framework's normalization layer ran | Don't bypass — return plain dicts, let _normalize_alerts_payload (PR-PAY-1) stamp metadata |
Model retraining churns rule_version_hash on EVERY run |
You're embedding the model weights / timestamp into the rule spec | Move model artifact references OUT of the spec; pin only model_id + model_version strings |
What ships when this rule fires¶
Same regulator-ready evidence emerges as for aggregation_window:
- Alert payload carries
rule_version+model_id+model_version - Audit pack
program_intent.mdenumerates the rule'sbusiness_intent+out_of_scope defect_log.jsonlcaptures any scorer exception with categorypython_ref_failuremonitoring_digest.jsonincludes it in the per-rule alert count + diff vs prior run
Plus an MRM advantage: because the model artifacts live in src/aml_framework/models/ (or your institution-prefixed package), 2LoD reviews the same code that fires the alert. No black-box vendor model. No deserialization surprise.
Next steps¶
- Champion / challenger: ship a second
python_refrule withmodel_id: <same>_v4_candidaterunning in parallel (status: experimental). Compare alerts in the Drift Monitor (page 50). - FP analysis: the FP Analysis page (page 45) auto-clusters this rule's alerts by feature; you'll see threshold tuning opportunities without writing SQL.
- Production deployment: set
environments: ["prod"]only after model validation pack is signed off. See How to promote a rule across environments.