zikaron 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""The `consolidation_group` aggregate and its two child tables, plus every state transition.
|
|
2
|
+
|
|
3
|
+
`schema.md` invariants 16, 17 and 19 are normative, and so is `architecture.md` §"Serving". Every
|
|
4
|
+
statement that reads or writes `consolidation_group`, `consolidation_group_member` or
|
|
5
|
+
`consolidation_group_candidate` is here, so a schema change reaches one module.
|
|
6
|
+
|
|
7
|
+
**Group order is `(created_at, uuid)` over the members**, named once in `consolidation.md` step 3
|
|
8
|
+
and implemented once here: it is the order members are delivered in, the order `remaining_uuids` is
|
|
9
|
+
reported in, the order the gist concatenation concatenates in, and the order shards are cut on.
|
|
10
|
+
`created_at` alone is not total — concurrent writes share the string — so the uuid is part of the
|
|
11
|
+
order rather than a tiebreak applied wherever a collision was noticed.
|
|
12
|
+
|
|
13
|
+
**Every status transition is a guarded update returning whether it fired.** That is what makes the
|
|
14
|
+
transitions idempotent under a retry and what lets two paths reach the same conclusion in one
|
|
15
|
+
transaction without writing it twice: invariant 16 gives `→ complete` **two** producers — the write
|
|
16
|
+
verb that dispositions the last member, and the serve transaction when serve-time vacating
|
|
17
|
+
dispositions it — so "did I actually close this" has to be an answer rather than an assumption.
|
|
18
|
+
|
|
19
|
+
**Frozen membership and the served set are different things.** `consolidation_group_member` is the
|
|
20
|
+
plan-time universe and stays that way; the served set is the payload view of it — the members with
|
|
21
|
+
`disposition IS NULL` after serve-time re-validation — and is never a second source of truth.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from enum import StrEnum
|
|
27
|
+
from typing import Final
|
|
28
|
+
from uuid import uuid4
|
|
29
|
+
|
|
30
|
+
import aiosqlite
|
|
31
|
+
|
|
32
|
+
#: Which statuses mean "this group is still open", i.e. the two invariant 16's closure property is
|
|
33
|
+
#: stated over and the two `remaining_groups` counts. `deferred` is terminal for the run and
|
|
34
|
+
#: `complete` is terminal outright, so neither is open.
|
|
35
|
+
OPEN_STATUSES: Final = ("pending", "served")
|
|
36
|
+
|
|
37
|
+
_GROUP_COLUMNS: Final = (
|
|
38
|
+
"group_id, run_id, anchor_uuid, order_key, shard_index, shard_count, status, "
|
|
39
|
+
"serve_count, served_at"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
#: The join and ordering that *is* group order. One constant rather than an `ORDER BY` per
|
|
43
|
+
#: statement, because five things are defined over this order and a statement ordering by
|
|
44
|
+
#: `created_at` alone would agree with the rest on every fixture with distinct timestamps.
|
|
45
|
+
_MEMBER_ORDER: Final = "ORDER BY m.created_at ASC, mem.memory_uuid ASC"
|
|
46
|
+
_MEMBER_JOIN: Final = (
|
|
47
|
+
"FROM consolidation_group_member mem "
|
|
48
|
+
"JOIN memory m ON m.uuid = mem.memory_uuid "
|
|
49
|
+
"WHERE mem.group_id = ?"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GroupStatus(StrEnum):
|
|
54
|
+
"""`consolidation_group.status`'s four values, exactly as `schema.md`'s `CHECK` states them."""
|
|
55
|
+
|
|
56
|
+
PENDING = "pending"
|
|
57
|
+
SERVED = "served"
|
|
58
|
+
COMPLETE = "complete"
|
|
59
|
+
DEFERRED = "deferred"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Disposition(StrEnum):
|
|
63
|
+
"""What became of one member.
|
|
64
|
+
|
|
65
|
+
`VACATED` is the one that is not a consolidator decision: the row left `tier='journal' AND
|
|
66
|
+
active=1` by another path between plan and serve — a primary agent retired it, or promoted it —
|
|
67
|
+
so it is not delivered, nothing was lost, and it blocks no completion. It **counts as
|
|
68
|
+
dispositioned**, which is what lets vacating the last open member close the group inside the
|
|
69
|
+
serve transaction rather than leaving it waiting for a write verb with nothing left to write.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
MERGED = "merged"
|
|
73
|
+
PROMOTED = "promoted"
|
|
74
|
+
DISCARDED = "discarded"
|
|
75
|
+
VACATED = "vacated"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CandidateRole(StrEnum):
|
|
79
|
+
"""`consolidation_group_candidate.role`: which of the two kinds of long-term record this is."""
|
|
80
|
+
|
|
81
|
+
ANCHOR = "anchor"
|
|
82
|
+
CANDIDATE = "candidate"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Shard:
|
|
87
|
+
"""One group's position among the shards of its pre-shard subgroup, both halves persisted.
|
|
88
|
+
|
|
89
|
+
**1-based**, so an unsharded group is `{index: 1, of: 1}` rather than a third convention meaning
|
|
90
|
+
"not sharded". `of` cannot be recomputed at serve time — that would mean replanning a subgroup
|
|
91
|
+
whose membership is frozen, against a store that has since moved because earlier groups mutate
|
|
92
|
+
it — so it is stored and returned verbatim.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: `of < 1` or `index` outside `1…of`, the same conditions the table's own per-row
|
|
96
|
+
`CHECK`s state. Held here as well so a malformed shard cannot be constructed at all,
|
|
97
|
+
rather than only failing at the `INSERT`: the planner computes these, and a planner that
|
|
98
|
+
can build an impossible one can also build a set that violates invariant 19.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
index: int
|
|
102
|
+
of: int
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
if self.of < 1:
|
|
106
|
+
raise ValueError(f"shard_count must be >= 1, got {self.of}")
|
|
107
|
+
if not 1 <= self.index <= self.of:
|
|
108
|
+
raise ValueError(f"shard_index {self.index} outside 1-{self.of}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class Group:
|
|
113
|
+
"""One `consolidation_group` row, exactly as stored."""
|
|
114
|
+
|
|
115
|
+
group_id: str
|
|
116
|
+
run_id: str
|
|
117
|
+
anchor_uuid: str | None
|
|
118
|
+
order_key: str
|
|
119
|
+
shard: Shard
|
|
120
|
+
status: GroupStatus
|
|
121
|
+
serve_count: int
|
|
122
|
+
served_at: str | None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class Member:
|
|
127
|
+
"""One `consolidation_group_member` row, exactly as stored, plus its ordering facts.
|
|
128
|
+
|
|
129
|
+
`created_at` is the member's own `memory.created_at` rather than a column of this table: group
|
|
130
|
+
order is defined over it, and carrying it here is what lets the order be established by one
|
|
131
|
+
statement instead of by a second lookup per member.
|
|
132
|
+
|
|
133
|
+
`version_seen` is the plan-time version and is **change detection only**; `version_served` is
|
|
134
|
+
the version whose prose was actually delivered on the most recent serve that delivered this row,
|
|
135
|
+
and is the `expected_version` the payload carried. `version_served IS NULL` iff the row has
|
|
136
|
+
never been delivered — which is what distinguishes a member that vacated on the serve that would
|
|
137
|
+
have delivered it from one delivered earlier and vacated later.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
memory_uuid: str
|
|
141
|
+
created_at: str
|
|
142
|
+
version_seen: int
|
|
143
|
+
version_served: int | None
|
|
144
|
+
disposition: Disposition | None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class AuthorizedRecord:
|
|
149
|
+
"""One row of `consolidation_group_candidate`: a long-term record a `merge` may target.
|
|
150
|
+
|
|
151
|
+
This table, not the `group_served` events, is the authority — the events are instrumentation. It
|
|
152
|
+
is rewritten on every serve, in the serve's own transaction, so authorization survives a service
|
|
153
|
+
restart without recomputation.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
memory_uuid: str
|
|
157
|
+
role: CandidateRole
|
|
158
|
+
version_served: int
|
|
159
|
+
rank: int
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def new_group_id() -> str:
|
|
163
|
+
"""A fresh `group_id`. The one handle this package exposes, and only to the consolidator."""
|
|
164
|
+
return str(uuid4())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _to_group(row: Sequence[object]) -> Group:
|
|
168
|
+
(
|
|
169
|
+
group_id,
|
|
170
|
+
run_id,
|
|
171
|
+
anchor_uuid,
|
|
172
|
+
order_key,
|
|
173
|
+
shard_index,
|
|
174
|
+
shard_count,
|
|
175
|
+
status,
|
|
176
|
+
serve_count,
|
|
177
|
+
served_at,
|
|
178
|
+
) = row
|
|
179
|
+
return Group(
|
|
180
|
+
group_id=str(group_id),
|
|
181
|
+
run_id=str(run_id),
|
|
182
|
+
anchor_uuid=None if anchor_uuid is None else str(anchor_uuid),
|
|
183
|
+
order_key=str(order_key),
|
|
184
|
+
shard=Shard(index=int(str(shard_index)), of=int(str(shard_count))),
|
|
185
|
+
status=GroupStatus(str(status)),
|
|
186
|
+
serve_count=int(str(serve_count)),
|
|
187
|
+
served_at=None if served_at is None else str(served_at),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def load(db: aiosqlite.Connection, group_id: str) -> Group | None:
|
|
192
|
+
"""One group by id, or `None` if the store holds no such row."""
|
|
193
|
+
rows = await db.execute_fetchall(
|
|
194
|
+
f"SELECT {_GROUP_COLUMNS} FROM consolidation_group WHERE group_id = ?", # noqa: S608 — a source-level column list; the id is bound.
|
|
195
|
+
(group_id,),
|
|
196
|
+
)
|
|
197
|
+
found = list(rows)
|
|
198
|
+
return None if not found else _to_group(tuple(found[0]))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def next_candidate(db: aiosqlite.Connection, run_id: str) -> Group | None:
|
|
202
|
+
"""The next group of `run_id` a serve should consider, in the order `architecture.md` fixes.
|
|
203
|
+
|
|
204
|
+
The earliest `served`-but-incomplete group by `(order_key, shard_index)` first, and only then
|
|
205
|
+
the earliest `pending` one. Re-serving before advancing is the point: a consolidator must not
|
|
206
|
+
be able to walk past a group it found hard simply by calling again.
|
|
207
|
+
|
|
208
|
+
**The two-step ordering is provably equivalent to plain `order_key` ordering on today's state
|
|
209
|
+
machine, and is written as the design states it anyway.** Serving always takes the earliest
|
|
210
|
+
remaining group, and every transition out of the candidate set (`→ complete`, `→ deferred`)
|
|
211
|
+
removes a group rather than returning it to `pending` — so among the groups that are still
|
|
212
|
+
`pending` or `served`, the served ones are always an order-prefix, and the `CASE` cannot change
|
|
213
|
+
which row comes back. That equivalence rests on a reachability argument about the whole state
|
|
214
|
+
machine rather than on anything local, so it is the wrong thing to encode: a future transition
|
|
215
|
+
that reopened a group would silently make skipping possible. Disclosed because it means a
|
|
216
|
+
mutation removing the `CASE` survives the suite, and no fixture can honestly make it fail.
|
|
217
|
+
|
|
218
|
+
`order_key` is `'earliest_created_at|min_uuid'` of the pre-shard subgroup, so ordering by it is
|
|
219
|
+
ordering by D29's step-3 total order's first two components, and `shard_index` is the third —
|
|
220
|
+
which is the only reason a third component is needed at all, since every shard of one subgroup
|
|
221
|
+
carries the same key.
|
|
222
|
+
"""
|
|
223
|
+
rows = await db.execute_fetchall(
|
|
224
|
+
f"SELECT {_GROUP_COLUMNS} FROM consolidation_group " # noqa: S608 — a source-level column list; the id is bound.
|
|
225
|
+
"WHERE run_id = ? AND status IN ('served', 'pending') "
|
|
226
|
+
"ORDER BY CASE status WHEN 'served' THEN 0 ELSE 1 END, order_key ASC, shard_index ASC "
|
|
227
|
+
"LIMIT 1",
|
|
228
|
+
(run_id,),
|
|
229
|
+
)
|
|
230
|
+
found = list(rows)
|
|
231
|
+
return None if not found else _to_group(tuple(found[0]))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def members(db: aiosqlite.Connection, group_id: str) -> tuple[Member, ...]:
|
|
235
|
+
"""Every member of one group — the frozen plan-time universe — in group order."""
|
|
236
|
+
rows = await db.execute_fetchall(
|
|
237
|
+
# Every fragment is a source-level constant; the group id is the one bound value.
|
|
238
|
+
"SELECT mem.memory_uuid, m.created_at, mem.version_seen, mem.version_served, "
|
|
239
|
+
f"mem.disposition {_MEMBER_JOIN} {_MEMBER_ORDER}",
|
|
240
|
+
(group_id,),
|
|
241
|
+
)
|
|
242
|
+
return tuple(
|
|
243
|
+
Member(
|
|
244
|
+
memory_uuid=str(memory_uuid),
|
|
245
|
+
created_at=str(created_at),
|
|
246
|
+
version_seen=int(str(version_seen)),
|
|
247
|
+
version_served=None if version_served is None else int(str(version_served)),
|
|
248
|
+
disposition=None if disposition is None else Disposition(str(disposition)),
|
|
249
|
+
)
|
|
250
|
+
for memory_uuid, created_at, version_seen, version_served, disposition in rows
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def open_member_uuids(db: aiosqlite.Connection, group_id: str) -> tuple[str, ...]:
|
|
255
|
+
"""`remaining_uuids`: this group's members with `disposition IS NULL`, in group order.
|
|
256
|
+
|
|
257
|
+
Read from the member table at response time rather than tracked in memory, because that is
|
|
258
|
+
what the design says it is — and because a write verb dispositions rows inside its own
|
|
259
|
+
transaction, so the value must shrink within one call rather than describe its starting state.
|
|
260
|
+
"""
|
|
261
|
+
rows = await db.execute_fetchall(
|
|
262
|
+
# Every fragment is a source-level constant; the group id is the one bound value.
|
|
263
|
+
f"SELECT mem.memory_uuid {_MEMBER_JOIN} AND mem.disposition IS NULL {_MEMBER_ORDER}",
|
|
264
|
+
(group_id,),
|
|
265
|
+
)
|
|
266
|
+
return tuple(str(memory_uuid) for (memory_uuid,) in rows)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def open_group_count(db: aiosqlite.Connection, run_id: str, *, excluding: str) -> int:
|
|
270
|
+
"""`remaining_groups`: how many **other** groups of this run are still `pending` or `served`.
|
|
271
|
+
|
|
272
|
+
Excludes the group named, because the payload's own contract counts work *not* in the
|
|
273
|
+
consolidator's hands — the delivered group is still open and still has undispositioned members,
|
|
274
|
+
so counting it would make a final group report 1 and leave "is this the last one" unanswerable
|
|
275
|
+
from the payload.
|
|
276
|
+
"""
|
|
277
|
+
rows = await db.execute_fetchall(
|
|
278
|
+
"SELECT count(*) FROM consolidation_group "
|
|
279
|
+
"WHERE run_id = ? AND group_id <> ? AND status IN ('pending', 'served')",
|
|
280
|
+
(run_id, excluding),
|
|
281
|
+
)
|
|
282
|
+
return int(str(next(iter(rows))[0]))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class RunCounts:
|
|
287
|
+
"""The three counts a `consolidate_run` event reports about one run.
|
|
288
|
+
|
|
289
|
+
A value type rather than three integers threaded through, because the event's own contract says
|
|
290
|
+
they describe **the run the transition is about** — and `plan_groups` emits two events about two
|
|
291
|
+
different runs in one call, which is exactly the situation in which three loose integers get
|
|
292
|
+
crossed. It lives with the group aggregate rather than with the run because all three are
|
|
293
|
+
counted over the two group tables, and this module is the only place their SQL may live.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
n_groups: int
|
|
297
|
+
n_members: int
|
|
298
|
+
n_deferred: int
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def run_counts(db: aiosqlite.Connection, run_id: str) -> RunCounts:
|
|
302
|
+
"""One run's group and member totals, and how many of its groups are currently `deferred`.
|
|
303
|
+
|
|
304
|
+
`n_groups` and `n_members` are fixed at plan time and so repeat unchanged on every later phase
|
|
305
|
+
of one run; `n_deferred` is the only one that moves. Read in one statement so all three
|
|
306
|
+
describe the same instant, which matters because they are reported together on one event row.
|
|
307
|
+
"""
|
|
308
|
+
rows = await db.execute_fetchall(
|
|
309
|
+
"""
|
|
310
|
+
SELECT
|
|
311
|
+
(SELECT count(*) FROM consolidation_group WHERE run_id = ?),
|
|
312
|
+
(SELECT count(*) FROM consolidation_group_member m
|
|
313
|
+
JOIN consolidation_group g ON g.group_id = m.group_id
|
|
314
|
+
WHERE g.run_id = ?),
|
|
315
|
+
(SELECT count(*) FROM consolidation_group WHERE run_id = ? AND status = 'deferred')
|
|
316
|
+
""",
|
|
317
|
+
(run_id, run_id, run_id),
|
|
318
|
+
)
|
|
319
|
+
n_groups, n_members, n_deferred = next(iter(rows))
|
|
320
|
+
return RunCounts(
|
|
321
|
+
n_groups=int(str(n_groups)),
|
|
322
|
+
n_members=int(str(n_members)),
|
|
323
|
+
n_deferred=int(str(n_deferred)),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def has_open_groups(db: aiosqlite.Connection, run_id: str) -> bool:
|
|
328
|
+
"""Whether any group of this run is still `pending` or `served`.
|
|
329
|
+
|
|
330
|
+
The condition invariant 17 makes the run's `active → complete` cause: "whichever transaction
|
|
331
|
+
first observes that no group of the run is `pending` or `served`". `deferred` groups do not
|
|
332
|
+
count — they are terminal for the run, and their members simply return to the next plan.
|
|
333
|
+
"""
|
|
334
|
+
rows = await db.execute_fetchall(
|
|
335
|
+
"SELECT EXISTS (SELECT 1 FROM consolidation_group "
|
|
336
|
+
"WHERE run_id = ? AND status IN ('pending', 'served'))",
|
|
337
|
+
(run_id,),
|
|
338
|
+
)
|
|
339
|
+
return bool(next(iter(rows))[0])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def insert(db: aiosqlite.Connection, group: Group) -> None:
|
|
343
|
+
"""Write one planned group. `status` and `serve_count` come from the value, not from a default,
|
|
344
|
+
so a planner that tried to insert an already-served group would be visible here."""
|
|
345
|
+
await db.execute(
|
|
346
|
+
"INSERT INTO consolidation_group "
|
|
347
|
+
"(group_id, run_id, anchor_uuid, order_key, shard_index, shard_count, status, "
|
|
348
|
+
" serve_count, served_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
349
|
+
(
|
|
350
|
+
group.group_id,
|
|
351
|
+
group.run_id,
|
|
352
|
+
group.anchor_uuid,
|
|
353
|
+
group.order_key,
|
|
354
|
+
group.shard.index,
|
|
355
|
+
group.shard.of,
|
|
356
|
+
group.status.value,
|
|
357
|
+
group.serve_count,
|
|
358
|
+
group.served_at,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def insert_member(
|
|
364
|
+
db: aiosqlite.Connection, *, group_id: str, memory_uuid: str, version_seen: int
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Write one planned member: undispositioned, never delivered, at its plan-time version."""
|
|
367
|
+
await db.execute(
|
|
368
|
+
"INSERT INTO consolidation_group_member "
|
|
369
|
+
"(group_id, memory_uuid, version_seen, version_served, disposition, disposed_at) "
|
|
370
|
+
"VALUES (?, ?, ?, NULL, NULL, NULL)",
|
|
371
|
+
(group_id, memory_uuid, version_seen),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def set_status(
|
|
376
|
+
db: aiosqlite.Connection, *, group_id: str, status: GroupStatus, expected: GroupStatus
|
|
377
|
+
) -> bool:
|
|
378
|
+
"""Move one group from `expected` to `status`, and report whether it moved.
|
|
379
|
+
|
|
380
|
+
Guarded on the *source* status rather than only on the id, because every transition in
|
|
381
|
+
invariant 17's table names one, and a guard on the id alone would let a group another path had
|
|
382
|
+
already closed be reopened as `deferred` — which is exactly the "no reachable state left
|
|
383
|
+
without an exit" property the table is asserting.
|
|
384
|
+
"""
|
|
385
|
+
cursor = await db.execute(
|
|
386
|
+
"UPDATE consolidation_group SET status = ? WHERE group_id = ? AND status = ?",
|
|
387
|
+
(status.value, group_id, expected.value),
|
|
388
|
+
)
|
|
389
|
+
return cursor.rowcount > 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def record_delivery(
|
|
393
|
+
db: aiosqlite.Connection, *, group_id: str, serve_count: int, served_at: str
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Set this group to `served` at the given delivery count and timestamp.
|
|
396
|
+
|
|
397
|
+
One statement for both transitions invariant 17 gives a delivery — `pending → served` setting
|
|
398
|
+
`serve_count = 1`, and `served → served` incrementing it — because the counter is what
|
|
399
|
+
distinguishes them and the caller has already computed it. `served_at` is refreshed on **every**
|
|
400
|
+
delivery, so it records the most recent one rather than the first.
|
|
401
|
+
"""
|
|
402
|
+
await db.execute(
|
|
403
|
+
"UPDATE consolidation_group SET status = ?, serve_count = ?, served_at = ? "
|
|
404
|
+
"WHERE group_id = ?",
|
|
405
|
+
(GroupStatus.SERVED.value, serve_count, served_at, group_id),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def disposition_members(
|
|
410
|
+
db: aiosqlite.Connection,
|
|
411
|
+
*,
|
|
412
|
+
group_id: str,
|
|
413
|
+
memory_uuids: Sequence[str],
|
|
414
|
+
disposition: Disposition,
|
|
415
|
+
at: str,
|
|
416
|
+
) -> int:
|
|
417
|
+
"""Mark the named members with `disposition`, and report how many rows actually moved.
|
|
418
|
+
|
|
419
|
+
Guarded on `disposition IS NULL`, so a member some other path already dispositioned cannot be
|
|
420
|
+
overwritten — a `vacated` row must not silently become `merged` — and the returned count is what
|
|
421
|
+
a caller checks that against. `discard`'s `{retired: n}` is this number.
|
|
422
|
+
|
|
423
|
+
**The guard is unobservable through any call, and that is disclosed rather than tested.** Rung 2
|
|
424
|
+
already refuses an `absorb` uuid that is not an *undispositioned* member, and the serve computes
|
|
425
|
+
the vacating set from the same predicate, so no reachable caller reaches this statement with an
|
|
426
|
+
already-dispositioned uuid. It stays because it is one clause and because the rule it enforces
|
|
427
|
+
is the design's, not this function's: an implementation that later relaxed rung 2 would find the
|
|
428
|
+
disposition still protected. Removing it changes no test's outcome, which is exactly the claim
|
|
429
|
+
being made here rather than one a test could make.
|
|
430
|
+
"""
|
|
431
|
+
if not memory_uuids:
|
|
432
|
+
return 0
|
|
433
|
+
placeholders = ", ".join("?" for _ in memory_uuids)
|
|
434
|
+
sql = (
|
|
435
|
+
"UPDATE consolidation_group_member SET disposition = ?, disposed_at = ? " # noqa: S608 — placeholders only; every uuid is a bound parameter.
|
|
436
|
+
f"WHERE group_id = ? AND memory_uuid IN ({placeholders}) AND disposition IS NULL"
|
|
437
|
+
)
|
|
438
|
+
cursor = await db.execute(sql, (disposition.value, at, group_id, *memory_uuids))
|
|
439
|
+
return cursor.rowcount
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
async def record_versions_served(
|
|
443
|
+
db: aiosqlite.Connection, *, group_id: str, versions: Mapping[str, int]
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Record, per member, the version whose prose this serve is delivering.
|
|
446
|
+
|
|
447
|
+
Written for exactly the served set, because that is the set whose prose the payload carries and
|
|
448
|
+
whose receipts are minted at these versions. A member the same serve marked `vacated` delivered
|
|
449
|
+
nothing, so its `version_served` is deliberately left as it was — null if it had never been
|
|
450
|
+
delivered, and its earlier value if it had.
|
|
451
|
+
"""
|
|
452
|
+
for memory_uuid, version in versions.items():
|
|
453
|
+
await db.execute(
|
|
454
|
+
"UPDATE consolidation_group_member SET version_served = ? "
|
|
455
|
+
"WHERE group_id = ? AND memory_uuid = ?",
|
|
456
|
+
(version, group_id, memory_uuid),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def replace_authorization(
|
|
461
|
+
db: aiosqlite.Connection, *, group_id: str, records: Sequence[AuthorizedRecord]
|
|
462
|
+
) -> None:
|
|
463
|
+
"""Rewrite this group's merge authorization set, replacing any previous serve's rows.
|
|
464
|
+
|
|
465
|
+
Deleted and reinserted rather than merged, because the set is recomputed from scratch on every
|
|
466
|
+
serve against a store that has moved: a record that no longer ranks, or an anchor that stopped
|
|
467
|
+
being targetable, must stop being authorized. Leaving a stale row behind would authorize a merge
|
|
468
|
+
into a record this serve did not show.
|
|
469
|
+
"""
|
|
470
|
+
await db.execute("DELETE FROM consolidation_group_candidate WHERE group_id = ?", (group_id,))
|
|
471
|
+
for record in records:
|
|
472
|
+
await db.execute(
|
|
473
|
+
"INSERT INTO consolidation_group_candidate "
|
|
474
|
+
"(group_id, memory_uuid, role, version_served, rank) VALUES (?, ?, ?, ?, ?)",
|
|
475
|
+
(group_id, record.memory_uuid, record.role.value, record.version_served, record.rank),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def authorized_uuids(db: aiosqlite.Connection, group_id: str) -> frozenset[str]:
|
|
480
|
+
"""Every uuid this group's most recent serve authorized a `merge` to target.
|
|
481
|
+
|
|
482
|
+
A set rather than the rows, because that is the whole question rung 2 asks — and the answer must
|
|
483
|
+
carry nothing else: `bad_merge_target`'s payload names a uuid and a reason that does not
|
|
484
|
+
distinguish "does not exist" from "not authorized", so the check cannot become an existence
|
|
485
|
+
oracle for the store.
|
|
486
|
+
"""
|
|
487
|
+
rows = await db.execute_fetchall(
|
|
488
|
+
"SELECT memory_uuid FROM consolidation_group_candidate WHERE group_id = ?", (group_id,)
|
|
489
|
+
)
|
|
490
|
+
return frozenset(str(memory_uuid) for (memory_uuid,) in rows)
|