agentevac 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentevac/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """AgentEvac — agent-based wildfire evacuation simulator.
2
+
3
+ Couples a SUMO traffic simulation with LLM-driven agents (GPT-4o-mini) that make
4
+ real-time evacuation decisions under different information regimes.
5
+
6
+ Sub-packages:
7
+ agents — per-agent decision pipeline (belief, departure, routing, etc.)
8
+ analysis — calibration, experiment sweep, and metrics collection.
9
+ utils — shared utilities: record/replay and fire forecast.
10
+ simulation — main simulation loop and vehicle spawn configuration.
11
+ """
12
+
13
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ """Agent decision pipeline modules.
2
+
3
+ Contains the per-agent state management and the sequential pipeline stages
4
+ that run every ``DECISION_PERIOD_S`` seconds:
5
+
6
+ agent_state — runtime state store and profile parameters.
7
+ information_model — sample noisy/delayed environment and social signals.
8
+ belief_model — Bayesian hazard belief update.
9
+ departure_model — three-clause departure decision logic.
10
+ routing_utility — expected-utility scoring for destination/route menus.
11
+ scenarios — information-regime filtering (no_notice / alert / advice).
12
+ """
@@ -0,0 +1,236 @@
1
+ """Agent runtime state management for the AgentEvac wildfire evacuation simulator.
2
+
3
+ This module defines the per-agent in-memory state that persists across decision rounds.
4
+ All live agent states are stored in the global ``AGENT_STATES`` dict, which is keyed by
5
+ vehicle ID. The main simulation loop (``agentevac.simulation.main``) creates or retrieves state via
6
+ ``ensure_agent_state()`` before running the belief-update and departure-decision pipeline.
7
+
8
+ Psychological profile parameters stored in each agent's ``profile`` dict:
9
+ - ``theta_trust`` : Weight given to social (neighbor) signals vs. own observations [0, 1].
10
+ Higher values mean the agent trusts peer messages more.
11
+ - ``theta_r`` : Risk threshold; agent departs if ``p_danger > theta_r`` [0, 1].
12
+ - ``theta_u`` : Urgency threshold; agent departs if the urgency term falls below
13
+ this value (see ``departure_model.py``) [0, 1].
14
+ - ``gamma`` : Discount factor controlling urgency decay over time (≈ 0.99).
15
+ Each elapsed second reduces urgency by a factor of ``gamma``.
16
+ - ``lambda_e`` : Exposure weight in the route utility function (≥ 0).
17
+ Larger values make agents more averse to hazardous routes.
18
+ - ``lambda_t`` : Travel-time weight in the route utility function (≥ 0).
19
+ Larger values make agents prefer shorter travel times.
20
+ """
21
+
22
+ import math
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Dict, List
25
+
26
+
27
+ @dataclass
28
+ class AgentRuntimeState:
29
+ """Holds all mutable per-agent state across decision rounds.
30
+
31
+ Attributes:
32
+ agent_id: Unique vehicle identifier matching the SUMO vehicle ID.
33
+ created_sim_t_s: Simulation time (seconds) when this agent was first registered.
34
+ last_sim_t_s: Simulation time of the most recent state update.
35
+ profile: Immutable-ish psychological parameters (theta_trust, theta_r, etc.).
36
+ Populated by ``ensure_agent_state`` and may be overridden by calibration sweeps.
37
+ belief: Current Bayesian belief distribution {p_safe, p_risky, p_danger} plus
38
+ derived fields (entropy, entropy_norm, uncertainty_bucket).
39
+ psychology: Scalar summaries derived from the belief (perceived_risk, confidence).
40
+ signal_history: Bounded list of recent environment signals (noisy margin observations).
41
+ Used by the delay model to replay stale observations.
42
+ social_history: Bounded list of recent social signals derived from inbox messages.
43
+ decision_history: Bounded list of past decision records (predeparture + routing).
44
+ Passed to the LLM as ``agent_self_history`` so agents can avoid repeated mistakes.
45
+ has_departed: True once the vehicle has been added to the SUMO simulation.
46
+ """
47
+
48
+ agent_id: str
49
+ created_sim_t_s: float
50
+ last_sim_t_s: float
51
+ profile: Dict[str, float] = field(default_factory=dict)
52
+ belief: Dict[str, Any] = field(default_factory=dict)
53
+ psychology: Dict[str, Any] = field(default_factory=dict)
54
+ signal_history: List[Dict[str, Any]] = field(default_factory=list)
55
+ social_history: List[Dict[str, Any]] = field(default_factory=list)
56
+ decision_history: List[Dict[str, Any]] = field(default_factory=list)
57
+ has_departed: bool = True
58
+
59
+
60
+ # Global registry of all agent states, keyed by vehicle ID.
61
+ # Populated lazily as vehicles are registered in the simulation.
62
+ AGENT_STATES: Dict[str, AgentRuntimeState] = {}
63
+
64
+
65
+ def ensure_agent_state(
66
+ agent_id: str,
67
+ sim_t_s: float,
68
+ *,
69
+ default_theta_trust: float = 0.5,
70
+ default_theta_r: float = 0.45,
71
+ default_theta_u: float = 0.30,
72
+ default_gamma: float = 0.995,
73
+ default_lambda_e: float = 1.0,
74
+ default_lambda_t: float = 0.1,
75
+ ) -> AgentRuntimeState:
76
+ """Retrieve an existing agent state or create a new one with default parameters.
77
+
78
+ On first call for a given ``agent_id``, initializes the belief distribution to a
79
+ uniform prior (maximum entropy, ``uncertainty_bucket="High"``) and sets the
80
+ psychology scalars to neutral values. On subsequent calls, only updates
81
+ ``last_sim_t_s`` and back-fills any missing profile keys via ``setdefault``.
82
+
83
+ Args:
84
+ agent_id: Vehicle ID used as the state registry key.
85
+ sim_t_s: Current simulation time in seconds.
86
+ default_theta_trust: Initial social-signal trust weight (see module docstring).
87
+ default_theta_r: Initial risk-departure threshold.
88
+ default_theta_u: Initial urgency-departure threshold.
89
+ default_gamma: Per-second urgency discount factor.
90
+ default_lambda_e: Initial exposure weight for route utility scoring.
91
+ default_lambda_t: Initial travel-time weight for route utility scoring.
92
+
93
+ Returns:
94
+ The (possibly newly created) ``AgentRuntimeState`` for this vehicle.
95
+ """
96
+ state = AGENT_STATES.get(agent_id)
97
+ if state is None:
98
+ # Uniform belief = maximum entropy for a 3-state distribution.
99
+ uniform_entropy = math.log(3.0)
100
+ state = AgentRuntimeState(
101
+ agent_id=agent_id,
102
+ created_sim_t_s=float(sim_t_s),
103
+ last_sim_t_s=float(sim_t_s),
104
+ profile={
105
+ "theta_trust": float(default_theta_trust),
106
+ "theta_r": float(default_theta_r),
107
+ "theta_u": float(default_theta_u),
108
+ "gamma": float(default_gamma),
109
+ "lambda_e": float(default_lambda_e),
110
+ "lambda_t": float(default_lambda_t),
111
+ },
112
+ belief={
113
+ "p_safe": 1.0 / 3.0,
114
+ "p_risky": 1.0 / 3.0,
115
+ "p_danger": 1.0 / 3.0,
116
+ "entropy": uniform_entropy,
117
+ "entropy_norm": 1.0,
118
+ "uncertainty_bucket": "High",
119
+ },
120
+ psychology={
121
+ "perceived_risk": 0.5,
122
+ "confidence": 0.0,
123
+ },
124
+ )
125
+ AGENT_STATES[agent_id] = state
126
+ # Back-fill any profile keys added in later code versions without resetting existing ones.
127
+ state.profile.setdefault("theta_trust", float(default_theta_trust))
128
+ state.profile.setdefault("theta_r", float(default_theta_r))
129
+ state.profile.setdefault("theta_u", float(default_theta_u))
130
+ state.profile.setdefault("gamma", float(default_gamma))
131
+ state.profile.setdefault("lambda_e", float(default_lambda_e))
132
+ state.profile.setdefault("lambda_t", float(default_lambda_t))
133
+ state.last_sim_t_s = float(sim_t_s)
134
+ return state
135
+
136
+
137
+ def _append_bounded(items: List[Dict[str, Any]], value: Dict[str, Any], max_items: int) -> None:
138
+ """Append ``value`` to ``items`` and trim the list to at most ``max_items`` entries.
139
+
140
+ Older entries are dropped from the front so that ``items`` always contains the most
141
+ recent ``max_items`` records.
142
+
143
+ Args:
144
+ items: The list to append to (mutated in-place).
145
+ value: The record to append (copied shallowly).
146
+ max_items: Maximum number of entries to retain.
147
+ """
148
+ items.append(dict(value))
149
+ if len(items) > max(1, int(max_items)):
150
+ del items[:-max(1, int(max_items))]
151
+
152
+
153
+ def append_signal_history(
154
+ state: AgentRuntimeState,
155
+ signal: Dict[str, Any],
156
+ *,
157
+ max_items: int = 16,
158
+ ) -> None:
159
+ """Append an environment signal record to the agent's signal history.
160
+
161
+ The history is bounded to ``max_items`` entries (default 16). The delay model in
162
+ ``information_model.apply_signal_delay`` uses this history to retrieve stale
163
+ observations when ``INFO_DELAY_S > 0``.
164
+
165
+ Args:
166
+ state: The agent whose history to update.
167
+ signal: The environment signal dict produced by ``sample_environment_signal``.
168
+ max_items: Maximum number of signal records to retain.
169
+ """
170
+ _append_bounded(state.signal_history, signal, max_items)
171
+
172
+
173
+ def append_social_history(
174
+ state: AgentRuntimeState,
175
+ signal: Dict[str, Any],
176
+ *,
177
+ max_items: int = 16,
178
+ ) -> None:
179
+ """Append a social signal record to the agent's social history.
180
+
181
+ Social signals are derived from inbox messages by ``information_model.build_social_signal``.
182
+ Keeping a bounded history supports future analysis of how peer influence evolved.
183
+
184
+ Args:
185
+ state: The agent whose history to update.
186
+ signal: The social signal dict produced by ``build_social_signal``.
187
+ max_items: Maximum number of social signal records to retain.
188
+ """
189
+ _append_bounded(state.social_history, signal, max_items)
190
+
191
+
192
+ def append_decision_history(
193
+ state: AgentRuntimeState,
194
+ decision: Dict[str, Any],
195
+ *,
196
+ max_items: int = 32,
197
+ ) -> None:
198
+ """Append a decision record to the agent's decision history.
199
+
200
+ Decision records are passed to the LLM as ``agent_self_history`` so the model can
201
+ avoid repeating previously ineffective choices. The larger default cap (32) relative
202
+ to signal/social histories reflects the higher value of decision context.
203
+
204
+ Args:
205
+ state: The agent whose history to update.
206
+ decision: The decision record (predeparture or routing) to append.
207
+ max_items: Maximum number of decision records to retain.
208
+ """
209
+ _append_bounded(state.decision_history, decision, max_items)
210
+
211
+
212
+ def snapshot_agent_state(state: AgentRuntimeState) -> Dict[str, Any]:
213
+ """Serialize an ``AgentRuntimeState`` to a plain dict for logging or replay.
214
+
215
+ The returned dict is JSON-serializable and contains shallow copies of all mutable
216
+ sub-structures. Used by ``replay.record_agent_cognition`` to capture a point-in-time
217
+ snapshot of the agent's internal state.
218
+
219
+ Args:
220
+ state: The agent state to serialize.
221
+
222
+ Returns:
223
+ A JSON-serializable dict representation of the agent state.
224
+ """
225
+ return {
226
+ "agent_id": state.agent_id,
227
+ "created_sim_t_s": float(state.created_sim_t_s),
228
+ "last_sim_t_s": float(state.last_sim_t_s),
229
+ "profile": dict(state.profile),
230
+ "belief": dict(state.belief),
231
+ "psychology": dict(state.psychology),
232
+ "signal_history": [dict(item) for item in state.signal_history],
233
+ "social_history": [dict(item) for item in state.social_history],
234
+ "decision_history": [dict(item) for item in state.decision_history],
235
+ "has_departed": bool(state.has_departed),
236
+ }
@@ -0,0 +1,289 @@
1
+ """Bayesian belief-update pipeline for wildfire hazard assessment.
2
+
3
+ Each agent maintains a probability distribution over three hazard states:
4
+ - ``p_safe`` : fire is far enough that the agent's current position is safe.
5
+ - ``p_risky`` : fire is close enough to warrant caution but not immediate danger.
6
+ - ``p_danger`` : fire is imminent; departure is likely warranted.
7
+
8
+ The update pipeline (exposed via ``update_agent_belief``) runs once per decision period:
9
+ 1. Map the observed margin (meters from fire edge) to a prior triplet
10
+ (``categorize_hazard_state``).
11
+ 2. If social messages are present, fuse the environment-derived prior with a
12
+ social-signal belief weighted by ``theta_trust`` (``fuse_env_and_social_beliefs``).
13
+ 3. Apply temporal smoothing using a fixed inertia factor to avoid belief whiplash
14
+ (``smooth_belief``).
15
+ 4. Compute Shannon entropy to quantify uncertainty (``compute_belief_entropy``).
16
+ 5. Normalize entropy to [0, 1] and bucket it into Low / Medium / High
17
+ (``normalize_entropy``, ``bucket_uncertainty``).
18
+ """
19
+
20
+ import math
21
+ from typing import Any, Dict
22
+
23
+
24
+ def _clamp(value: float, lo: float, hi: float) -> float:
25
+ """Clamp ``value`` to the closed interval [``lo``, ``hi``].
26
+
27
+ Args:
28
+ value: The value to clamp.
29
+ lo: Lower bound (inclusive).
30
+ hi: Upper bound (inclusive).
31
+
32
+ Returns:
33
+ ``value`` clipped to [``lo``, ``hi``].
34
+ """
35
+ return max(lo, min(hi, float(value)))
36
+
37
+
38
+ def _normalize_triplet(belief: Dict[str, float]) -> Dict[str, float]:
39
+ """L1-normalize a {p_safe, p_risky, p_danger} belief dict to sum to 1.
40
+
41
+ If the total probability mass is zero or negative (degenerate input), returns a
42
+ uniform distribution so downstream code always receives a valid probability vector.
43
+
44
+ Args:
45
+ belief: Dict with keys "p_safe", "p_risky", "p_danger".
46
+
47
+ Returns:
48
+ A new dict with the same keys whose values sum to 1.0.
49
+ """
50
+ total = float(
51
+ belief.get("p_safe", 0.0) +
52
+ belief.get("p_risky", 0.0) +
53
+ belief.get("p_danger", 0.0)
54
+ )
55
+ if total <= 0.0:
56
+ return {"p_safe": 1.0 / 3.0, "p_risky": 1.0 / 3.0, "p_danger": 1.0 / 3.0}
57
+ return {
58
+ "p_safe": float(belief.get("p_safe", 0.0)) / total,
59
+ "p_risky": float(belief.get("p_risky", 0.0)) / total,
60
+ "p_danger": float(belief.get("p_danger", 0.0)) / total,
61
+ }
62
+
63
+
64
+ def categorize_hazard_state(signal: Dict[str, Any]) -> Dict[str, float]:
65
+ """Map an observed margin (meters from fire edge) to a hazard-state prior.
66
+
67
+ Uses ``observed_margin_m`` if available, falling back to ``base_margin_m``.
68
+ If neither is present (e.g., no fire active), returns a uniform prior.
69
+
70
+ Threshold rationale:
71
+ - ≤ 0 m : fire has reached / overtaken the edge → near-certain danger.
72
+ - ≤ 100 m : fire front is on the street block → high danger.
73
+ - ≤ 300 m : fire within roughly two city blocks → risky (watch closely).
74
+ - ≤ 700 m : fire is a few blocks away → elevated but manageable.
75
+ - > 700 m : fire is well clear of the route → predominantly safe.
76
+
77
+ Args:
78
+ signal: Environment signal dict, typically from ``information_model.sample_environment_signal``.
79
+
80
+ Returns:
81
+ A normalized {p_safe, p_risky, p_danger} dict representing the categorical prior.
82
+ """
83
+ margin = signal.get("observed_margin_m")
84
+ if margin is None:
85
+ margin = signal.get("base_margin_m")
86
+ if margin is None:
87
+ return {"p_safe": 1.0 / 3.0, "p_risky": 1.0 / 3.0, "p_danger": 1.0 / 3.0}
88
+
89
+ margin_f = float(margin)
90
+ if margin_f <= 0.0:
91
+ return {"p_safe": 0.02, "p_risky": 0.08, "p_danger": 0.90}
92
+ if margin_f <= 100.0:
93
+ return {"p_safe": 0.05, "p_risky": 0.20, "p_danger": 0.75}
94
+ if margin_f <= 300.0:
95
+ return {"p_safe": 0.15, "p_risky": 0.55, "p_danger": 0.30}
96
+ if margin_f <= 700.0:
97
+ return {"p_safe": 0.35, "p_risky": 0.50, "p_danger": 0.15}
98
+ return {"p_safe": 0.75, "p_risky": 0.20, "p_danger": 0.05}
99
+
100
+
101
+ def fuse_env_and_social_beliefs(
102
+ env_belief: Dict[str, float],
103
+ social_belief: Dict[str, float],
104
+ theta_trust: float,
105
+ ) -> Dict[str, float]:
106
+ """Fuse an environmental belief with a social-signal belief via weighted average.
107
+
108
+ The agent's trust parameter ``theta_trust`` ∈ [0, 1] determines how much weight
109
+ is given to peer messages relative to its own observations:
110
+ fused = (1 - theta_trust) * env_belief + theta_trust * social_belief
111
+
112
+ Args:
113
+ env_belief: Belief derived from the agent's own hazard observations.
114
+ social_belief: Belief inferred from neighbor inbox messages.
115
+ theta_trust: Weight given to social signals; 0 = ignore peers, 1 = ignore self.
116
+
117
+ Returns:
118
+ A normalized {p_safe, p_risky, p_danger} dict representing the fused belief.
119
+ """
120
+ social_weight = _clamp(theta_trust, 0.0, 1.0)
121
+ env_weight = 1.0 - social_weight
122
+ fused = {
123
+ "p_safe": env_weight * float(env_belief.get("p_safe", 0.0)) + social_weight * float(social_belief.get("p_safe", 0.0)),
124
+ "p_risky": env_weight * float(env_belief.get("p_risky", 0.0)) + social_weight * float(social_belief.get("p_risky", 0.0)),
125
+ "p_danger": env_weight * float(env_belief.get("p_danger", 0.0)) + social_weight * float(social_belief.get("p_danger", 0.0)),
126
+ }
127
+ return _normalize_triplet(fused)
128
+
129
+
130
+ def smooth_belief(
131
+ prev_belief: Dict[str, float],
132
+ new_belief: Dict[str, float],
133
+ inertia: float = 0.35,
134
+ ) -> Dict[str, float]:
135
+ """Blend the previous belief with a newly computed belief using exponential smoothing.
136
+
137
+ Prevents erratic belief flips between decision rounds by retaining a fraction
138
+ (``inertia``) of the prior state:
139
+ smoothed = inertia * prev + (1 - inertia) * new
140
+
141
+ A higher inertia value means the agent is slower to update beliefs (more conservative).
142
+ The default of 0.35 balances responsiveness with stability.
143
+
144
+ Args:
145
+ prev_belief: The agent's belief from the previous decision round.
146
+ new_belief: The freshly computed belief for the current round.
147
+ inertia: Mixing weight for the previous belief ∈ [0, 0.999].
148
+
149
+ Returns:
150
+ A normalized {p_safe, p_risky, p_danger} dict representing the smoothed belief.
151
+ """
152
+ smooth = _clamp(inertia, 0.0, 0.999)
153
+ prev = _normalize_triplet(prev_belief)
154
+ new = _normalize_triplet(new_belief)
155
+ merged = {
156
+ "p_safe": smooth * prev["p_safe"] + (1.0 - smooth) * new["p_safe"],
157
+ "p_risky": smooth * prev["p_risky"] + (1.0 - smooth) * new["p_risky"],
158
+ "p_danger": smooth * prev["p_danger"] + (1.0 - smooth) * new["p_danger"],
159
+ }
160
+ return _normalize_triplet(merged)
161
+
162
+
163
+ def compute_belief_entropy(belief: Dict[str, float]) -> float:
164
+ """Compute the Shannon entropy of a {p_safe, p_risky, p_danger} belief distribution.
165
+
166
+ Entropy H = -Σ p_i * log(p_i) over the three states. The maximum possible value
167
+ for a 3-state uniform distribution is log(3) ≈ 1.099 nats. A floor of 1e-12 is
168
+ applied to each probability to avoid log(0) singularities.
169
+
170
+ Args:
171
+ belief: The belief distribution dict (will be normalized internally).
172
+
173
+ Returns:
174
+ Shannon entropy in nats (≥ 0).
175
+ """
176
+ norm = _normalize_triplet(belief)
177
+ total = 0.0
178
+ for key in ("p_safe", "p_risky", "p_danger"):
179
+ p = max(1e-12, float(norm[key]))
180
+ total -= p * math.log(p)
181
+ return total
182
+
183
+
184
+ def normalize_entropy(entropy: float) -> float:
185
+ """Normalize Shannon entropy to the range [0, 1] relative to the 3-state maximum.
186
+
187
+ Divides raw entropy by log(3) so that a uniform distribution maps to 1.0 and a
188
+ fully confident belief maps to 0.0.
189
+
190
+ Args:
191
+ entropy: Raw Shannon entropy in nats.
192
+
193
+ Returns:
194
+ Normalized entropy ∈ [0, 1].
195
+ """
196
+ max_entropy = math.log(3.0)
197
+ if max_entropy <= 0.0:
198
+ return 0.0
199
+ return _clamp(float(entropy) / max_entropy, 0.0, 1.0)
200
+
201
+
202
+ def bucket_uncertainty(entropy_norm: float) -> str:
203
+ """Discretize normalized entropy into a human-readable uncertainty label.
204
+
205
+ Thresholds:
206
+ - "Low" : entropy_norm ≤ 0.33 (agent is fairly confident)
207
+ - "Medium" : entropy_norm ≤ 0.67 (moderate uncertainty)
208
+ - "High" : entropy_norm > 0.67 (agent is highly uncertain)
209
+
210
+ The label is included in the LLM prompt so the model can reason about its own
211
+ epistemic state without having to interpret raw entropy values.
212
+
213
+ Args:
214
+ entropy_norm: Normalized entropy ∈ [0, 1].
215
+
216
+ Returns:
217
+ One of "Low", "Medium", or "High".
218
+ """
219
+ val = _clamp(entropy_norm, 0.0, 1.0)
220
+ if val <= 0.33:
221
+ return "Low"
222
+ if val <= 0.67:
223
+ return "Medium"
224
+ return "High"
225
+
226
+
227
+ def update_agent_belief(
228
+ prev_belief: Dict[str, float],
229
+ env_signal: Dict[str, Any],
230
+ social_signal: Dict[str, Any],
231
+ theta_trust: float,
232
+ inertia: float = 0.35,
233
+ ) -> Dict[str, Any]:
234
+ """Run the full Bayesian belief-update pipeline for one decision round.
235
+
236
+ Steps:
237
+ 1. Convert environment signal margin to a categorical prior (``categorize_hazard_state``).
238
+ 2. If peer messages exist, fuse env prior with social belief via ``theta_trust``
239
+ (``fuse_env_and_social_beliefs``); otherwise use env prior directly.
240
+ 3. Apply temporal smoothing against the previous belief (``smooth_belief``).
241
+ 4. Compute and normalize Shannon entropy; bucket into Low / Medium / High.
242
+
243
+ Args:
244
+ prev_belief: The agent's belief dict from the previous decision round.
245
+ env_signal: Environment signal produced by ``information_model.sample_environment_signal``.
246
+ social_signal: Social signal produced by ``information_model.build_social_signal``.
247
+ theta_trust: Social-signal trust weight ∈ [0, 1] (from agent profile).
248
+ inertia: Temporal smoothing factor ∈ [0, 0.999].
249
+
250
+ Returns:
251
+ An enriched belief dict containing:
252
+ - p_safe, p_risky, p_danger : smoothed posterior probabilities
253
+ - entropy, entropy_norm : Shannon entropy (raw and normalized)
254
+ - uncertainty_bucket : "Low", "Medium", or "High"
255
+ - env_weight, social_weight : fusion weights applied this round
256
+ - env_belief, social_belief : component beliefs before fusion
257
+ """
258
+ env_belief = categorize_hazard_state(env_signal)
259
+
260
+ social_count = int(social_signal.get("message_count", 0) or 0)
261
+ social_belief_raw = social_signal.get("social_belief") or {}
262
+ if social_count > 0:
263
+ social_belief = _normalize_triplet(social_belief_raw)
264
+ fused = fuse_env_and_social_beliefs(env_belief, social_belief, theta_trust)
265
+ social_weight = _clamp(theta_trust, 0.0, 1.0)
266
+ env_weight = 1.0 - social_weight
267
+ else:
268
+ # No messages in inbox: rely entirely on own environmental observation.
269
+ social_belief = {"p_safe": 1.0 / 3.0, "p_risky": 1.0 / 3.0, "p_danger": 1.0 / 3.0}
270
+ fused = dict(env_belief)
271
+ social_weight = 0.0
272
+ env_weight = 1.0
273
+
274
+ smoothed = smooth_belief(prev_belief or env_belief, fused, inertia=inertia)
275
+ entropy = compute_belief_entropy(smoothed)
276
+ entropy_norm = normalize_entropy(entropy)
277
+
278
+ return {
279
+ "p_safe": smoothed["p_safe"],
280
+ "p_risky": smoothed["p_risky"],
281
+ "p_danger": smoothed["p_danger"],
282
+ "entropy": round(entropy, 4),
283
+ "entropy_norm": round(entropy_norm, 4),
284
+ "uncertainty_bucket": bucket_uncertainty(entropy_norm),
285
+ "env_weight": round(env_weight, 4),
286
+ "social_weight": round(social_weight, 4),
287
+ "env_belief": env_belief,
288
+ "social_belief": social_belief,
289
+ }
@@ -0,0 +1,82 @@
1
+ """Departure decision rule for pre-evacuation agents.
2
+
3
+ An agent waits at its spawn edge until this module's ``should_depart_now`` function
4
+ returns ``True``. The function implements a three-clause OR rule:
5
+
6
+ 1. **Risk threshold** (``risk_threshold``):
7
+ The agent's estimated danger probability exceeds its personal threshold
8
+ ``theta_r``. This is the primary, reactive trigger.
9
+
10
+ 2. **Urgency decay** (``urgency_threshold``):
11
+ The urgency term ``gamma^elapsed_s * p_safe`` falls below ``theta_u``.
12
+ As time passes, the discount factor ``gamma`` (< 1) erodes the "stay safe"
13
+ signal so that an agent that has been waiting a long time will eventually
14
+ feel compelled to act — even if ``p_danger`` is still low. This captures
15
+ the real-world observation that people eventually evacuate out of a growing
16
+ general unease rather than a discrete danger trigger.
17
+
18
+ 3. **Low-confidence precaution** (``low_confidence_precaution``):
19
+ If the agent is highly uncertain (``confidence < 0.15``) *and* the danger
20
+ probability has reached at least 60 % of ``theta_r``, it departs
21
+ pre-emptively. This prevents agents from staying frozen in high-entropy
22
+ situations where they should arguably err on the side of caution.
23
+ """
24
+
25
+ from typing import Any, Dict, Tuple
26
+
27
+ from agentevac.agents.agent_state import AgentRuntimeState
28
+
29
+
30
+ def should_depart_now(
31
+ agent_state: AgentRuntimeState,
32
+ belief: Dict[str, Any],
33
+ psychology: Dict[str, Any],
34
+ sim_t_s: float,
35
+ ) -> Tuple[bool, str]:
36
+ """Evaluate whether an agent should depart from its spawn edge at the current tick.
37
+
38
+ Applies three departure clauses in priority order (first match wins):
39
+ 1. ``risk_threshold`` : ``p_danger > theta_r``
40
+ 2. ``urgency_threshold`` : ``gamma^elapsed_s * p_safe < theta_u``
41
+ 3. ``low_confidence_precaution``: ``confidence < 0.15`` and
42
+ ``p_danger >= max(0.20, theta_r * 0.6)``
43
+
44
+ If none of the clauses fire, returns ``(False, "wait")``.
45
+
46
+ Args:
47
+ agent_state: The agent's runtime state (supplies profile parameters and creation time).
48
+ belief: Current Bayesian belief dict with keys "p_danger", "p_safe", etc.
49
+ psychology: Current psychology dict with key "confidence".
50
+ sim_t_s: Current simulation time in seconds.
51
+
52
+ Returns:
53
+ A ``(should_depart, reason)`` tuple where ``reason`` is one of
54
+ "risk_threshold", "urgency_threshold", "low_confidence_precaution", or "wait".
55
+ """
56
+ p_danger = float(belief.get("p_danger", 0.0))
57
+ p_safe = float(belief.get("p_safe", 0.0))
58
+ theta_r = float(agent_state.profile.get("theta_r", 0.45))
59
+ theta_u = float(agent_state.profile.get("theta_u", 0.30))
60
+ gamma = float(agent_state.profile.get("gamma", 0.995))
61
+ created_t = float(agent_state.created_sim_t_s)
62
+ elapsed_s = max(0.0, float(sim_t_s) - created_t)
63
+
64
+ # Clause 1: Direct danger threshold.
65
+ if p_danger > theta_r:
66
+ return True, "risk_threshold"
67
+
68
+ # Clause 2: Urgency decay — the longer the agent waits, the lower gamma^t * p_safe
69
+ # becomes, eventually dropping below theta_u. This forces eventual action even in
70
+ # low-information environments.
71
+ urgency_term = (gamma ** elapsed_s) * p_safe
72
+ if urgency_term < theta_u:
73
+ return True, "urgency_threshold"
74
+
75
+ # Clause 3: High uncertainty + non-trivial danger probability → err on the side of
76
+ # caution. Only fires when the agent is genuinely uncertain (low confidence) and
77
+ # danger is already elevated relative to its own risk threshold.
78
+ confidence = float(psychology.get("confidence", 0.0))
79
+ if confidence < 0.15 and p_danger >= max(0.20, theta_r * 0.6):
80
+ return True, "low_confidence_precaution"
81
+
82
+ return False, "wait"