gabriel / muse public
g_counter.py python
181 lines 6.2 KB
Raw
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217 chore(timeline): remove unused RationalRate import in entity.py Human minor ⚠ breaking 8 days ago
1 """Grow-only Counter (G-Counter) — CRDT for monotonically increasing counts.
2
3 A G-Counter assigns one slot per agent. Each agent may only increment its own
4 slot. The global value is the sum of all slots. ``join`` takes the per-slot
5 maximum (matching the Vector Clock pattern), which is commutative, associative,
6 and idempotent.
7
8 Use cases in Muse:
9 - Commit-count metrics per agent (how many commits has each agent contributed?).
10 - Replay counters (how many times has this element been touched?).
11 - Any monotonically increasing quantity across a distributed agent fleet.
12
13 **Lattice laws satisfied by** :meth:`join`:
14 1. Commutativity: ``join(a, b) == join(b, a)``
15 2. Associativity: ``join(join(a, b), c) == join(a, join(b, c))``
16 3. Idempotency: ``join(a, a) == a``
17
18 Proof: ``join`` is ``max`` per slot and ``value`` is ``sum`` — both operations
19 on the non-negative integer lattice are trivially lattice-correct.
20
21 Public API
22 ----------
23 - :class:`GCounterDict` — ``TypedDict`` wire format ``{agent_id: count}``.
24 - :class:`GCounter` — the counter itself.
25 """
26
27 import logging
28 from typing import TypedDict
29
30 logger = logging.getLogger(__name__)
31
32 type _CountMap = dict[str, int]
33
34 class GCounterDict(TypedDict, total=False):
35 """Wire format for a :class:`GCounter` — ``{agent_id: count}``.
36
37 ``total=False`` because absent keys are equivalent to ``0``. Serialise
38 with :meth:`GCounter.to_dict` and deserialise with :meth:`GCounter.from_dict`.
39 """
40
41 class GCounter:
42 """Grow-only Counter — a CRDT counter that only ever increases.
43
44 Each agent increments its own private slot; the global value is the sum of
45 all slots. Only the owning agent may increment a slot (this is enforced by
46 convention — not cryptographically).
47
48 All mutating methods return new :class:`GCounter` instances; ``self`` is
49 never modified.
50
51 Example::
52
53 c1 = GCounter().increment("agent-1")
54 c2 = GCounter().increment("agent-2").increment("agent-2")
55 merged = c1.join(c2)
56 assert merged.value() == 3 # 1 from agent-1 + 2 from agent-2
57 assert merged.join(c1).value() == 3 # idempotent
58 """
59
60 def __init__(self, counts: _CountMap | None = None) -> None:
61 """Construct a G-Counter, optionally pre-populated.
62
63 Args:
64 counts: Initial ``{agent_id: count}`` mapping. Copied defensively.
65 All values must be non-negative integers.
66 """
67 self._counts: _CountMap = dict(counts) if counts else {}
68
69 # ------------------------------------------------------------------
70 # Mutation (returns new GCounter)
71 # ------------------------------------------------------------------
72
73 def increment(self, agent_id: str, by: int = 1) -> GCounter:
74 """Return a new counter with *agent_id*'s slot incremented by *by*.
75
76 Args:
77 agent_id: The agent performing the increment (must be the caller's
78 own agent ID to maintain CRDT invariants).
79 by: Amount to increment; must be a positive integer.
80
81 Returns:
82 A new :class:`GCounter` with the updated slot.
83
84 Raises:
85 ValueError: If *by* is not a positive integer.
86 """
87 if by <= 0:
88 raise ValueError(f"GCounter.increment: 'by' must be positive, got {by}")
89 new_counts = dict(self._counts)
90 new_counts[agent_id] = new_counts.get(agent_id, 0) + by
91 return GCounter(new_counts)
92
93 # ------------------------------------------------------------------
94 # CRDT join
95 # ------------------------------------------------------------------
96
97 def join(self, other: GCounter) -> GCounter:
98 """Return the lattice join — per-slot maximum of ``self`` and *other*.
99
100 Args:
101 other: The counter to merge with.
102
103 Returns:
104 A new :class:`GCounter` holding the per-agent maximum counts.
105 """
106 all_agents = set(self._counts) | set(other._counts)
107 merged = {
108 agent: max(self._counts.get(agent, 0), other._counts.get(agent, 0))
109 for agent in all_agents
110 }
111 return GCounter(merged)
112
113 # ------------------------------------------------------------------
114 # Query
115 # ------------------------------------------------------------------
116
117 def value(self) -> int:
118 """Return the global counter value — the sum of all agent slots.
119
120 Returns:
121 Non-negative integer.
122 """
123 return sum(self._counts.values())
124
125 def value_for(self, agent_id: str) -> int:
126 """Return the count for a specific agent.
127
128 Args:
129 agent_id: The agent to query.
130
131 Returns:
132 The agent's slot value, or ``0`` if the agent has not incremented.
133 """
134 return self._counts.get(agent_id, 0)
135
136 # ------------------------------------------------------------------
137 # Serialisation
138 # ------------------------------------------------------------------
139
140 def to_dict(self) -> _CountMap:
141 """Return a JSON-serialisable ``{agent_id: count}`` mapping.
142
143 Returns:
144 A shallow copy of the internal counts dictionary.
145 """
146 return dict(self._counts)
147
148 @classmethod
149 def from_dict(cls, data: _CountMap) -> GCounter:
150 """Reconstruct a :class:`GCounter` from its wire representation.
151
152 Args:
153 data: ``{agent_id: count}`` mapping as produced by :meth:`to_dict`.
154
155 Returns:
156 A new :class:`GCounter`.
157 """
158 return cls(data)
159
160 # ------------------------------------------------------------------
161 # Python dunder helpers
162 # ------------------------------------------------------------------
163
164 def equivalent(self, other: GCounter) -> bool:
165 """Return ``True`` if both counters hold identical per-agent counts.
166
167 Args:
168 other: The G-Counter to compare against.
169
170 Returns:
171 ``True`` when every agent slot has the same value in both counters
172 (treating absent agents as count 0).
173 """
174 all_agents = set(self._counts) | set(other._counts)
175 return all(
176 self._counts.get(a, 0) == other._counts.get(a, 0)
177 for a in all_agents
178 )
179
180 def __repr__(self) -> str:
181 return f"GCounter(value={self.value()}, slots={self._counts!r})"
File History 1 commit
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217 chore(timeline): remove unused RationalRate import in entity.py Human minor 8 days ago