-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
DataRecorder exhibits off-by-one error (captures pre-execution state) #3297
Description
Describe the bug
In the new DES architecture, the experimental DataRecorder(#3145) exhibits an "off-by-one" recording error where it permanently captures the "past" state of the model. It records the state of the model before the agents execute their logic for the current time step.
Because model.time is an Observable, updating the simulation clock instantly broadcasts a signal to the DataRecorder, which takes a snapshot before the actual event for that time slice is allowed to execute.
Expected behavior
The DataRecorder should capture the resolved state of the model after the agents have executed their actions at a given time unit. I see it succeed in its behavior but tags the collection one time unit late(see last output). If an agent acts at , the data row for should reflect the results of that action.
The exact cause is in model.py inside the _advance_time method:
Lines 223 to 225 in d6ad80f
| if event.time <= until: | |
| self.time = event.time | |
| event.execute() |
An obvious fix is to move self.time = event.time below event.execute(). However, It completely broke the simulation. Because the default step() is wrapped in an EventGenerator, executing the event before updating the clock forces the generator to reschedule the next step using a frozen clock (e.g., 0.0 + 1.0 = 1.0). This created a paradox where the engine immediately pops and runs the newly scheduled event, causing agents to execute twice per step.
To Reproduce
from mesa import Model, Agent
from mesa.experimental.data_collection import DataRecorder
from mesa.datacollection import DataCollector
class MyAgent(Agent):
def __init__(self, model):
super().__init__(model)
self.wealth = 0
def step(self):
self.wealth += 10
class MyModel(Model):
def __init__(self):
super().__init__()
MyAgent.create_agents(self,1)
self.data_registry.track_agents(self.agents, "Wealth", "wealth")
self.datacollector = DataCollector(
agent_reporters={"Wealth": "wealth"},
)
self.recorder = DataRecorder(self)
self.datacollector.collect(self)
def step(self):
self.agents.do("step")
self.datacollector.collect(self)
model = MyModel()
for _ in range(4):
model.step()
print(f"New DataRecorder: \n{model.recorder.get_table_dataframe("Wealth").to_string()}\n")
print(f"Old DataCollector: \n{model.datacollector.get_agent_vars_dataframe().to_string()}")Output:
On main:
New DataRecorder:
unique_id wealth time
0 1 0 0.0
1 1 0 1.0
2 1 10 2.0
3 1 20 3.0
4 1 30 4.0
Old DataCollector:
Wealth
Step AgentID
0.0 1 0
1.0 1 10
2.0 1 20
3.0 1 30
4.0 1 40
After moving self.tim=event.time below event.execute() in model.py
New DataRecorder:
unique_id wealth time
0 1 0 0.0
1 1 10 1.0
2 1 30 2.0
3 1 50 3.0
4 1 70 4.0
Old DataCollector:
Wealth
Step AgentID
0.0 1 10
1.0 1 30
2.0 1 50
3.0 1 70
4.0 1 80
After reverting the changes in #3284
New DataRecorder:
unique_id wealth time
0 1 0 1.0
1 1 10 2.0
2 1 20 3.0
3 1 30 4.0
Old DataCollector:
Step AgentID Wealth
0.0 1 0
1.0 1 10
2.0 1 20
3.0 1 30
4.0 1 40