"""Phase 11: Reasoning engine. Builds analyst-facing structured reasoning from model prediction and explainability outputs. """ from __future__ import annotations from typing import Any def _risk_level(risk_score: float) -> str: if risk_score >= 70: return "high" if risk_score >= 40: return "medium" return "low" def _recommended_action(classification: str, risk_score: float) -> str: if classification == "fraud" or risk_score >= 70: return "BLOCK" if classification == "suspicious" or risk_score >= 40: return "MFA" return "APPROVE" def _confidence( fraud_probability: float, fraud_threshold: float, suspicious_threshold: float, ) -> float: distance = max( abs(fraud_probability - fraud_threshold), abs(fraud_probability - suspicious_threshold), ) normalized = min(distance / max(1e-6, 1 - suspicious_threshold), 1.0) return round(0.55 + 0.44 * normalized, 4) def _top_categories(category_scores: dict[str, float], top_k: int = 3) -> list[dict[str, Any]]: ranked = sorted(category_scores.items(), key=lambda item: item[1], reverse=True) output: list[dict[str, Any]] = [] for name, score in ranked[:top_k]: output.append( { "category": name, "score": round(float(score), 6), "percent": round(float(score) * 100, 2), } ) return output def _signal_list(signals: list[dict[str, Any]], top_k: int = 3) -> list[dict[str, Any]]: output: list[dict[str, Any]] = [] for signal in signals[:top_k]: output.append( { "feature": signal.get("feature"), "label": signal.get("label"), "value": signal.get("value"), "impact": signal.get("direction"), "contribution": signal.get("shap_value"), } ) return output def _relationship_insight(category_scores: dict[str, float]) -> str: merchant = float(category_scores.get("merchant_risk", 0.0)) geo = float(category_scores.get("geo_anomaly", 0.0)) relationship_signal = merchant + geo if relationship_signal >= 0.4: return "Strong merchant and/or geo-cluster relationship risk signals are present." if relationship_signal >= 0.2: return "Moderate relationship-driven risk from merchant or geo clustering is observed." return "No strong relationship-risk concentration was detected." def _ato_insight(fraud_type: str, category_scores: dict[str, float]) -> str: ato_score = float(category_scores.get("account_takeover", 0.0)) if fraud_type == "account_takeover" or ato_score >= 0.45: return "ATO-style behavior is likely, driven by velocity and behavior-shift signals." if ato_score >= 0.2: return "Some ATO indicators are present but not dominant." return "ATO indicators are weak for this transaction." def _synthetic_identity_insight(fraud_type: str, category_scores: dict[str, float]) -> str: syn_score = float(category_scores.get("synthetic_identity", 0.0)) if fraud_type == "synthetic_identity" or syn_score >= 0.45: return "Synthetic-identity risk indicators are dominant for this transaction." if syn_score >= 0.2: return "Some synthetic-identity indicators are present but secondary." return "Synthetic-identity indicators are weak for this transaction." def _counterfactual_insight( positive_signals: list[dict[str, Any]], classification: str, ) -> str: if not positive_signals: return "No dominant positive risk driver was found for a stable counterfactual recommendation." top_driver = positive_signals[0] label = str(top_driver.get("label", top_driver.get("feature", "top signal"))).lower() if classification == "legitimate": return f"If {label} increases materially, this transaction may cross into suspicious risk." return f"If {label} were materially reduced, the score would likely move toward a lower-risk class." def build_reasoning_output( *, transaction_id: str | None, classification: str, fraud_probability: float, risk_score: float, fraud_threshold: float, suspicious_threshold: float, explainability: dict[str, Any], decision_action: str | None = None, ) -> dict[str, Any]: risk_level = _risk_level(risk_score) action = decision_action or _recommended_action(classification, risk_score) confidence = _confidence( fraud_probability=fraud_probability, fraud_threshold=fraud_threshold, suspicious_threshold=suspicious_threshold, ) category_scores = explainability.get("category_scores", {}) positive_signals = explainability.get("top_positive_signals", []) negative_signals = explainability.get("top_negative_signals", []) fraud_type = str(explainability.get("fraud_type", "general_fraud")) headline = f"{classification.upper()} decision with {risk_level.upper()} risk" summary = str(explainability.get("explanation_summary", "No explanation summary available.")) what_changed = "Risk increased due to strong positive driver signals." if classification == "legitimate": what_changed = "Risk remained contained due to stronger counter-signals than risk drivers." return { "transaction_id": transaction_id, "headline": headline, "risk_level": risk_level, "risk_breakdown": _top_categories(category_scores), "summary": summary, "what_changed": what_changed, "key_signals": _signal_list(positive_signals), "counter_signals": _signal_list(negative_signals), "relationship_insight": _relationship_insight(category_scores), "ato_insight": _ato_insight(fraud_type, category_scores), "synthetic_identity_insight": _synthetic_identity_insight(fraud_type, category_scores), "counterfactual_insight": _counterfactual_insight(positive_signals, classification), "recommended_action": action, "confidence": confidence, "classification": classification, "fraud_probability": round(float(fraud_probability), 6), "risk_score": round(float(risk_score), 2), "fraud_type": fraud_type, } def phase_status() -> str: return "phase 11 reasoning engine implemented"