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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
//! The participation-scoped search index
//! (docs/proposals/participation-scoped-agent-search.md).
//!
//! An agent asked to find something in the conversations its caller took part
//! in cannot replay each one per call: a heavy user's participation set is
//! plausibly thousands of small partitions, because the chat edge derives a
//! conversation id per thread rather than per channel. This module is the
//! maintained projection that makes such a search bounded.
//!
//! # Append-only segments per conversation, in the layout `DataFusion` reads
//!
//! `docs/reference/datafusion-data-layer.md` ("Cold storage: Parquet
//! projections") already settled the storage question: `ListingTable` reads
//! Parquet over an object store with zero custom code, including row-group and
//! page-index statistics pruning and Bloom-filter pruning for high-selectivity
//! equality predicates, and the per-conversation layout means a session's
//! catalog registers only the conversations it is already authorized for.
//!
//! So the projection is a directory per conversation holding a row per
//! (committed message, distinct term), written in the layout a `ListingTable`
//! reads: Hive-style `conversation_id=` directories, a Bloom filter and sorted
//! page statistics on `term_hash`. A search will prune on `term_hash` equality
//! natively and open only the files that survive — turning "replay every
//! partition" into "prune most, scan few".
//!
//! Within that directory a publish APPENDS a segment covering only the
//! positions it just indexed, rather than rewriting one file per conversation.
//! A rewrite costs O(N) rows per committed turn and therefore O(N²) over a
//! conversation's life — the same quadratic the Parquet layout was chosen to
//! escape, reintroduced one level down. `ListingTable` reads every segment in
//! the directory as one table, so the read side is unchanged, and
//! [`store::SearchProjection::compact`] folds the segments back to one once
//! either compaction trigger fires (see [`worker`]).
//!
//! That read path is the search port, a later change in this stack. This
//! module writes the layout and reads whole files; see
//! [`store`]'s own doc for exactly what prunes today and what does not.
//!
//! An earlier draft built a bespoke key-value store here, on the stated
//! grounds that "there is no range-queryable durable store in this
//! workspace." That was wrong, and every mechanism it hand-rolled has a
//! native counterpart: a membership filter is a Bloom filter, a postings
//! record is columnar rows, a delta-varint codec is Parquet's own encoding,
//! and candidate ordering is query planning.
//!
//! # Why document frequency is load-bearing here
//!
//! The live lexical core admits an entry at term overlap >= 1 and performs no
//! stopword removal. A natural-language query — "where did we decide the
//! timeout" — therefore contains terms present in essentially every
//! conversation, so an unfiltered membership test passes the ENTIRE scope and
//! the mechanism collapses back into the per-call replay it exists to avoid.
//! The governing variable is term document frequency, not the filter's
//! false-positive rate. [`SearchIndexConfig::max_term_document_frequency`] is
//! what bounds it, and it is runtime configuration rather than a constant
//! because no corpus exists to calibrate it against before launch.
//!
//! # Derived, never authoritative
//!
//! Every file here is reconstructible from the event log alone, and one that
//! disagrees with its journal must be discarded rather than trusted. The
//! footer carries what makes that checkable — the source incarnation, the term
//! key's identity, and the format version. The key and format are verified on
//! every coverage read; the incarnation is verified against the live journal by
//! [`store::SearchProjection::verified_coverage`], which is the read a search
//! must go through and the reason this design needs no startup barrier.
//!
//! # What a Container wires, and what is still missing
//!
//! [`SearchIndex`] is this module's whole outward surface: it opens the
//! projection, hands back the [`marks::CommitMarks`] handle the Container
//! drives from the durable commit feed, and owns the [`worker`] loop the
//! Container supervises. The three go together by construction — a marks
//! handle driven without a running worker fills a dirty set nothing drains,
//! and a worker without a reconcile can never clear the degraded flag that
//! overflow sets (see [`marks::CommitMarks`]'s own doc).
//!
//! The `SearchSnapshot` port that READS what the worker publishes lands in a
//! later change, so the index is maintained but not yet queried.
pub
pub
pub
pub
pub
use PathBuf;
use Arc;
use cratePartitionJournal;
use CancellationToken;
/// Raised when the search index's projection cannot be opened.
///
/// Carries the store's failure as text rather than the `pub(crate)` store
/// error itself: the on-disk layout is this module's business, and a Container
/// can only ever log this and refuse to start.
/// Where a Container's term key came from.
///
/// Not a detail the index can shrug at. Every stored term hash is computed under
/// the key, and the footer records the key's identity — so a key the Container
/// MINTED rather than read back invalidates every segment in the projection at
/// once. Detection already worked: coverage under a foreign key identity reads
/// as unreadable. Repair did not. Nothing sweeps on a key change, so a
/// conversation that never commits another turn keeps its orphaned segments and
/// its participants' search refuses forever, with no degraded flag and therefore
/// no reconcile to notice.
///
/// The mint is not hypothetical. A dev custody directory on a non-persistent
/// path loses its keys on every reschedule, and a Kubernetes Secret that is
/// deleted or missing its data key reads as absent.
/// The maintained search index, ready for a Container to register and
/// supervise.
///
/// # Take the marks handle, then supervise the worker
///
/// [`SearchIndex::marks`] borrows and [`SearchIndex::run`] consumes, so the
/// only order the two can be called in is the correct one: start driving the
/// handle from the commit feed first, so no commit lands unmarked, then hand
/// the loop to the task set that notices when it stops. Both sides share one
/// dirty set, so there is no way to fill a different set from the one the
/// worker drains.
///
/// What the types do NOT prevent is taking the handle and dropping the index:
/// driving that handle leaves a set nothing empties, which fills to its bound
/// and then refuses every search. The Container is what pairs them, and this
/// is stated rather than claimed away.
///
/// The worker is a plain future rather than something that spawns itself. A
/// perpetual loop belongs in the Container's supervised set, where a task that
/// dies takes the process down with it — a detached spawn would leave the
/// index silently frozen behind a watermark that keeps claiming coverage.
///
/// # Everything environmental arrives from the Container
///
/// This crate is a Component and reads no configuration of its own: the
/// projection root, the per-deployment term key, and the host to replay
/// through are all constructor arguments.
/// Tunables that govern how much work a search may do.
///
/// Both bounds are runtime configuration rather than constants, deliberately.
/// The deployment has no participation corpus to calibrate them against before
/// launch, so the honest position is that the first real numbers arrive from
/// production — `partitions_read_per_search` is the metric that supplies them
/// — and tuning must then be a config change rather than a rebuild of the
/// layer that is hardest to rebase.
pub
/// Where a conversation's matching terms actually are.
pub
/// One committed message's searchable terms. Carries no message text — the
/// text is read back from the journal when a hit is actually fetched.
pub