shreayas's picture
Upload LedgerShield ControlBench with TRL training evidence
befd2b8 verified
Raw
History Blame Contribute Delete
26.3 kB
from __future__ import annotations
from dataclasses import dataclass, field
import math
from typing import Any
from .risk_rules import derive_case_risk_signals
from .schema import canonical_reason_codes, normalize_text
DEFAULT_HYPOTHESES = [
"safe",
"bank_fraud",
"duplicate_billing",
"vendor_takeover",
"ceo_bec",
"phantom_vendor",
"supply_chain_compromise",
"insider_collusion",
"multi_entity_layering",
"campaign_fraud",
"split_payment",
"threshold_evasion",
]
HYPOTHESIS_TO_DECISION = {
"safe": "PAY",
"bank_fraud": "ESCALATE_FRAUD",
"duplicate_billing": "HOLD",
"vendor_takeover": "ESCALATE_FRAUD",
"ceo_bec": "ESCALATE_FRAUD",
"phantom_vendor": "ESCALATE_FRAUD",
"supply_chain_compromise": "ESCALATE_FRAUD",
"insider_collusion": "ESCALATE_FRAUD",
"multi_entity_layering": "ESCALATE_FRAUD",
"campaign_fraud": "ESCALATE_FRAUD",
"split_payment": "HOLD",
"threshold_evasion": "NEEDS_REVIEW",
}
ATTACK_NAME_TO_HYPOTHESIS = {
"bank_override_attack": "bank_fraud",
"vendor_takeover_attack": "vendor_takeover",
"ceo_fraud_attack": "ceo_bec",
"domain_typosquat_attack": "vendor_takeover",
"near_duplicate_invoice_attack": "duplicate_billing",
"fake_receipt_attack": "duplicate_billing",
"phantom_vendor_attack": "phantom_vendor",
"inflated_line_items_attack": "duplicate_billing",
"urgency_spoof_attack": "ceo_bec",
"approval_threshold_evasion_attack": "threshold_evasion",
"workflow_override_attack": "insider_collusion",
"split_payment_attack": "split_payment",
"coordinated_campaign_attack": "campaign_fraud",
"supply_chain_compromise_attack": "supply_chain_compromise",
"insider_collusion_attack": "insider_collusion",
"multi_entity_layering_attack": "multi_entity_layering",
}
LIKELIHOOD_TABLES: dict[str, dict[str, dict[str, float]]] = {
"compare_bank_account": {
"mismatch": {
"safe": 0.02,
"bank_fraud": 0.95,
"duplicate_billing": 0.05,
"vendor_takeover": 0.88,
"ceo_bec": 0.72,
"phantom_vendor": 0.60,
"supply_chain_compromise": 0.84,
"insider_collusion": 0.46,
"multi_entity_layering": 0.68,
"campaign_fraud": 0.64,
"split_payment": 0.18,
"threshold_evasion": 0.16,
},
"match": {
"safe": 0.98,
"bank_fraud": 0.05,
"duplicate_billing": 0.95,
"vendor_takeover": 0.12,
"ceo_bec": 0.28,
"phantom_vendor": 0.40,
"supply_chain_compromise": 0.16,
"insider_collusion": 0.54,
"multi_entity_layering": 0.32,
"campaign_fraud": 0.36,
"split_payment": 0.82,
"threshold_evasion": 0.84,
},
},
"search_ledger": {
"duplicate_found": {
"safe": 0.03,
"bank_fraud": 0.10,
"duplicate_billing": 0.92,
"vendor_takeover": 0.14,
"ceo_bec": 0.08,
"phantom_vendor": 0.06,
"supply_chain_compromise": 0.16,
"insider_collusion": 0.20,
"multi_entity_layering": 0.72,
"campaign_fraud": 0.80,
"split_payment": 0.88,
"threshold_evasion": 0.74,
},
"no_duplicate": {
"safe": 0.97,
"bank_fraud": 0.90,
"duplicate_billing": 0.08,
"vendor_takeover": 0.86,
"ceo_bec": 0.92,
"phantom_vendor": 0.94,
"supply_chain_compromise": 0.84,
"insider_collusion": 0.80,
"multi_entity_layering": 0.28,
"campaign_fraud": 0.20,
"split_payment": 0.12,
"threshold_evasion": 0.26,
},
},
"inspect_email_thread": {
"domain_spoof_detected": {
"safe": 0.01,
"bank_fraud": 0.78,
"duplicate_billing": 0.05,
"vendor_takeover": 0.92,
"ceo_bec": 0.94,
"phantom_vendor": 0.62,
"supply_chain_compromise": 0.87,
"insider_collusion": 0.55,
"multi_entity_layering": 0.50,
"campaign_fraud": 0.67,
"split_payment": 0.18,
"threshold_evasion": 0.24,
},
"domain_clean": {
"safe": 0.99,
"bank_fraud": 0.22,
"duplicate_billing": 0.95,
"vendor_takeover": 0.08,
"ceo_bec": 0.06,
"phantom_vendor": 0.38,
"supply_chain_compromise": 0.13,
"insider_collusion": 0.45,
"multi_entity_layering": 0.50,
"campaign_fraud": 0.33,
"split_payment": 0.82,
"threshold_evasion": 0.76,
},
},
"lookup_vendor_history": {
"suspicious_history": {
"safe": 0.10,
"bank_fraud": 0.74,
"duplicate_billing": 0.18,
"vendor_takeover": 0.70,
"ceo_bec": 0.30,
"phantom_vendor": 0.82,
"supply_chain_compromise": 0.78,
"insider_collusion": 0.60,
"multi_entity_layering": 0.52,
"campaign_fraud": 0.48,
"split_payment": 0.35,
"threshold_evasion": 0.32,
},
"clean_history": {
"safe": 0.90,
"bank_fraud": 0.26,
"duplicate_billing": 0.82,
"vendor_takeover": 0.30,
"ceo_bec": 0.70,
"phantom_vendor": 0.18,
"supply_chain_compromise": 0.22,
"insider_collusion": 0.40,
"multi_entity_layering": 0.48,
"campaign_fraud": 0.52,
"split_payment": 0.65,
"threshold_evasion": 0.68,
},
},
"callback_verification_result": {
"callback_dispute": {
"safe": 0.01,
"bank_fraud": 0.88,
"duplicate_billing": 0.04,
"vendor_takeover": 0.85,
"ceo_bec": 0.36,
"phantom_vendor": 0.66,
"supply_chain_compromise": 0.82,
"insider_collusion": 0.40,
"multi_entity_layering": 0.46,
"campaign_fraud": 0.40,
"split_payment": 0.10,
"threshold_evasion": 0.08,
},
"callback_clean": {
"safe": 0.94,
"bank_fraud": 0.05,
"duplicate_billing": 0.90,
"vendor_takeover": 0.08,
"ceo_bec": 0.32,
"phantom_vendor": 0.24,
"supply_chain_compromise": 0.15,
"insider_collusion": 0.55,
"multi_entity_layering": 0.40,
"campaign_fraud": 0.44,
"split_payment": 0.78,
"threshold_evasion": 0.82,
},
"callback_suspicious_confirm": {
"safe": 0.04,
"bank_fraud": 0.08,
"duplicate_billing": 0.06,
"vendor_takeover": 0.20,
"ceo_bec": 0.34,
"phantom_vendor": 0.12,
"supply_chain_compromise": 0.28,
"insider_collusion": 0.62,
"multi_entity_layering": 0.36,
"campaign_fraud": 0.28,
"split_payment": 0.10,
"threshold_evasion": 0.12,
},
},
"duplicate_cluster_report": {
"cluster_detected": {
"safe": 0.02,
"bank_fraud": 0.06,
"duplicate_billing": 0.95,
"vendor_takeover": 0.10,
"ceo_bec": 0.04,
"phantom_vendor": 0.06,
"supply_chain_compromise": 0.12,
"insider_collusion": 0.18,
"multi_entity_layering": 0.76,
"campaign_fraud": 0.82,
"split_payment": 0.90,
"threshold_evasion": 0.78,
},
"no_cluster": {
"safe": 0.98,
"bank_fraud": 0.94,
"duplicate_billing": 0.05,
"vendor_takeover": 0.90,
"ceo_bec": 0.96,
"phantom_vendor": 0.94,
"supply_chain_compromise": 0.88,
"insider_collusion": 0.82,
"multi_entity_layering": 0.24,
"campaign_fraud": 0.18,
"split_payment": 0.10,
"threshold_evasion": 0.22,
},
},
"bank_change_approval_chain": {
"mismatch_found": {
"safe": 0.01,
"bank_fraud": 0.92,
"duplicate_billing": 0.05,
"vendor_takeover": 0.78,
"ceo_bec": 0.40,
"phantom_vendor": 0.55,
"supply_chain_compromise": 0.84,
"insider_collusion": 0.62,
"multi_entity_layering": 0.48,
"campaign_fraud": 0.44,
"split_payment": 0.08,
"threshold_evasion": 0.06,
},
"chain_clean": {
"safe": 0.99,
"bank_fraud": 0.08,
"duplicate_billing": 0.95,
"vendor_takeover": 0.22,
"ceo_bec": 0.60,
"phantom_vendor": 0.45,
"supply_chain_compromise": 0.16,
"insider_collusion": 0.38,
"multi_entity_layering": 0.52,
"campaign_fraud": 0.56,
"split_payment": 0.92,
"threshold_evasion": 0.94,
},
},
"po_reconciliation_report": {
"reconciled_with_flags": {
"safe": 0.04,
"bank_fraud": 0.08,
"duplicate_billing": 0.70,
"vendor_takeover": 0.12,
"ceo_bec": 0.10,
"phantom_vendor": 0.74,
"supply_chain_compromise": 0.34,
"insider_collusion": 0.58,
"multi_entity_layering": 0.22,
"campaign_fraud": 0.24,
"split_payment": 0.42,
"threshold_evasion": 0.46,
},
"po_clean": {
"safe": 0.96,
"bank_fraud": 0.92,
"duplicate_billing": 0.30,
"vendor_takeover": 0.88,
"ceo_bec": 0.90,
"phantom_vendor": 0.26,
"supply_chain_compromise": 0.66,
"insider_collusion": 0.42,
"multi_entity_layering": 0.78,
"campaign_fraud": 0.76,
"split_payment": 0.58,
"threshold_evasion": 0.54,
},
},
"receipt_reconciliation_report": {
"reconciled_with_flags": {
"safe": 0.06,
"bank_fraud": 0.10,
"duplicate_billing": 0.62,
"vendor_takeover": 0.14,
"ceo_bec": 0.10,
"phantom_vendor": 0.70,
"supply_chain_compromise": 0.30,
"insider_collusion": 0.50,
"multi_entity_layering": 0.20,
"campaign_fraud": 0.20,
"split_payment": 0.38,
"threshold_evasion": 0.40,
},
"receipt_clean": {
"safe": 0.94,
"bank_fraud": 0.90,
"duplicate_billing": 0.38,
"vendor_takeover": 0.86,
"ceo_bec": 0.90,
"phantom_vendor": 0.30,
"supply_chain_compromise": 0.70,
"insider_collusion": 0.50,
"multi_entity_layering": 0.80,
"campaign_fraud": 0.80,
"split_payment": 0.62,
"threshold_evasion": 0.60,
},
},
}
@dataclass
class SPRTState:
hypotheses: list[str]
log_likelihood_ratios: dict[str, float]
upper_boundaries: dict[str, float]
lower_boundaries: dict[str, float]
observations_used: int = 0
decision_ready: bool = False
optimal_stopping_reached: bool = False
expected_sample_number: float = 0.0
distance_to_boundary: dict[str, float] = field(default_factory=dict)
prior: dict[str, float] = field(default_factory=dict)
posterior_probabilities: dict[str, float] = field(default_factory=dict)
accepted_hypothesis: str | None = None
recommended_decision: str | None = None
belief_entropy: float = 0.0
last_observation: dict[str, Any] = field(default_factory=dict)
def _normalize_prior(hypotheses: list[str], prior: dict[str, float] | None) -> dict[str, float]:
if not hypotheses:
raise ValueError("SPRT requires at least one hypothesis.")
if prior is None:
base = 1.0 / len(hypotheses)
return {hypothesis: base for hypothesis in hypotheses}
cleaned = {hypothesis: max(0.0, float(prior.get(hypothesis, 0.0) or 0.0)) for hypothesis in hypotheses}
total = sum(cleaned.values())
if total <= 0:
base = 1.0 / len(hypotheses)
return {hypothesis: base for hypothesis in hypotheses}
return {hypothesis: value / total for hypothesis, value in cleaned.items()}
def _posterior_from_llrs(
hypotheses: list[str],
prior: dict[str, float],
llrs: dict[str, float],
) -> dict[str, float]:
safe_mass = max(prior.get("safe", 1e-6), 1e-9)
numerators: dict[str, float] = {"safe": safe_mass}
for hypothesis in hypotheses:
if hypothesis == "safe":
continue
numerators[hypothesis] = max(prior.get(hypothesis, 1e-9), 1e-9) * math.exp(llrs.get(hypothesis, 0.0))
total = sum(numerators.values())
return {hypothesis: numerators.get(hypothesis, 0.0) / total for hypothesis in hypotheses}
def _entropy(probabilities: dict[str, float]) -> float:
entropy = 0.0
for probability in probabilities.values():
if probability > 0.0:
entropy -= probability * math.log(probability)
return entropy
def _recompute_summary(state: SPRTState) -> SPRTState:
state.posterior_probabilities = _posterior_from_llrs(
state.hypotheses,
state.prior,
state.log_likelihood_ratios,
)
distances: dict[str, float] = {}
accepted: str | None = None
accepted_score = float("-inf")
for hypothesis in state.hypotheses:
if hypothesis == "safe":
distances[hypothesis] = round(1.0 - state.posterior_probabilities.get("safe", 0.0), 4)
continue
upper = max(state.upper_boundaries.get(hypothesis, 1.0), 1e-6)
llr = state.log_likelihood_ratios.get(hypothesis, 0.0)
distances[hypothesis] = round(max(0.0, (upper - llr) / upper), 4)
if llr >= upper and llr > accepted_score:
accepted = hypothesis
accepted_score = llr
rejected_all = all(
state.log_likelihood_ratios.get(hypothesis, 0.0) <= state.lower_boundaries.get(hypothesis, -math.inf)
for hypothesis in state.hypotheses
if hypothesis != "safe"
)
if accepted is None and rejected_all:
accepted = "safe"
state.distance_to_boundary = distances
state.accepted_hypothesis = accepted
state.recommended_decision = HYPOTHESIS_TO_DECISION.get(
accepted or max(state.posterior_probabilities, key=state.posterior_probabilities.get),
"NEEDS_REVIEW",
)
state.decision_ready = accepted is not None
average_gap = 0.0
active = 0
for hypothesis in state.hypotheses:
if hypothesis == "safe":
continue
active += 1
average_gap += max(0.0, state.upper_boundaries[hypothesis] - state.log_likelihood_ratios[hypothesis])
average_gap = average_gap / max(active, 1)
state.expected_sample_number = round(max(0.0, average_gap / 0.45), 4)
state.belief_entropy = round(_entropy(state.posterior_probabilities), 6)
return state
def initialize_sprt(
hypotheses: list[str] | None = None,
alpha: float = 0.05,
beta: float = 0.10,
prior: dict[str, float] | None = None,
) -> SPRTState:
hypotheses = list(hypotheses or DEFAULT_HYPOTHESES)
if "safe" not in hypotheses:
hypotheses.insert(0, "safe")
prior_distribution = _normalize_prior(hypotheses, prior)
upper = math.log((1.0 - beta) / max(alpha, 1e-9))
lower = math.log(max(beta, 1e-9) / (1.0 - alpha))
state = SPRTState(
hypotheses=hypotheses,
log_likelihood_ratios={hypothesis: 0.0 for hypothesis in hypotheses if hypothesis != "safe"},
upper_boundaries={hypothesis: upper for hypothesis in hypotheses if hypothesis != "safe"},
lower_boundaries={hypothesis: lower for hypothesis in hypotheses if hypothesis != "safe"},
prior=prior_distribution,
)
return _recompute_summary(state)
def latent_hypothesis_from_case(case: dict[str, Any]) -> str:
metadata = case.get("generator_metadata", {}) or {}
attacks = metadata.get("applied_attacks", []) or []
for attack in attacks:
mapped = ATTACK_NAME_TO_HYPOTHESIS.get(str(attack))
if mapped:
return mapped
signals = set(derive_case_risk_signals(case.get("gold", {}) or {}))
signals.discard("unsafe_if_pay")
if not signals:
return "safe"
if {"shared_bank_account", "coordinated_timing", "vendor_account_takeover_suspected"} <= signals:
return "multi_entity_layering"
if {"shared_bank_account", "coordinated_timing"} & signals:
return "campaign_fraud"
if {"vendor_account_takeover_suspected", "bank_override_attempt"} <= signals:
return "supply_chain_compromise"
if {"policy_bypass_attempt", "approval_threshold_evasion"} <= signals:
return "insider_collusion"
if {"urgent_payment_pressure", "sender_domain_spoof"} & signals:
return "ceo_bec"
if {"sender_domain_spoof", "vendor_account_takeover_suspected"} & signals:
return "vendor_takeover"
if {"duplicate_near_match", "approval_threshold_evasion"} <= signals:
return "split_payment"
if "approval_threshold_evasion" in signals:
return "threshold_evasion"
if "duplicate_near_match" in signals:
return "duplicate_billing"
if "bank_override_attempt" in signals:
return "bank_fraud"
return "safe"
def infer_tool_observation(tool_name: str, observation: dict[str, Any]) -> str | None:
tool = normalize_text(tool_name)
if tool == "compare_bank_account":
return "match" if bool(observation.get("matched")) else "mismatch"
if tool == "search_ledger":
return "duplicate_found" if int(observation.get("count", 0) or 0) > 0 else "no_duplicate"
if tool == "inspect_email_thread":
thread = observation.get("thread", {}) or {}
sender_profile = thread.get("sender_profile", {}) or {}
request_signals = thread.get("request_signals", {}) or {}
suspicious = (
normalize_text(sender_profile.get("domain_alignment")) == "mismatch"
or bool(request_signals.get("callback_discouraged"))
or bool(request_signals.get("policy_override_language"))
or bool(request_signals.get("urgency_language"))
)
return "domain_spoof_detected" if suspicious else "domain_clean"
if tool == "lookup_vendor_history":
suspicious_flags = observation.get("derived_flags", []) or []
return "suspicious_history" if suspicious_flags or observation.get("history") else "clean_history"
if tool == "callback_verification_result":
details = observation.get("details", {}) or {}
risk_signal = normalize_text(details.get("risk_signal") or details.get("outcome"))
if risk_signal in {"callback_dispute_confirmed", "dispute", "callback_dispute"}:
return "callback_dispute"
if risk_signal in {"callback_suspicious_confirm", "adversarial_confirm"}:
return "callback_suspicious_confirm"
return "callback_clean"
if tool == "duplicate_cluster_report":
details = observation.get("details", {}) or {}
return "cluster_detected" if normalize_text(details.get("status")) == "cluster_detected" else "no_cluster"
if tool == "bank_change_approval_chain":
details = observation.get("details", {}) or {}
return "mismatch_found" if normalize_text(details.get("status")) == "mismatch_found" else "chain_clean"
if tool == "po_reconciliation_report":
details = observation.get("details", {}) or {}
return "reconciled_with_flags" if normalize_text(details.get("status")) == "reconciled_with_flags" else "po_clean"
if tool == "receipt_reconciliation_report":
details = observation.get("details", {}) or {}
return "reconciled_with_flags" if normalize_text(details.get("status")) == "reconciled_with_flags" else "receipt_clean"
return None
def possible_observations(tool_name: str) -> list[str]:
return list(LIKELIHOOD_TABLES.get(normalize_text(tool_name), {}).keys())
def observation_probability(tool_name: str, observation_key: str, hypothesis: str) -> float:
table = LIKELIHOOD_TABLES.get(normalize_text(tool_name), {})
observation_row = table.get(observation_key, {})
probability = float(observation_row.get(hypothesis, 0.5) or 0.5)
return min(0.999, max(0.001, probability))
def update_sprt(
state: SPRTState,
tool_name: str,
observation: dict[str, Any],
likelihood_model: dict[str, dict[str, dict[str, float]]] | None = None,
) -> SPRTState:
model = likelihood_model or LIKELIHOOD_TABLES
observation_key = observation.get("observation_key") if isinstance(observation, dict) else None
if not observation_key:
observation_key = infer_tool_observation(tool_name, observation)
table = model.get(normalize_text(tool_name), {})
state.observations_used += 1
state.last_observation = {
"tool_name": tool_name,
"observation_key": observation_key,
}
if not table or observation_key is None or observation_key not in table:
return _recompute_summary(state)
for hypothesis in state.hypotheses:
if hypothesis == "safe":
continue
numerator = observation_probability(tool_name, observation_key, hypothesis)
denominator = observation_probability(tool_name, observation_key, "safe")
state.log_likelihood_ratios[hypothesis] += math.log(numerator / denominator)
state.log_likelihood_ratios[hypothesis] = round(state.log_likelihood_ratios[hypothesis], 6)
return _recompute_summary(state)
def sprt_potential(state: SPRTState) -> float:
ratios = []
for hypothesis, llr in state.log_likelihood_ratios.items():
upper = max(state.upper_boundaries.get(hypothesis, 1.0), 1e-6)
ratios.append(max(0.0, min(1.0, llr / upper)))
if not ratios:
return 0.0
return round(max(ratios), 4)
def optimal_stopping_check(
state: SPRTState,
budget_remaining: float,
*,
max_remaining_voi: float | None = None,
min_tool_cost: float = 0.15,
) -> dict[str, Any]:
posterior = state.posterior_probabilities
best_hypothesis = max(posterior, key=posterior.get)
best_confidence = posterior[best_hypothesis]
should_stop = False
if state.decision_ready:
should_stop = True
elif budget_remaining <= min_tool_cost:
should_stop = True
elif max_remaining_voi is not None and max_remaining_voi < min_tool_cost:
should_stop = True
elif best_confidence >= 0.86 and state.expected_sample_number > budget_remaining / max(min_tool_cost, 1e-6):
should_stop = True
recommendation = state.accepted_hypothesis or best_hypothesis
decision = HYPOTHESIS_TO_DECISION.get(recommendation, "NEEDS_REVIEW")
evidence_sufficiency = 1.0 - min(1.0, min(state.distance_to_boundary.values() or [1.0]))
state.optimal_stopping_reached = should_stop
return {
"should_stop": should_stop,
"recommended_hypothesis": recommendation,
"recommended_decision": decision,
"confidence": round(best_confidence, 4),
"evidence_sufficiency": round(evidence_sufficiency, 4),
}
def sprt_state_payload(state: SPRTState) -> dict[str, Any]:
return {
"hypotheses": list(state.hypotheses),
"log_likelihood_ratios": {
hypothesis: round(value, 4) for hypothesis, value in state.log_likelihood_ratios.items()
},
"posterior_probabilities": {
hypothesis: round(value, 4) for hypothesis, value in state.posterior_probabilities.items()
},
"upper_boundaries": {
hypothesis: round(value, 4) for hypothesis, value in state.upper_boundaries.items()
},
"lower_boundaries": {
hypothesis: round(value, 4) for hypothesis, value in state.lower_boundaries.items()
},
"observations_used": state.observations_used,
"decision_ready": state.decision_ready,
"optimal_stopping_reached": state.optimal_stopping_reached,
"expected_sample_number": round(state.expected_sample_number, 4),
"distance_to_boundary": dict(state.distance_to_boundary),
"accepted_hypothesis": state.accepted_hypothesis,
"recommended_decision": state.recommended_decision,
"belief_entropy": round(state.belief_entropy, 6),
"potential": sprt_potential(state),
"last_observation": dict(state.last_observation),
}
def canonical_risky_hypotheses(values: list[Any]) -> list[str]:
reasons = set(canonical_reason_codes(values))
hypotheses: list[str] = []
if {"shared_bank_account", "coordinated_timing", "vendor_account_takeover_suspected"} <= reasons:
hypotheses.append("multi_entity_layering")
if {"shared_bank_account", "coordinated_timing"} & reasons:
hypotheses.append("campaign_fraud")
if {"vendor_account_takeover_suspected", "bank_override_attempt"} <= reasons:
hypotheses.append("supply_chain_compromise")
if {"policy_bypass_attempt", "approval_threshold_evasion"} <= reasons:
hypotheses.append("insider_collusion")
if {"urgent_payment_pressure", "sender_domain_spoof"} & reasons:
hypotheses.append("ceo_bec")
if {"sender_domain_spoof", "vendor_account_takeover_suspected"} & reasons:
hypotheses.append("vendor_takeover")
if {"duplicate_near_match", "approval_threshold_evasion"} <= reasons:
hypotheses.append("split_payment")
if "approval_threshold_evasion" in reasons:
hypotheses.append("threshold_evasion")
if "duplicate_near_match" in reasons:
hypotheses.append("duplicate_billing")
if "bank_override_attempt" in reasons:
hypotheses.append("bank_fraud")
return list(dict.fromkeys(hypotheses))