Source code for fraudtwin.simulation.behavior

"""Customer behavior profiles and their generated payment dataset."""

import time
from dataclasses import dataclass, field, replace
from random import Random
from typing import TYPE_CHECKING, Literal

from pydantic import BaseModel

from fraudtwin.calibration import ResolvedCalibration
from fraudtwin.camouflage import resolve_camouflage, transform_generated_data
from fraudtwin.config import SimulationRunConfig
from fraudtwin.counterfactual import CounterfactualDataset, generate_counterfactuals
from fraudtwin.difficulty import resolve_difficulty
from fraudtwin.domain import (
    BehaviorProfile,
    Customer,
    CustomerDispute,
    DelayedFraudLabel,
    FinalObservedLabel,
    FraudAlert,
    FraudCase,
    FraudCaseConfirmation,
    FraudRecord,
    GraphCampaign,
    GraphCampaignMembership,
    GraphEvidence,
    GraphHyperedge,
    GraphHyperedgeMembership,
    GraphPattern,
    LabelObservation,
)
from fraudtwin.domain.payments import LedgerEntry, Payment, PaymentEvent
from fraudtwin.label_observation import apply_label_observation
from fraudtwin.seed import create_stream_rng
from fraudtwin.simulation.cases import FraudWorkflowGenerator
from fraudtwin.simulation.fraud import (
    FraudDataset,
    FraudScenarioGenerator,
    count_true_fraud_records,
    scenario_events,
)
from fraudtwin.simulation.generator import EntityDataset
from fraudtwin.simulation.graph_fraud import GraphFraudDataset, GraphFraudGenerator
from fraudtwin.simulation.payments import (
    PaymentGenerator,
    count_card_lifecycle_events,
    count_pix_lifecycle_events,
)
from fraudtwin.simulation.quality import QualityFaultInjector
from fraudtwin.timing import StageMetrics, finish_stage, measure_stage

if TYPE_CHECKING:
    from fraudtwin.campaign_dynamics import DynamicCampaignDataset

_PROFILE_ID_WIDTH = 6
_COUNTRIES = ("BR", "US", "GB", "DE")
SpendingLevel = Literal["LOW", "MEDIUM", "HIGH"]


