g_counter.py python
184 lines 6.3 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 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 from __future__ import annotations
28
29 import logging
30 from typing import TypedDict
31
32 logger = logging.getLogger(__name__)
33
34 type _CountMap = dict[str, int]
35
36
37 class GCounterDict(TypedDict, total=False):
38 """Wire format for a :class:`GCounter` — ``{agent_id: count}``.
39
40 ``total=False`` because absent keys are equivalent to ``0``. Serialise
41 with :meth:`GCounter.to_dict` and deserialise with :meth:`GCounter.from_dict`.
42 """
43
44 class GCounter:
45 """Grow-only Counter — a CRDT counter that only ever increases.
46
47 Each agent increments its own private slot; the global value is the sum of
48 all slots. Only the owning agent may increment a slot (this is enforced by
49 convention — not cryptographically).
50
51 All mutating methods return new :class:`GCounter` instances; ``self`` is
52 never modified.
53
54 Example::
55
56 c1 = GCounter().increment("agent-1")
57 c2 = GCounter().increment("agent-2").increment("agent-2")
58 merged = c1.join(c2)
59 assert merged.value() == 3 # 1 from agent-1 + 2 from agent-2
60 assert merged.join(c1).value() == 3 # idempotent
61 """
62
63 def __init__(self, counts: _CountMap | None = None) -> None:
64 """Construct a G-Counter, optionally pre-populated.
65
66 Args:
67 counts: Initial ``{agent_id: count}`` mapping. Copied defensively.
68 All values must be non-negative integers.
69 """
70 self._counts: _CountMap = dict(counts) if counts else {}
71
72 # ------------------------------------------------------------------
73 # Mutation (returns new GCounter)
74 # ------------------------------------------------------------------
75
76 def increment(self, agent_id: str, by: int = 1) -> GCounter:
77 """Return a new counter with *agent_id*'s slot incremented by *by*.
78
79 Args:
80 agent_id: The agent performing the increment (must be the caller's
81 own agent ID to maintain CRDT invariants).
82 by: Amount to increment; must be a positive integer.
83
84 Returns:
85 A new :class:`GCounter` with the updated slot.
86
87 Raises:
88 ValueError: If *by* is not a positive integer.
89 """
90 if by <= 0:
91 raise ValueError(f"GCounter.increment: 'by' must be positive, got {by}")
92 new_counts = dict(self._counts)
93 new_counts[agent_id] = new_counts.get(agent_id, 0) + by
94 return GCounter(new_counts)
95
96 # ------------------------------------------------------------------
97 # CRDT join
98 # ------------------------------------------------------------------
99
100 def join(self, other: GCounter) -> GCounter:
101 """Return the lattice join — per-slot maximum of ``self`` and *other*.
102
103 Args:
104 other: The counter to merge with.
105
106 Returns:
107 A new :class:`GCounter` holding the per-agent maximum counts.
108 """
109 all_agents = set(self._counts) | set(other._counts)
110 merged = {
111 agent: max(self._counts.get(agent, 0), other._counts.get(agent, 0))
112 for agent in all_agents
113 }
114 return GCounter(merged)
115
116 # ------------------------------------------------------------------
117 # Query
118 # ------------------------------------------------------------------
119
120 def value(self) -> int:
121 """Return the global counter value — the sum of all agent slots.
122
123 Returns:
124 Non-negative integer.
125 """
126 return sum(self._counts.values())
127
128 def value_for(self, agent_id: str) -> int:
129 """Return the count for a specific agent.
130
131 Args:
132 agent_id: The agent to query.
133
134 Returns:
135 The agent's slot value, or ``0`` if the agent has not incremented.
136 """
137 return self._counts.get(agent_id, 0)
138
139 # ------------------------------------------------------------------
140 # Serialisation
141 # ------------------------------------------------------------------
142
143 def to_dict(self) -> _CountMap:
144 """Return a JSON-serialisable ``{agent_id: count}`` mapping.
145
146 Returns:
147 A shallow copy of the internal counts dictionary.
148 """
149 return dict(self._counts)
150
151 @classmethod
152 def from_dict(cls, data: _CountMap) -> GCounter:
153 """Reconstruct a :class:`GCounter` from its wire representation.
154
155 Args:
156 data: ``{agent_id: count}`` mapping as produced by :meth:`to_dict`.
157
158 Returns:
159 A new :class:`GCounter`.
160 """
161 return cls(data)
162
163 # ------------------------------------------------------------------
164 # Python dunder helpers
165 # ------------------------------------------------------------------
166
167 def equivalent(self, other: GCounter) -> bool:
168 """Return ``True`` if both counters hold identical per-agent counts.
169
170 Args:
171 other: The G-Counter to compare against.
172
173 Returns:
174 ``True`` when every agent slot has the same value in both counters
175 (treating absent agents as count 0).
176 """
177 all_agents = set(self._counts) | set(other._counts)
178 return all(
179 self._counts.get(a, 0) == other._counts.get(a, 0)
180 for a in all_agents
181 )
182
183 def __repr__(self) -> str:
184 return f"GCounter(value={self.value()}, slots={self._counts!r})"
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago