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 +13 -0
- agentevac/agents/__init__.py +12 -0
- agentevac/agents/agent_state.py +236 -0
- agentevac/agents/belief_model.py +289 -0
- agentevac/agents/departure_model.py +82 -0
- agentevac/agents/information_model.py +265 -0
- agentevac/agents/routing_utility.py +278 -0
- agentevac/agents/scenarios.py +241 -0
- agentevac/analysis/__init__.py +7 -0
- agentevac/analysis/calibration.py +398 -0
- agentevac/analysis/experiments.py +435 -0
- agentevac/analysis/metrics.py +348 -0
- agentevac/analysis/study_runner.py +265 -0
- agentevac/simulation/__init__.py +5 -0
- agentevac/simulation/__main__.py +3 -0
- agentevac/simulation/main.py +3396 -0
- agentevac/simulation/spawn_events.py +278 -0
- agentevac/utils/__init__.py +5 -0
- agentevac/utils/forecast_layer.py +284 -0
- agentevac/utils/replay.py +393 -0
- agentevac-0.1.0.dist-info/METADATA +298 -0
- agentevac-0.1.0.dist-info/RECORD +26 -0
- agentevac-0.1.0.dist-info/WHEEL +5 -0
- agentevac-0.1.0.dist-info/entry_points.txt +3 -0
- agentevac-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentevac-0.1.0.dist-info/top_level.txt +1 -0
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"
|