[docs] @dataclass(frozen=True) class BehaviorDataset: """Stable, ordered behavior profiles and their generated payment stream.""" profiles: tuple[BehaviorProfile, ...] payments: tuple[Payment, ...] payment_events: tuple[PaymentEvent, ...] ledger_entries: tuple[LedgerEntry, ...] = () fraud_records: tuple[FraudRecord, ...] = () alerts: tuple[FraudAlert, ...] = () fraud_cases: tuple[FraudCase, ...] = () case_confirmations: tuple[FraudCaseConfirmation, ...] = () customer_disputes: tuple[CustomerDispute, ...] = () fraud_labels: tuple[DelayedFraudLabel, ...] = () label_observations: tuple[LabelObservation, ...] = () final_observed_labels: tuple[FinalObservedLabel, ...] = () quality_fault_counts: dict[str, int] = field(default_factory=dict) quality_fault_rates: dict[str, float] = field(default_factory=dict) quality_diagnostics: dict[str, object] = field(default_factory=dict) graph_memberships: tuple[GraphCampaignMembership, ...] = () graph_campaigns: tuple[GraphCampaign, ...] = () graph_patterns: tuple[GraphPattern, ...] = () graph_evidence: tuple[GraphEvidence, ...] = () graph_hyperedges: tuple[GraphHyperedge, ...] = () graph_hyperedge_memberships: tuple[GraphHyperedgeMembership, ...] = () camouflage_metadata: dict[str, object] = field(default_factory=dict) # M8 keeps an immutable in-memory oracle before intentional corruption. oracle_tables: dict[str, tuple[BaseModel, ...]] = field(default_factory=dict, repr=False) quality_raw_faults: tuple[dict[str, object], ...] = field(default_factory=tuple, repr=False) schema_evolution_rows: dict[str, tuple[dict[str, object], ...]] = field( default_factory=dict, repr=False ) counterfactual: CounterfactualDataset | None = field(default=None, repr=False) campaign_dynamics: "DynamicCampaignDataset | None" = field(default=None, repr=False)
[docs] def tables(self) -> dict[str, tuple[BaseModel, ...]]: """Return all behavior tables in their stable export order.""" return { "behavior_profiles": self.profiles, "payments": self.payments, "payment_events": self.payment_events, "ledger_entries": self.ledger_entries, "fraud_records": self.fraud_records, "fraud_alerts": self.alerts, "fraud_cases": self.fraud_cases, "case_confirmations": self.case_confirmations, "customer_disputes": self.customer_disputes, "fraud_labels": self.fraud_labels, }
@property def counts(self) -> dict[str, int]: return {name: len(records) for name, records in self.tables().items()} @property def fraud_events(self) -> tuple[PaymentEvent, ...]: """Return scenario-linked payment events, excluding hard negatives.""" source_events = self.oracle_tables.get("payment_events", self.payment_events) typed_events = tuple(event for event in source_events if isinstance(event, PaymentEvent)) return scenario_events(typed_events) @property def fraud_record_counts(self) -> dict[str, int]: """Count true scenario records for manifest reporting.""" return count_true_fraud_records(self.fraud_records) @property def fraud_counts(self) -> dict[str, int]: """Return manifest fraud counters, or empty counters when disabled.""" if not self.fraud_records: return {} record_counts = self.fraud_record_counts fraud_count = sum(record_counts.values()) return { **record_counts, "fraud_events": len(self.fraud_events), "fraud_records": fraud_count, "hard_negatives": len(self.fraud_records) - fraud_count, "alerts": len(self.alerts), "cases": len(self.fraud_cases), "confirmations": len(self.case_confirmations), "disputes": len(self.customer_disputes), "delayed_labels": len(self.fraud_labels), } @property def fraud_rates(self) -> dict[str, float]: """Return realized true-record rates by scenario.""" if not self.payments: return {} return { scenario: count / len(self.payments) for scenario, count in self.fraud_record_counts.items() } @property def card_lifecycle_event_counts(self) -> dict[str, int]: """Return counts for the explicit card event vocabulary.""" return count_card_lifecycle_events(self.payment_events) @property def pix_lifecycle_event_counts(self) -> dict[str, int]: """Return counts for the explicit PIX event vocabulary.""" return count_pix_lifecycle_events(self.payment_events) @property def event_counts(self) -> dict[str, int]: """Return payment, lifecycle, ledger, and fraud counts for the manifest.""" card_counts = self.card_lifecycle_event_counts pix_counts = self.pix_lifecycle_event_counts return { "payments": len(self.payments), "payment_events": len(self.payment_events), "ledger_entries": len(self.ledger_entries), "card_lifecycle_events": sum(card_counts.values()), "pix_lifecycle_events": sum(pix_counts.values()), "fraud_events": len(self.fraud_events), "fraud_records": len(self.fraud_records), "fraud_alerts": len(self.alerts), "fraud_cases": len(self.fraud_cases), "case_confirmations": len(self.case_confirmations), "customer_disputes": len(self.customer_disputes), "fraud_labels": len(self.fraud_labels), **card_counts, **pix_counts, }
def _mask_difficulty_event_truth(dataset: BehaviorDataset) -> BehaviorDataset: """Keep direct M6 scenario annotations in oracle tables only.""" masked_events = tuple( event.model_copy( update={ "scenario_id": None, "scenario_type": None, "scenario_trigger": None, "scenario_reason": None, "fraud_record_id": None, "affected_entity_ids": (), } ) for event in dataset.payment_events ) return replace(dataset, payment_events=masked_events)
[docs] class BehaviorGenerator: """Generate customer profiles and their payment stream from M1 entities.""" def __init__( self, config: SimulationRunConfig, entities: EntityDataset, simulation_run_id: str | None = None, calibration: ResolvedCalibration | None = None, stage_timings: StageMetrics | None = None, ) -> None: self.config = config self.entities = entities self.simulation_run_id = simulation_run_id self.calibration = calibration self.stage_timings = stage_timings self._scale_features = frozenset(config.scale.features) if config.scale.enabled else None self.merchant_categories = tuple( sorted({merchant.merchant_category_code for merchant in entities.merchants}) ) def _feature_enabled(self, name: str) -> bool: """Return whether a scale-only stage is enabled for this run.""" return self._scale_features is None or name in self._scale_features def _profile_devices(self, rng: Random) -> tuple[str, ...]: limit = self.config.behavior.preferred_device_limit if limit == 0 or not self.entities.devices: return () trusted = tuple(device.device_id for device in self.entities.devices if device.trusted) if not trusted: return () count = min(len(trusted), rng.randint(1, limit)) return tuple(sorted(rng.sample(trusted, count))) def _profile_hours(self, rng: Random) -> tuple[tuple[int, ...], tuple[float, ...]]: active = tuple(sorted(self.config.behavior.active_hours)) patterns = ( (7, 8, 12, 13, 18, 19), (9, 10, 12, 18, 20, 21), (6, 7, 11, 17, 18, 22), ) typical = tuple(hour for hour in rng.choice(patterns) if hour in active) if not typical: typical = active[: min(6, len(active))] weights = tuple( 0.0 if hour not in active else (2.5 if hour in typical else 0.35) * rng.uniform(0.8, 1.2) for hour in range(24) ) return typical, weights def _profile_weekdays(self, rng: Random) -> tuple[float, ...]: weekend_preference = rng.uniform(0.55, 1.8) return tuple( weight * (weekend_preference if weekday >= 5 else rng.uniform(0.85, 1.15)) for weekday, weight in enumerate(self.config.behavior.weekday_weights) ) def _profile(self, customer: Customer, number: int, rng: Random) -> BehaviorProfile: spending_level: SpendingLevel = rng.choices( ("LOW", "MEDIUM", "HIGH"), weights=self.config.behavior.spending_level_weights, k=1, )[0] income_ranges = { "LOW": (1_500.0, 3_500.0), "MEDIUM": (3_500.0, 8_000.0), "HIGH": (8_000.0, 30_000.0), } monthly_income = round(rng.uniform(*income_ranges[spending_level]), 2) budget_rate = rng.uniform(0.15, 0.35) monthly_budget = round(monthly_income * budget_rate, 2) typical_hours, hour_weights = self._profile_hours(rng) weekday_weights = self._profile_weekdays(rng) travel_frequency = round(rng.uniform(0.01, 0.30), 4) countries = [customer.country] if rng.random() < travel_frequency: travel_countries = tuple( country for country in _COUNTRIES if country != customer.country ) countries.append(rng.choice(travel_countries)) preference_count = min( self.config.behavior.merchant_preference_count, len(self.merchant_categories) ) category_preferences = tuple(sorted(rng.sample(self.merchant_categories, preference_count))) category_weights = tuple(round(rng.uniform(0.5, 2.0), 4) for _ in category_preferences) preferred_devices = self._profile_devices(rng) return BehaviorProfile( behavior_profile_id=f"BEH-{number:0{_PROFILE_ID_WIDTH}d}", customer_id=customer.customer_id, spending_level=spending_level, typical_payment_hours=typical_hours, hour_weights=hour_weights, weekday_weights=weekday_weights, typical_countries=tuple(countries), merchant_category_preferences=category_preferences, merchant_category_weights=category_weights, monthly_income=monthly_income, monthly_spending_budget=monthly_budget, card_vs_transfer_preference=round(rng.uniform(0.15, 0.95), 4), online_purchase_rate=round(rng.uniform(0.1, 0.9), 4), travel_frequency=travel_frequency, preferred_device_ids=preferred_devices, trusted_device_count=len(preferred_devices), )
[docs] def generate_profiles(self) -> tuple[BehaviorProfile, ...]: """Generate one deterministic profile per existing customer.""" rng = create_stream_rng(self.config.simulation.seed, "milestone-3:profiles") return tuple( self._profile(customer, number, rng) for number, customer in enumerate(self.entities.customers, start=1) )
[docs] def generate(self) -> BehaviorDataset: """Generate profiles, base payments, fraud scenarios, and events.""" with measure_stage(self.stage_timings, "profile_generation"): profiles = self.generate_profiles() with measure_stage(self.stage_timings, "payments_lifecycle"): payment_dataset = PaymentGenerator( self.config, self.entities.accounts, self.entities.cards, self.entities.merchants, self.entities.devices, self.entities.pix_keys, simulation_run_id=self.simulation_run_id, calibration=self.calibration, include_lifecycle=self._feature_enabled("lifecycle"), include_ledger=self._feature_enabled("ledger"), ).generate(profiles) downstream_started = time.perf_counter() counterfactual_dataset = ( generate_counterfactuals( self.config, self.entities, payment_dataset, counterfactual_id=( f"CF-{self.simulation_run_id}" if self.simulation_run_id else None ), run_id=self.simulation_run_id, ) if self.config.counterfactual.active else None ) if self._feature_enabled("fraud") and self.config.fraud.enabled: fraud_dataset = FraudScenarioGenerator( self.config, self.entities.accounts, self.entities.cards, self.entities.merchants, self.entities.devices, self.entities.pix_keys, payment_dataset, simulation_run_id=self.simulation_run_id, ).generate() else: fraud_dataset = FraudDataset( payments=payment_dataset.payments, payment_events=payment_dataset.payment_events, ledger_entries=payment_dataset.ledger_entries, fraud_records=(), ) if self._feature_enabled("graph") and self.config.graph.enabled: graph_dataset = GraphFraudGenerator( self.config, self.entities.accounts, self.entities.devices, self.entities.network_endpoints, fraud_dataset, simulation_run_id=self.simulation_run_id, merchants=self.entities.merchants, pix_keys=self.entities.pix_keys, ).generate() else: graph_dataset = GraphFraudDataset( payments=fraud_dataset.payments, payment_events=fraud_dataset.payment_events, ledger_entries=fraud_dataset.ledger_entries, # ``transform_generated_data`` receives baseline fraud records # separately; the graph dataset contributes only graph-stage # records. Keep this aligned with GraphFraudGenerator's # disabled-stage behavior. fraud_records=(), memberships=(), patterns=(), ) dynamic_dataset: DynamicCampaignDataset | None = None if self.config.campaign_dynamics.active: from fraudtwin.campaign_dynamics import evolve_campaigns dynamic_dataset = evolve_campaigns( self.config, self.entities, graph_dataset, self.simulation_run_id or "in-memory" ) graph_dataset = dynamic_dataset.graph ( camo_payments, camo_events, camo_ledger, camo_records, camo_memberships, camo_campaigns, camo_patterns, camo_evidence, camouflage_metadata, ) = transform_generated_data( self.config, self.entities, profiles, graph_dataset.payments, graph_dataset.payment_events, graph_dataset.ledger_entries, fraud_dataset.fraud_records + graph_dataset.fraud_records, graph_dataset.memberships, graph_dataset.campaigns, graph_dataset.patterns, graph_dataset.evidence, ) if dynamic_dataset is not None: from fraudtwin.campaign_dynamics import validate_campaign_dynamics dynamic_dataset = replace( dynamic_dataset, graph=GraphFraudDataset( camo_payments, camo_events, camo_ledger, camo_records, camo_memberships, camo_patterns, camo_campaigns, camo_evidence, graph_dataset.hyperedges, graph_dataset.hyperedge_memberships, ), ) validate_campaign_dynamics(self.config, self.entities, dynamic_dataset) workflow_source = FraudDataset( payments=camo_payments, payment_events=camo_events, ledger_entries=camo_ledger, fraud_records=camo_records, ) workflow_dataset = FraudWorkflowGenerator( self.config, self.entities, workflow_source, ).generate() label_observations: tuple[LabelObservation, ...] = () final_observed_labels: tuple[FinalObservedLabel, ...] = () if self.config.labels.enabled and self._feature_enabled("labels"): workflow_alerts = workflow_dataset.alerts label_observations, final_observed_labels = apply_label_observation( self.config, camo_records, camo_payments, customers=self.entities.customers, alerts=workflow_alerts, simulation_run_id=self.simulation_run_id, ) selected_ids = { item.fraud_record_id for item in label_observations if item.investigation_selected } reopen_at_by_record = { observation.fraud_record_id: observation.reopenings[-1].reopened_at for observation in label_observations if observation.reopenings } workflow_dataset = replace( workflow_dataset, alerts=tuple( item for item in workflow_dataset.alerts if item.fraud_record_id in selected_ids ), cases=tuple( item.model_copy( update={"case_reopened_at": reopen_at_by_record.get(item.fraud_record_id)} ) for item in workflow_dataset.cases if item.fraud_record_id in selected_ids ), confirmations=tuple( item for item in workflow_dataset.confirmations if item.fraud_record_id in selected_ids ), disputes=tuple( item for item in workflow_dataset.disputes if item.fraud_record_id in selected_ids ), labels=tuple( item for item in workflow_dataset.labels if item.fraud_record_id in selected_ids ), ) dataset = BehaviorDataset( profiles=profiles, payments=camo_payments, payment_events=camo_events, ledger_entries=camo_ledger, fraud_records=camo_records, alerts=workflow_dataset.alerts, fraud_cases=workflow_dataset.cases, case_confirmations=workflow_dataset.confirmations, customer_disputes=workflow_dataset.disputes, fraud_labels=workflow_dataset.labels, label_observations=label_observations, final_observed_labels=final_observed_labels, graph_memberships=camo_memberships, graph_campaigns=camo_campaigns, graph_patterns=camo_patterns, graph_evidence=camo_evidence, graph_hyperedges=graph_dataset.hyperedges, graph_hyperedge_memberships=graph_dataset.hyperedge_memberships, camouflage_metadata=camouflage_metadata, counterfactual=counterfactual_dataset, campaign_dynamics=dynamic_dataset, ) dataset = QualityFaultInjector(self.config).apply(dataset) if resolve_difficulty(self.config).enabled or resolve_camouflage(self.config).enabled: dataset = _mask_difficulty_event_truth(dataset) finish_stage(self.stage_timings, "fraud_graph_quality", downstream_started) return dataset
[docs] def generate_behavior(config: SimulationRunConfig, entities: EntityDataset) -> BehaviorDataset: """Convenience function for deterministic behavior generation.""" return BehaviorGenerator(config, entities).generate()