File size: 4,801 Bytes
eb1ae15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
TimeController — Slot-based turn synchronisation for the multi-agent CropRL env.

Each calendar month is divided into K action slots.  Every agent starts the
month with a budget of K slots.  The month only advances when **every** agent
has either exhausted their budget or explicitly called End Turn (action 0).

Design choices aligned with the implementation plan:
- Fixed rotation: the "first agent" rotates each month to avoid positional bias.
- Slot ordering within a month is fixed (agent 0, 1, … N-1 take turns), but
  the first active slot is awarded to a different agent each month.
- Agents that call End Turn early simply wait (blocked) while others finish.
"""

from __future__ import annotations

from typing import Dict, Optional


class TurnOverError(Exception):
    """Raised when an agent tries to act after calling End Turn this month."""


class TimeController:
    """
    Manages the shared calendar month and per-agent action budgets.

    Attributes
    ----------
    month : int
        Current calendar month (1-12).
    year : int
        Current year (1-based).
    month_count : int
        Total months elapsed since episode start.
    """

    def __init__(self, num_agents: int, action_slots_per_month: int) -> None:
        self.num_agents = num_agents
        self.action_slots_per_month = action_slots_per_month

        self.month: int = 1
        self.year: int = 1
        self.month_count: int = 0

        # Per-agent bookkeeping
        self._slots_used: Dict[int, int] = {i: 0 for i in range(num_agents)}

        # Rotating first-agent index (changes each month, fair rotation)
        self._first_agent_offset: int = 0

    # ──────────────────────────────────────────────────────────────
    # Public API
    # ──────────────────────────────────────────────────────────────

    def reset(self) -> None:
        """Reset the controller for a new episode."""
        self.month = 1
        self.year = 1
        self.month_count = 0
        self._first_agent_offset = 0
        self._reset_month()

    def slots_remaining(self, agent_id: int) -> int:
        """Return how many action slots agent *agent_id* has left this month."""
        return max(0, self.action_slots_per_month - self._slots_used[agent_id])

    def is_turn_done(self, agent_id: int) -> bool:
        """Return True if the agent has exhausted their slots for this month."""
        return self._slots_used[agent_id] >= self.action_slots_per_month

    def consume_slot(self, agent_id: int) -> None:
        """
        Consume one action slot for *agent_id*.

        Raises
        ------
        ValueError
            If the agent has no slots remaining (budget exhausted).
        """
        if self._slots_used[agent_id] >= self.action_slots_per_month:
            raise TurnOverError(
                f"Agent {agent_id} has no action slots remaining this month."
            )

        self._slots_used[agent_id] += 1

    def all_done(self) -> bool:
        """Return True when every agent has exhausted their action slots."""
        return all(self._slots_used[i] >= self.action_slots_per_month for i in range(self.num_agents))

    def advance_month(self) -> bool:
        """
        Advance the calendar by one month.  Returns True when a year boundary
        is crossed (useful for triggering inflation in the outer env).
        """
        old_month = self.month
        self.month = (self.month % 12) + 1
        self.month_count += 1

        year_advanced = False
        if self.month == 1 and old_month == 12:
            self.year += 1
            year_advanced = True

        # Rotate the first-agent offset
        self._first_agent_offset = (self._first_agent_offset + 1) % self.num_agents
        self._reset_month()
        return year_advanced

    def current_slot_for(self, agent_id: int) -> int:
        """Return the slot index (0-based) the agent is *currently* on."""
        return self._slots_used[agent_id]

    # ──────────────────────────────────────────────────────────────
    # Internal helpers
    # ──────────────────────────────────────────────────────────────

    def _reset_month(self) -> None:
        """Reset per-agent slot budgets for a new month."""
        for i in range(self.num_agents):
            self._slots_used[i] = 0