File size: 9,177 Bytes
a663682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8961dba
a663682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
"""
World Model — Hidden dynamics engine for the Cashflow Multi-Agent Environment.

Manages probabilistic events that are NOT directly visible to agents:
- Future inflow uncertainty (customer payment delays)
- Cash shocks (equipment failure, tax audit, fraud)
- Vendor behavior shifts (trust decay/growth)
- Market conditions (interest rate changes)

The world model is updated AFTER every environment step (Step 8 in workflow).
"""

import random
from typing import List, Dict, Any, Optional
from uuid import uuid4


class WorldEvent:
    """A single hidden event that may trigger on a specific day."""
    def __init__(self, day: int, event_type: str, severity: float,
                 description: str, target_id: Optional[str] = None,
                 amount: float = 0.0, probability: float = 1.0):
        self.day = day
        self.event_type = event_type  # cash_shock, payment_delay, vendor_shift, revenue_miss, fraud
        self.severity = severity      # 0.0 to 1.0
        self.description = description
        self.target_id = target_id    # affected invoice/receivable/vendor ID
        self.amount = amount
        self.probability = probability
        self.triggered = False


class WorldModel:
    """
    Hidden state tracker that evolves probabilistically each day.
    
    The environment calls:
      - initialize() on reset
      - update(day) after each step to check for triggered events
    
    Agents get PARTIAL views — they don't see the full event list.
    """

    def __init__(self):
        self.events: List[WorldEvent] = []
        self.triggered_log: List[Dict[str, Any]] = []
        self.market_stress: float = 0.0    # 0.0 = calm, 1.0 = crisis
        self.vendor_mood: Dict[str, float] = {}  # vendor_id -> mood modifier
        self.day_effects: Dict[str, Any] = {}    # per-day cache of effects

    def initialize(self, scenario: Dict[str, Any], max_days: int = 10):
        """Generate hidden events from scenario data."""
        self.events = []
        self.triggered_log = []
        self.market_stress = random.uniform(0.0, 0.3)
        self.vendor_mood = {}
        self.day_effects = {}

        vendors = scenario.get("vendors", [])
        for v in vendors:
            self.vendor_mood[v["id"]] = 0.0

        # --- Generate Cash Shocks ---
        num_shocks = random.randint(4, 7)
        shock_types = [
            ("Equipment Failure", -200000, -400000),
            ("Tax Audit Penalty", -150000, -300000),
            ("Emergency Repair", -80000, -200000),
            ("Regulatory Fine", -100000, -250000),
            ("Supplier Price Hike", -50000, -150000),
        ]
        for _ in range(num_shocks):
            shock = random.choice(shock_types)
            self.events.append(WorldEvent(
                day=random.randint(1, max_days - 1),
                event_type="cash_shock",
                severity=random.uniform(0.6, 1.0),
                description=shock[0],
                amount=random.uniform(shock[1], shock[2]),
                probability=random.uniform(0.6, 0.95),
            ))

        # --- Generate Payment Delays ---
        receivables = scenario.get("initial_receivables", [])
        for rec in receivables:
            if random.random() < 0.7:  # 70% chance any receivable gets delayed
                self.events.append(WorldEvent(
                    day=random.randint(1, max_days),
                    event_type="payment_delay",
                    severity=random.uniform(0.5, 0.9),
                    description=f"Customer {rec['customer_id']} payment delayed",
                    target_id=rec["id"],
                    amount=random.randint(2, 5),  # delay in days
                    probability=random.uniform(0.7, 1.0),
                ))

        # --- Generate Revenue Miss ---
        if random.random() < 0.6:
            self.events.append(WorldEvent(
                day=random.randint(1, max_days),
                event_type="revenue_miss",
                severity=random.uniform(0.6, 0.9),
                description="Quarterly revenue target missed — board review triggered",
                probability=random.uniform(0.7, 0.9),
            ))

        # --- Generate Vendor Mood Shifts ---
        for v in vendors:
            if random.random() < 0.6:
                self.events.append(WorldEvent(
                    day=random.randint(1, max_days),
                    event_type="vendor_shift",
                    severity=random.uniform(0.4, 0.8),
                    description=f"Vendor {v['name']} mood shift",
                    target_id=v["id"],
                    amount=random.uniform(-0.3, -0.1),  # negative trust modifier
                    probability=random.uniform(0.7, 0.9),
                ))

        # --- Fraud anomaly (rare) ---
        if random.random() < 0.4:
            self.events.append(WorldEvent(
                day=random.randint(1, max_days),
                event_type="fraud",
                severity=0.9,
                description="Suspicious transaction detected — investigation required",
                amount=-random.uniform(200000, 500000),
                probability=0.7,
            ))

    def update(self, day: int) -> Dict[str, Any]:
        """
        Check and trigger events for the given day.
        Returns a dict of effects to apply to the environment.
        """
        effects = {
            "cash_delta": 0.0,
            "payment_delays": [],       # list of (receivable_id, extra_days)
            "vendor_trust_deltas": {},   # vendor_id -> trust_delta
            "shock_occurred": False,
            "shock_description": None,
            "fraud_alert": False,
            "revenue_miss": False,
            "events_triggered": [],
        }

        for event in self.events:
            if event.day == day and not event.triggered:
                # Roll the dice
                if random.random() < event.probability:
                    event.triggered = True

                    if event.event_type == "cash_shock":
                        effects["cash_delta"] += event.amount
                        effects["shock_occurred"] = True
                        effects["shock_description"] = event.description

                    elif event.event_type == "payment_delay":
                        effects["payment_delays"].append(
                            (event.target_id, int(event.amount))
                        )

                    elif event.event_type == "vendor_shift":
                        vid = event.target_id
                        effects["vendor_trust_deltas"][vid] = event.amount
                        self.vendor_mood[vid] = self.vendor_mood.get(vid, 0) + event.amount

                    elif event.event_type == "revenue_miss":
                        effects["revenue_miss"] = True
                        self.market_stress = min(1.0, self.market_stress + 0.2)

                    elif event.event_type == "fraud":
                        effects["fraud_alert"] = True
                        effects["cash_delta"] += event.amount

                    effects["events_triggered"].append({
                        "type": event.event_type,
                        "description": event.description,
                        "severity": event.severity,
                    })

                    self.triggered_log.append({
                        "day": day,
                        "type": event.event_type,
                        "description": event.description,
                        "amount": event.amount,
                    })

        # Market stress naturally decays
        self.market_stress = max(0.0, self.market_stress - 0.02)

        self.day_effects[day] = effects
        return effects

    def get_risk_hints(self, day: int) -> Dict[str, Any]:
        """
        Partial information for the Risk Agent.
        Reveals SOME upcoming threats but not exact amounts/days.
        """
        hints = {
            "market_stress": round(self.market_stress, 2),
            "upcoming_risk_level": "low",
            "vendor_sentiment": {},
        }

        # Give a vague warning about upcoming shocks (within 2 days)
        upcoming_threats = 0
        for event in self.events:
            if not event.triggered and abs(event.day - day) <= 2:
                if event.event_type in ("cash_shock", "fraud", "payment_delay", "revenue_miss"):
                    upcoming_threats += 1

        if upcoming_threats >= 2:
            hints["upcoming_risk_level"] = "critical"
        elif upcoming_threats == 1:
            hints["upcoming_risk_level"] = "elevated"

        # Vendor sentiment (partial view)
        for vid, mood in self.vendor_mood.items():
            if mood < -0.1:
                hints["vendor_sentiment"][vid] = "negative"
            elif mood > 0.05:
                hints["vendor_sentiment"][vid] = "positive"
            else:
                hints["vendor_sentiment"][vid] = "neutral"

        return hints

    def get_triggered_events(self) -> List[Dict]:
        """Full log of all triggered events (for grading/logging)."""
        return self.triggered_log