Skip to content

DataRecorder exhibits off-by-one error (captures pre-execution state) #3297

@codebreaker32

Description

@codebreaker32

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:

mesa/mesa/model.py

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions