""" adapter.py ---------- Inference Adapter for RazorShield Risk Engine. Converts public API transaction payloads into the exact feature representation expected by the trained XGBoost transaction model without data leakage or retrained dependencies. Handles historical customer/device state tracking and unknown categorical values safely. """ from __future__ import annotations import logging import math from typing import Any import numpy as np import pandas as pd from src.api.schemas import TransactionApiInput LOGGER = logging.getLogger("inference-adapter") class CustomerHistoryTracker: """In-memory historical customer & device state tracker for API streaming inference.""" def __init__(self): self.customer_history: dict[str, dict[str, Any]] = {} self.device_history: dict[str, int] = {} def get_and_update_customer_stats(self, customer_id: str, amount: float) -> dict[str, float]: """Retrieves past stats for customer, then updates history chronologically.""" if customer_id not in self.customer_history: past_stats = { "customer_txn_count_past": 0, "customer_amount_mean_past": 0.0, "customer_amount_std_past": 0.0, "customer_amount_dev": 1.0, } self.customer_history[customer_id] = { "count": 1, "sum": float(amount), "sum_sq": float(amount ** 2), } return past_stats c_data = self.customer_history[customer_id] count = c_data["count"] sum_amt = c_data["sum"] sum_sq = c_data["sum_sq"] mean_past = sum_amt / count var_past = max(0.0, (sum_sq / count) - (mean_past ** 2)) std_past = math.sqrt(var_past) amt_dev = amount / (mean_past + 1e-5) past_stats = { "customer_txn_count_past": count, "customer_amount_mean_past": float(mean_past), "customer_amount_std_past": float(std_past), "customer_amount_dev": float(amt_dev), } # Update state with current transaction c_data["count"] += 1 c_data["sum"] += float(amount) c_data["sum_sq"] += float(amount ** 2) return past_stats def get_and_update_device_stats(self, device_id: str) -> int: """Retrieves past device transaction count, then updates state.""" past_count = self.device_history.get(device_id, 0) self.device_history[device_id] = past_count + 1 return past_count def reset(self): """Resets tracker state.""" self.customer_history.clear() self.device_history.clear() class InferenceAdapter: """Adapts public API input transactions into model feature DataFrames.""" def __init__(self, tracker: CustomerHistoryTracker | None = None): self.tracker = tracker if tracker is not None else CustomerHistoryTracker() def transform_transaction(self, tx: TransactionApiInput) -> pd.DataFrame: """ Transforms a single TransactionApiInput into a 1-row feature DataFrame compatible with the trained XGBoost transaction model. """ event_time = tx.event_time amount = tx.amount # Basic time features hour = event_time.hour day_of_week = event_time.weekday() is_weekend = 1 if day_of_week >= 5 else 0 amount_log1p = float(np.log1p(max(0.0, amount))) # Historical customer & device features cust_stats = self.tracker.get_and_update_customer_stats(tx.customer_id, amount) dev_count = self.tracker.get_and_update_device_stats(tx.device_id) # Missingness & identity indicators identity_available = 0 if tx.device_id in ["D_UNKNOWN", "", None] else 1 missing_p_email = 0 missing_r_email = 1 missing_addr1 = 1 missing_device_info = 0 if identity_available == 1 else 1 feature_dict = { "amount": float(amount), "amount_log1p": amount_log1p, "hour": int(hour), "day_of_week": int(day_of_week), "is_weekend": int(is_weekend), "customer_txn_count_past": int(cust_stats["customer_txn_count_past"]), "customer_amount_mean_past": float(cust_stats["customer_amount_mean_past"]), "customer_amount_std_past": float(cust_stats["customer_amount_std_past"]), "customer_amount_dev": float(cust_stats["customer_amount_dev"]), "device_txn_count_past": int(dev_count), "identity_available": int(identity_available), "missing_p_email": int(missing_p_email), "missing_r_email": int(missing_r_email), "missing_addr1": int(missing_addr1), "missing_device_info": int(missing_device_info), } df_feat = pd.DataFrame([feature_dict]) return df_feat