brainiac-cli 0.16.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.
Files changed (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/index.py ADDED
@@ -0,0 +1,1576 @@
1
+ """The derived SQLite index: FTS5 (lexical) + a vector backend (semantic).
2
+
3
+ Single file (default under app-data, see config.py). Derived and DISPOSABLE —
4
+ ``rebuild`` drops and recreates everything from the Markdown in ``vault/``, so
5
+ deleting the file is always safe. Retrieval depends on the vector ADAPTER
6
+ (brain.vectors.VectorBackend), never on sqlite-vec directly.
7
+
8
+ S03 added:
9
+ * **Chunk-level vectors (IDX-02).** Notes are split into section/block chunks
10
+ (``brain.chunk``); each chunk is embedded with an in-language contextual
11
+ prefix. The vector backend is keyed by *chunk* rowid; a semantic hit maps
12
+ back to its note and we keep the best chunk per note.
13
+ * **Incremental sync (IDX-03).** ``sync`` re-indexes only notes whose
14
+ path+content-hash changed and propagates deletes — no full rebuild.
15
+ * **Model-change guard (IDX-01).** ``embed_model`` + ``embed_dim`` are stored
16
+ in ``meta``; a mismatch on ``sync`` forces a clean rebuild (Arctic vectors
17
+ must never be mixed with HashEmbedder vectors).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import datetime as _dt
22
+ import hashlib
23
+ import os
24
+ import re
25
+ import sqlite3
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from . import config, frontmatter
31
+ from . import classification as cls_mod
32
+ from .chunk import chunk_text
33
+ from .embed import Embedder, get_embedder
34
+ from .notes import Note, scan_vault
35
+ from .vectors import SqliteVecBackend, VectorBackend, get_backend
36
+
37
+ # Optional 3rd-party regex engine (mrab-regex) with a REAL per-call match
38
+ # timeout -- unlike stdlib `re`, `regex.search(text, timeout=...)` genuinely
39
+ # bounds catastrophic backtracking (confirmed: stdlib re on r'(a+)+$' vs 30
40
+ # 'a's takes ~47s; the `regex` module's timeout on the identical input returns
41
+ # in well under a millisecond). Declared as the optional `index` extra in
42
+ # pyproject.toml; falls back to stdlib `re` when not installed, in which case
43
+ # the pattern-length cap below is the sole ReDoS mitigation (documented
44
+ # residual risk: docs/SECURITY_NOTES.md).
45
+ try:
46
+ import regex as _grep_engine
47
+ _GREP_HAS_TIMEOUT = True
48
+ except ImportError: # pragma: no cover - exercised only when `regex` is absent
49
+ _grep_engine = re
50
+ _GREP_HAS_TIMEOUT = False
51
+
52
+ # ReDoS / resource-exhaustion guard for BrainIndex.grep (RET-04 hardening).
53
+ MAX_GREP_PATTERN_LEN = 200 # absurdly long patterns are the abuse surface, not legitimate use
54
+ GREP_REGEX_TIMEOUT_S = 2.0 # per-match wall-clock budget (only enforced with the `regex` engine)
55
+
56
+
57
+ def _today() -> _dt.date:
58
+ """Today, overridable via ``BRAIN_NOW=YYYY-MM-DD`` so recency ranking is
59
+ deterministic in tests (mirrors the injectable-clock pattern used by
60
+ maintenance staleness)."""
61
+ v = os.environ.get("BRAIN_NOW", "").strip()
62
+ if v:
63
+ try:
64
+ return _dt.date.fromisoformat(v[:10])
65
+ except ValueError:
66
+ pass
67
+ return _dt.date.today()
68
+
69
+
70
+ def _env_float(name: str, default: float) -> float:
71
+ try:
72
+ return float(os.environ[name])
73
+ except (KeyError, ValueError):
74
+ return default
75
+
76
+
77
+ # Temporal-intent detector for query-aware recency weighting (EN + PT — the
78
+ # reference corpus is bilingual). Deliberately coarse: false positives only
79
+ # strengthen a gentle, bounded prior; false negatives fall back to the default.
80
+ _TEMPORAL_INTENT_RE = re.compile(
81
+ r"\b(latest|newest|current(?:ly)?|recent(?:ly)?|as of|today|now|up[- ]to[- ]date|"
82
+ r"this (?:week|month|quarter|year)|"
83
+ r"atual(?:mente)?|mais recentes?|últim[oa]s?|recentes?|hoje|"
84
+ r"est[ae] (?:semana|mês|trimestre|ano))\b", re.IGNORECASE)
85
+
86
+
87
+ def _recency_factor(date_str: str, today: _dt.date, weight: float,
88
+ half_life: float) -> float:
89
+ """Gentle multiplicative STALENESS PENALTY for the RRF fusion, bounded to
90
+ ``(1 - weight, 1.0]``. A note dated today (or in the future) is neutral at
91
+ ``1.0``; the penalty deepens as the note ages, halving its distance-from-full
92
+ every ``half_life`` days, asymptoting at ``1 - weight`` for very old notes.
93
+ An undated note (or ``weight<=0``) is neutral at ``1.0`` — undated notes are
94
+ never penalised.
95
+
96
+ A *penalty* (≤1), not a boost (>1), so the fused score never exceeds the RRF
97
+ ceiling ``2/(rrf_k+1)`` — the fusion-scale invariant the zone-authority prior
98
+ also respects. Relative order between any two DATED notes is identical to a
99
+ symmetric boost, so the newer of two topically-similar hits still wins."""
100
+ if weight <= 0 or not date_str:
101
+ return 1.0
102
+ try:
103
+ d = _dt.date.fromisoformat(date_str[:10])
104
+ except ValueError:
105
+ return 1.0
106
+ age = (today - d).days
107
+ if age <= 0:
108
+ return 1.0
109
+ return 1.0 - weight * (1.0 - 0.5 ** (age / half_life))
110
+
111
+
112
+ class GrepPatternError(ValueError):
113
+ """A user-supplied grep pattern was rejected before compilation."""
114
+
115
+
116
+ def _grep_bounded_search(compiled, text: str):
117
+ """``compiled.search(text)`` with a wall-clock budget when the `regex`
118
+ engine is available. A pathological pattern degrades to "no match on this
119
+ line" (never raises out of `grep`) rather than hanging the whole call."""
120
+ if _GREP_HAS_TIMEOUT:
121
+ try:
122
+ return compiled.search(text, timeout=GREP_REGEX_TIMEOUT_S)
123
+ except TimeoutError:
124
+ return None
125
+ return compiled.search(text)
126
+
127
+
128
+ SCHEMA_VERSION = 3 # TMP-02: bitemporal columns added (notes gain 6 new cols).
129
+ # Migration-safe by construction: sync()'s _schema_ready()
130
+ # check already forces a rebuild() on any version mismatch —
131
+ # no separate ALTER-TABLE migration path is needed.
132
+
133
+
134
+ @dataclass
135
+ class Hit:
136
+ id: str
137
+ title: str
138
+ classification: str
139
+ zone: str
140
+ path: str
141
+ score: float
142
+ source: str # "lexical" | "semantic" | "both"
143
+ snippet: str = ""
144
+ is_latest_version: str = "" # TMP-02: "true"|"false"|"" — post-egress field,
145
+ # never consulted by the classification gate.
146
+ date: str = "" # valid-time date (effective_date → document_date → created)
147
+ # — lets an agent see at a glance HOW CURRENT each hit is.
148
+ type: str = "" # note type (decision|source|note|…) — authority signal:
149
+ # a `decision` hit IS the decision layer; a `source` hit is
150
+ # material under consideration (2026-07-11: an agent
151
+ # promoted a draft memo's scenario into a "decision"
152
+ # because the ranked list didn't show which was which).
153
+
154
+ def to_dict(self) -> dict[str, Any]:
155
+ return {
156
+ "id": self.id,
157
+ "title": self.title,
158
+ "classification": self.classification,
159
+ "zone": self.zone,
160
+ "path": self.path,
161
+ "score": round(self.score, 6),
162
+ "source": self.source,
163
+ "snippet": self.snippet,
164
+ "is_latest_version": self.is_latest_version,
165
+ "date": self.date,
166
+ "type": self.type,
167
+ }
168
+
169
+
170
+ @dataclass
171
+ class _NotePlan:
172
+ """A note's planned index rows (chunking/prefix/dedup done) BEFORE embedding.
173
+
174
+ Decouples planning from writing so ``rebuild`` can bulk-embed every note's
175
+ chunk inputs in one batched call (the S11 indexing speed fix) instead of one
176
+ tiny embed per note."""
177
+
178
+ note_rowid: int
179
+ row: dict[str, Any]
180
+ chunks: list[Any]
181
+ inputs: list[str]
182
+
183
+
184
+ class BrainIndex:
185
+ def __init__(
186
+ self,
187
+ db_path: Path | None = None,
188
+ backend: VectorBackend | None = None,
189
+ embedder: Embedder | None = None,
190
+ *,
191
+ read_only: bool = False,
192
+ ) -> None:
193
+ self.db_path = Path(db_path) if db_path else config.index_path()
194
+ self.backend: VectorBackend = backend or get_backend("auto")
195
+ # $BRAIN_EMBEDDER overrides embedder selection (auto|hash|arctic|catalog);
196
+ # default auto. CI + air-gapped validation force "hash" (offline, no model
197
+ # download), per get_embedder's contract — same one-line swap tests use.
198
+ # None ⇒ resolved LAZILY on first use (the `embedder` property below):
199
+ # constructing the index must never die on a missing embedder, or
200
+ # DV-03's fail-closed ($BRAIN_REQUIRE_REAL_EMBEDDER, defaulted on the
201
+ # VM leg) bites verbs that never embed — capture/draft-capture, grep,
202
+ # bases-query — instead of only the semantic path as documented.
203
+ self._embedder: Embedder | None = embedder
204
+ # Cache the reranker on the index instance so the ONNX session is loaded
205
+ # ONCE, not on every _apply_rerank call. Without this, qwen3-embed's
206
+ # TextCrossEncoder reloads the 573MB ONNX model per query (S11 finding),
207
+ # making rerank-bound eval pathologically slow. The cache is keyed on the
208
+ # resolved model id so a mid-session BRAIN_RERANKER_MODEL change is honoured.
209
+ self._reranker_cache: tuple[str, Any] | None = None
210
+ # Multi-hop retrieval (RET-06) caches: the wikilink graph and the entity
211
+ # lexicon are both derived from the immutable ``notes`` table, so build
212
+ # them once per index lifetime (not per query). None until first use.
213
+ self._link_graph: Any | None = None
214
+ self._entity_lex: Any | None = None
215
+ # read_only is the VM-leg posture (S06): the connection is opened
216
+ # ``mode=ro`` so the engine CANNOT open WAL or mutate the index. Any
217
+ # write raises ``sqlite3.OperationalError`` (attempt to write a readonly
218
+ # database) and no ``-wal``/``-shm`` sidecar is ever created.
219
+ self.read_only = read_only
220
+ self._conn: sqlite3.Connection | None = None
221
+
222
+ @property
223
+ def embedder(self) -> Embedder:
224
+ """The query/index embedder, resolved on FIRST USE, not construction.
225
+
226
+ Only the paths that actually embed (search/hybrid-search, rebuild/sync,
227
+ near-dup scoring) ever touch this — so under DV-03's fail-closed policy
228
+ an EmbedderUnavailable raises exactly where the semantic contract is
229
+ exercised, and lexical/draft verbs keep working on a machine with no
230
+ real embedder (the documented DV-03 scope)."""
231
+ if self._embedder is None:
232
+ self._embedder = get_embedder(os.environ.get("BRAIN_EMBEDDER", "auto"))
233
+ return self._embedder
234
+
235
+ # -- connection -------------------------------------------------------
236
+ @property
237
+ def conn(self) -> sqlite3.Connection:
238
+ if self._conn is None:
239
+ if self.read_only:
240
+ # Open the (snapshot) DB strictly read-only. mode=ro means SQLite
241
+ # will not create the file, will not open a write journal/WAL, and
242
+ # fails any write — the VM-leg guarantee enforced at the engine.
243
+ uri = f"file:{self.db_path}?mode=ro"
244
+ self._conn = sqlite3.connect(uri, uri=True)
245
+ if isinstance(self.backend, SqliteVecBackend):
246
+ try:
247
+ self.backend.load_into(self._conn)
248
+ except Exception:
249
+ pass
250
+ # Belt-and-suspenders: forbid writes at the connection level too.
251
+ try:
252
+ self._conn.execute("PRAGMA query_only=ON")
253
+ except sqlite3.OperationalError:
254
+ pass
255
+ return self._conn
256
+ is_file_backed = self.db_path != Path(":memory:")
257
+ if is_file_backed:
258
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
259
+ self._conn = sqlite3.connect(str(self.db_path))
260
+ if is_file_backed and self.db_path.exists():
261
+ # sqlite3.connect() creates the file (when absent) with the
262
+ # process umask -- often 0o644 / world-readable on a typical
263
+ # single-user default. The index can hold note bodies up to and
264
+ # including MNPI-tier content (the classification gate is an
265
+ # egress *decision*, not containment), so tighten to owner-only
266
+ # immediately, regardless of umask.
267
+ config.secure_file_permissions(self.db_path)
268
+ # sqlite-vec needs its extension loaded on EVERY connection.
269
+ if isinstance(self.backend, SqliteVecBackend):
270
+ try:
271
+ self.backend.load_into(self._conn)
272
+ except Exception:
273
+ pass
274
+ self._conn.execute("PRAGMA journal_mode=WAL")
275
+ # M-6: without a busy_timeout, a concurrent writer's first INSERT
276
+ # raises "database is locked" immediately instead of waiting for
277
+ # the other writer's transaction to finish. 5s covers a normal
278
+ # sync; a still-locked DB past that surfaces as a real error.
279
+ self._conn.execute("PRAGMA busy_timeout=5000")
280
+ if is_file_backed:
281
+ # WAL mode creates -wal/-shm sidecars on first write; make sure
282
+ # those inherit the same owner-only posture too (they can carry
283
+ # the same sensitive content as the main DB file).
284
+ for suffix in ("-wal", "-shm"):
285
+ side = Path(str(self.db_path) + suffix)
286
+ if side.exists():
287
+ config.secure_file_permissions(side)
288
+ return self._conn
289
+
290
+ def close(self) -> None:
291
+ if self._conn is not None:
292
+ self._conn.close()
293
+ self._conn = None
294
+
295
+ # -- schema -----------------------------------------------------------
296
+ def _create_schema(self) -> None:
297
+ c = self.conn
298
+ c.execute("DROP TABLE IF EXISTS notes")
299
+ c.execute("DROP TABLE IF EXISTS notes_fts")
300
+ c.execute("DROP TABLE IF EXISTS chunks")
301
+ c.execute("DROP TABLE IF EXISTS meta")
302
+ c.execute(
303
+ """CREATE TABLE notes (
304
+ rowid INTEGER PRIMARY KEY,
305
+ id TEXT UNIQUE, title TEXT, type TEXT,
306
+ classification TEXT, zone TEXT, path TEXT UNIQUE,
307
+ created TEXT, updated TEXT, sha256 TEXT, content_hash TEXT, body TEXT,
308
+ document_date TEXT, effective_date TEXT, superseded_date TEXT,
309
+ is_latest_version TEXT, superseded_by TEXT, previous_version TEXT
310
+ )"""
311
+ )
312
+ # Plain (non-contentless) fts5 so incremental DELETE WHERE rowid works.
313
+ c.execute("CREATE VIRTUAL TABLE notes_fts USING fts5(id, title, body)")
314
+ c.execute(
315
+ """CREATE TABLE chunks (
316
+ rowid INTEGER PRIMARY KEY,
317
+ note_rowid INTEGER NOT NULL,
318
+ ordinal INTEGER, heading TEXT, lang TEXT, text TEXT
319
+ )"""
320
+ )
321
+ c.execute("CREATE INDEX idx_chunks_note ON chunks(note_rowid)")
322
+ c.execute("CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT)")
323
+ self._set_meta("schema_version", str(SCHEMA_VERSION))
324
+ self._set_meta("vector_backend", self.backend.name)
325
+ self._set_meta("embed_model", self.embedder.model_id)
326
+ self._set_meta("embed_dim", str(self.embedder.dim))
327
+ self.backend.setup(c, self.embedder.dim)
328
+
329
+ def _set_meta(self, k: str, v: str) -> None:
330
+ self.conn.execute("INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)", (k, v))
331
+
332
+ def get_meta(self, k: str) -> str | None:
333
+ try:
334
+ r = self.conn.execute("SELECT v FROM meta WHERE k=?", (k,)).fetchone()
335
+ except sqlite3.OperationalError:
336
+ return None
337
+ return r[0] if r else None
338
+
339
+ def _schema_ready(self) -> bool:
340
+ return self.get_meta("schema_version") == str(SCHEMA_VERSION)
341
+
342
+ def model_matches(self) -> bool:
343
+ """True iff the stored embed_model/dim match the current embedder."""
344
+ return (
345
+ self.get_meta("embed_model") == self.embedder.model_id
346
+ and self.get_meta("embed_dim") == str(self.embedder.dim)
347
+ )
348
+
349
+ # -- insertion --------------------------------------------------------
350
+ def _next_rowid(self, table: str) -> int:
351
+ # FALSE POSITIVE (scanner: string-built SQL / hardcoded_sql_expressions):
352
+ # `table` is never user input -- it is a hardcoded literal ("chunks" /
353
+ # "notes") at both call sites below, never derived from a request
354
+ # argument. See docs/SECURITY_NOTES.md.
355
+ r = self.conn.execute(f"SELECT COALESCE(MAX(rowid), 0) FROM {table}").fetchone() # nosec B608
356
+ return int(r[0]) + 1
357
+
358
+ def _plan_note(self, note: Note, note_rowid: int) -> _NotePlan:
359
+ """Plan one note's index rows WITHOUT embedding or DB writes.
360
+
361
+ Splits the chunking/context-prefix/dedup work out of the write path so
362
+ ``rebuild`` can collect EVERY note's embed inputs first and embed them
363
+ in ONE bulk batched call (the S11 indexing speed fix — see ``rebuild``)
364
+ instead of ~one tiny embed per note. ``sync`` keeps the per-note path
365
+ (``_insert_note``), where only a handful of notes re-embed."""
366
+ row = note.to_row()
367
+ # Real-corpus robustness: a foreign vault has many frontmatter-bearing
368
+ # notes whose id falls back to a non-unique stem (e.g. dozens of
369
+ # SKILL.md / _index.md). notes.id is UNIQUE, so disambiguate a colliding
370
+ # id with a short path hash. Brain-native notes carry unique explicit
371
+ # ids, so this never fires for them (in-process tests are unaffected).
372
+ # Retrieval keys on path (Hit.path), so the synthetic id is internal only.
373
+ seen = getattr(self, "_seen_ids", None)
374
+ if seen is not None:
375
+ if row["id"] in seen:
376
+ # FALSE POSITIVE (scanner: weak-hash / hashlib-insecure-functions):
377
+ # SHA1 here is a non-security content-addressed de-dup suffix (a
378
+ # short, stable disambiguator for a colliding synthetic id), not a
379
+ # security boundary -- collision resistance / preimage resistance
380
+ # don't matter for this use. See docs/SECURITY_NOTES.md.
381
+ # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1
382
+ row["id"] = f"{row['id']}__{hashlib.sha1(row['path'].encode()).hexdigest()[:8]}" # nosec B303 B324
383
+ seen.add(row["id"])
384
+ chunks = chunk_text(note.body)
385
+ if not chunks:
386
+ # A note with an empty body still gets one chunk (the title) so it is
387
+ # retrievable semantically.
388
+ from .chunk import Chunk, detect_language
389
+
390
+ chunks = [Chunk(0, "", note.title or note.id, detect_language(note.title))]
391
+ # UPG-04 Contextual Retrieval: generate a per-note doc-context once and
392
+ # prepend it to every chunk. Inert (returns "") when no LLM is configured
393
+ # ($BRAIN_CONTEXTUAL_LLM unset) — degrades cleanly to the S10 path.
394
+ from .context import doc_context as _doc_context
395
+
396
+ dctx = _doc_context(note.title or note.id, note.zone, note.body)
397
+ inputs = [ch.embed_input(note.title, note.zone, dctx) for ch in chunks]
398
+ return _NotePlan(note_rowid=note_rowid, row=row, chunks=chunks, inputs=inputs)
399
+
400
+ def _write_planned(
401
+ self, plan: "_NotePlan", vecs: list[list[float]], chunk_rowid: int
402
+ ) -> int:
403
+ """Write a planned note + its FTS row + its chunks (with vectors).
404
+
405
+ ``vecs`` must be aligned 1:1 with ``plan.inputs``/``plan.chunks``. Pure
406
+ DB writes — no embedding (the bulk ``rebuild`` path embeds everything up
407
+ front). Returns the next free chunk rowid."""
408
+ c = self.conn
409
+ row = plan.row
410
+ c.execute(
411
+ "INSERT INTO notes(rowid, id, title, type, classification, zone, path,"
412
+ " created, updated, sha256, content_hash, body, document_date,"
413
+ " effective_date, superseded_date, is_latest_version, superseded_by,"
414
+ " previous_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
415
+ (
416
+ plan.note_rowid, row["id"], row["title"], row["type"], row["classification"],
417
+ row["zone"], row["path"], row["created"], row["updated"],
418
+ row["sha256"], row["content_hash"], row["body"],
419
+ row.get("document_date", ""), row.get("effective_date", ""),
420
+ row.get("superseded_date", ""), row.get("is_latest_version", ""),
421
+ row.get("superseded_by", ""), row.get("previous_version", ""),
422
+ ),
423
+ )
424
+ c.execute(
425
+ "INSERT INTO notes_fts(rowid, id, title, body) VALUES (?,?,?,?)",
426
+ (plan.note_rowid, row["id"], row["title"], row["body"]),
427
+ )
428
+ for ch, vec in zip(plan.chunks, vecs):
429
+ c.execute(
430
+ "INSERT INTO chunks(rowid, note_rowid, ordinal, heading, lang, text)"
431
+ " VALUES (?,?,?,?,?,?)",
432
+ (chunk_rowid, plan.note_rowid, ch.ordinal, ch.heading, ch.lang, ch.text),
433
+ )
434
+ self.backend.upsert(c, chunk_rowid, vec)
435
+ chunk_rowid += 1
436
+ return chunk_rowid
437
+
438
+ def _insert_note(self, note: Note, note_rowid: int, chunk_rowid: int) -> int:
439
+ """Plan + embed + write ONE note (the incremental ``sync`` path).
440
+
441
+ ``rebuild`` does NOT use this — it plans every note first, then embeds
442
+ all inputs in one bulk batched call (see ``rebuild``), which is the S11
443
+ indexing speed fix. Returns the next free chunk rowid."""
444
+ plan = self._plan_note(note, note_rowid)
445
+ vecs = self.embedder.embed_batch(plan.inputs, is_query=False)
446
+ return self._write_planned(plan, vecs, chunk_rowid)
447
+
448
+ def _delete_note(self, note_rowid: int) -> None:
449
+ c = self.conn
450
+ for (crid,) in c.execute(
451
+ "SELECT rowid FROM chunks WHERE note_rowid=?", (note_rowid,)
452
+ ).fetchall():
453
+ self.backend.delete(c, int(crid))
454
+ c.execute("DELETE FROM chunks WHERE note_rowid=?", (note_rowid,))
455
+ c.execute("DELETE FROM notes_fts WHERE rowid=?", (note_rowid,))
456
+ c.execute("DELETE FROM notes WHERE rowid=?", (note_rowid,))
457
+
458
+ # -- build (full) -----------------------------------------------------
459
+ def rebuild(self, vault: Path) -> dict[str, Any]:
460
+ """Drop and rebuild the entire index from vault/. Always safe.
461
+
462
+ S11 indexing speed fix: chunking / embedding / writing are now THREE
463
+ separate passes. Previously ``_insert_note`` was called once per note
464
+ and embedded only that note's ~2-10 chunks, so the ONNX session ran one
465
+ tiny forward pass per note (~2254 for the real vault) that badly
466
+ under-used the batch dimension and the intra-op threads. Now every note
467
+ is planned (chunked + prefixed) first, ALL chunk inputs are embedded in
468
+ ONE bulk batched call (fastembed batches internally — default 256 — and
469
+ saturates the cores), and only then are the rows + vectors written.
470
+ Same vectors, same retrieval — just far fewer, much larger forward
471
+ passes."""
472
+ self._create_schema()
473
+ self._seen_ids: set[str] = set() # collision-only id dedup (real corpora)
474
+ # Pass 1: plan every note (chunk + contextual prefix + id dedup) — no embedding.
475
+ plans: list[_NotePlan] = [
476
+ self._plan_note(note, i)
477
+ for i, note in enumerate(scan_vault(vault), start=1)
478
+ ]
479
+ # Pass 2: embed ALL chunk inputs in ONE bulk batched call.
480
+ all_inputs = [inp for p in plans for inp in p.inputs]
481
+ all_vecs = (
482
+ self.embedder.embed_batch(all_inputs, is_query=False) if all_inputs else []
483
+ )
484
+ # Pass 3: write notes + FTS + chunks + vectors.
485
+ chunk_rowid = 1
486
+ vi = 0
487
+ for p in plans:
488
+ nch = len(p.inputs)
489
+ chunk_rowid = self._write_planned(p, all_vecs[vi : vi + nch], chunk_rowid)
490
+ vi += nch
491
+ self.conn.commit()
492
+ self._seen_ids = None # scope dedup strictly to the rebuild loop
493
+ return {
494
+ "indexed": len(plans),
495
+ "chunks": chunk_rowid - 1,
496
+ "backend": self.backend.name,
497
+ "embed_model": self.embedder.model_id,
498
+ "embed_dim": self.embedder.dim,
499
+ "db": str(self.db_path),
500
+ }
501
+
502
+ # -- build (incremental, IDX-03) -------------------------------------
503
+ def sync(self, vault: Path) -> dict[str, Any]:
504
+ """Incrementally reconcile the index with vault/ by path + content-hash.
505
+
506
+ Only changed/new notes are re-indexed; notes whose file vanished are
507
+ deleted (delete-propagation). A schema or embed-model mismatch forces a
508
+ clean rebuild (mixing model vectors would corrupt retrieval)."""
509
+ if not self._schema_ready():
510
+ res = self.rebuild(vault)
511
+ res["mode"] = "rebuild(no-schema)"
512
+ return res
513
+ if not self.model_matches():
514
+ res = self.rebuild(vault)
515
+ res["mode"] = "rebuild(model-change)"
516
+ return res
517
+
518
+ c = self.conn
519
+ # Current on-disk state: path -> Note.
520
+ on_disk: dict[str, Note] = {}
521
+ for note in scan_vault(vault):
522
+ on_disk[note.path.as_posix()] = note
523
+ # Indexed state: path -> (note_rowid, content_hash).
524
+ indexed: dict[str, tuple[int, str]] = {
525
+ r[0]: (int(r[1]), r[2] or "")
526
+ for r in c.execute("SELECT path, rowid, content_hash FROM notes").fetchall()
527
+ }
528
+
529
+ added = updated = unchanged = deleted = 0
530
+ chunk_rowid = self._next_rowid("chunks")
531
+
532
+ # Delete-propagation FIRST (H-2): a renamed/moved note keeps its id but
533
+ # gets a new path, landing in both "path not in on_disk" (old path,
534
+ # deleted below) and "path not in indexed" (new path, inserted above).
535
+ # If we insert before deleting, the new-path insert can collide with
536
+ # the still-present old-path row on the UNIQUE `id` column. Deleting
537
+ # every stale path (by path, and belt-and-suspenders by id collision)
538
+ # before the insert/update pass makes rename-with-same-id a no-crash,
539
+ # normal reconcile.
540
+ for path, (note_rowid, _h) in indexed.items():
541
+ if path not in on_disk:
542
+ self._delete_note(note_rowid)
543
+ deleted += 1
544
+
545
+ # Re-fetch indexed ids after the deletion pass above so a same-id
546
+ # rename never hits "UNIQUE constraint failed: notes.id".
547
+ indexed_ids: dict[str, int] = {
548
+ r[0]: int(r[1])
549
+ for r in c.execute("SELECT id, rowid FROM notes").fetchall()
550
+ }
551
+
552
+ for path, note in on_disk.items():
553
+ if path not in indexed:
554
+ if note.id in indexed_ids:
555
+ self._delete_note(indexed_ids[note.id])
556
+ del indexed_ids[note.id]
557
+ note_rowid = self._next_rowid("notes")
558
+ chunk_rowid = self._insert_note(note, note_rowid, chunk_rowid)
559
+ added += 1
560
+ elif indexed[path][1] != note.content_hash:
561
+ old_rowid = indexed[path][0]
562
+ self._delete_note(old_rowid)
563
+ # reuse the old note_rowid for stability
564
+ chunk_rowid = self._insert_note(note, old_rowid, chunk_rowid)
565
+ updated += 1
566
+ else:
567
+ unchanged += 1
568
+
569
+ self.conn.commit()
570
+ total_chunks = int(
571
+ c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
572
+ )
573
+ return {
574
+ "mode": "incremental",
575
+ "added": added,
576
+ "updated": updated,
577
+ "unchanged": unchanged,
578
+ "deleted": deleted,
579
+ "indexed": added + updated + unchanged,
580
+ "chunks": total_chunks,
581
+ "backend": self.backend.name,
582
+ "embed_model": self.embedder.model_id,
583
+ "embed_dim": self.embedder.dim,
584
+ "db": str(self.db_path),
585
+ }
586
+
587
+ # -- retrieval --------------------------------------------------------
588
+ def _note_row(self, rowid: int) -> dict[str, Any] | None:
589
+ r = self.conn.execute(
590
+ "SELECT id,title,classification,zone,path,body,is_latest_version,type"
591
+ " FROM notes WHERE rowid=?",
592
+ (rowid,),
593
+ ).fetchone()
594
+ if not r:
595
+ return None
596
+ return {
597
+ "id": r[0], "title": r[1], "classification": r[2],
598
+ "zone": r[3], "path": r[4], "body": r[5], "is_latest_version": r[6] or "",
599
+ "type": r[7] or "",
600
+ }
601
+
602
+ @staticmethod
603
+ def _snippet(body: str, n: int = 160) -> str:
604
+ s = " ".join(body.split())
605
+ return s[:n] + ("…" if len(s) > n else "")
606
+
607
+ # Default typed-zone authority weights (anti-burial).
608
+ # PT-02 (s05): curated_weight=2.0 / meetings_damp=0.55 is the CV-SELECTED
609
+ # candidate from a 5x4=20-point (curated boost x "40 Meetings" damp) grid,
610
+ # chosen by stratified 5-fold CV on train+dev ONLY (H34) — see
611
+ # `eval/pt_zonefix_sweep.py` and `docs/eval-bench/pt-fix.md`. All 5 outer
612
+ # folds independently selected this SAME point (stable, not noise). It
613
+ # replaces the pre-S05 1.35-only default, which was provably a no-op on
614
+ # the migrated index (see `_resolve_zone` docstring — the prior needs
615
+ # BOTH the source_zone fix AND a weight strong enough to move fused RRF
616
+ # ranks once alive). Damping "40 Meetings" (rather than a bigger uniform
617
+ # curated boost) is what avoids trading away monolingual_pt / multi_hop,
618
+ # per `docs/eval-bench/pt-diagnosis.md` §3 E5's own warning.
619
+ _DEFAULT_ZONE_WEIGHTS = {
620
+ "10 People": 2.0, "20 Companies": 2.0, "30 Projects": 2.0,
621
+ "60 Concepts": 2.0, "70 Decisions": 2.0,
622
+ "40 Meetings": 0.55,
623
+ }
624
+
625
+ def _zone_weight(self, zone: str) -> float:
626
+ """Authority multiplier for a note's zone (see hybrid_search). Curated
627
+ typed zones get a modest boost over voluminous transcript/source zones;
628
+ unknown zones default to 1.0. Override via BRAIN_ZONE_WEIGHTS (JSON)."""
629
+ weights = getattr(self, "_zone_weights", None)
630
+ if weights is None:
631
+ import json as _json
632
+ import os as _os
633
+ weights = dict(self._DEFAULT_ZONE_WEIGHTS)
634
+ raw = _os.environ.get("BRAIN_ZONE_WEIGHTS")
635
+ if raw:
636
+ try:
637
+ weights.update({str(k): float(v) for k, v in _json.loads(raw).items()})
638
+ except Exception:
639
+ pass
640
+ self._zone_weights = weights
641
+ return float(weights.get(zone, 1.0))
642
+
643
+ def _resolve_zone(self, zone_col: str, path: str) -> str:
644
+ """Anti-burial authority KEY for a note (PT-02, s05).
645
+
646
+ The live migrated index flattens every Johnny-Decimal zone to
647
+ ``brain``/``raw`` in the ``notes.zone`` column (People pages land in
648
+ ``brain/areas/`` next to Companies; meeting transcripts land in
649
+ ``raw/``). ``_zone_weight`` is keyed on the ORIGINAL zone names
650
+ (``"10 People"``, ``"40 Meetings"``, ...), so on the flattened column
651
+ it was a no-op — see `docs/eval-bench/pt-diagnosis.md` root cause 1.
652
+
653
+ This is a RETRIEVAL-TIME-ONLY fix (H23/H11 reversibility gate): the
654
+ migration tool (`tools/apply_live_migration.py`) already writes the
655
+ original zone into each migrated note's frontmatter as
656
+ ``source_zone:`` alongside ``source_path:``. Rather than re-indexing
657
+ to carry that field into the SQLite schema, we read it straight off
658
+ the note's file (identified by the already-indexed ``path`` column)
659
+ at query time — no index/schema/vector change, fully reversible by
660
+ deleting this method and the call site. Brain-native notes created
661
+ after the migration have no ``source_zone`` (they were never
662
+ Johnny-Decimal); those fall back to the flattened ``zone`` column
663
+ unchanged, so this fix only ever *adds* signal, never removes it.
664
+
665
+ Only the frontmatter block (first ~2 KB) is read, not the full note
666
+ body — cheap even for large meeting transcripts. Results are cached
667
+ per ``(path, mtime)`` for the life of the index object so a query
668
+ that touches the same candidate note twice (or a session with many
669
+ queries) does not re-read the file repeatedly; a changed mtime
670
+ invalidates the cache entry automatically.
671
+
672
+ Kill switch: ``BRAIN_ZONE_SOURCE_MODE=column`` disables this and
673
+ restores the pre-fix behaviour (flattened column only) for rollback
674
+ without a code change.
675
+ """
676
+ if os.environ.get("BRAIN_ZONE_SOURCE_MODE", "auto").strip().lower() == "column":
677
+ return zone_col
678
+ cache = getattr(self, "_source_zone_cache", None)
679
+ if cache is None:
680
+ cache = {}
681
+ self._source_zone_cache = cache
682
+ try:
683
+ mtime_ns = os.stat(path).st_mtime_ns
684
+ except OSError:
685
+ return zone_col
686
+ key = (path, mtime_ns)
687
+ if key in cache:
688
+ return cache[key] or zone_col
689
+ source_zone: str | None = None
690
+ try:
691
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
692
+ head = fh.read(2048)
693
+ if head.startswith("---"):
694
+ end = head.find("---", 3)
695
+ block = head[3:end] if end != -1 else head[3:]
696
+ meta = frontmatter.parse(block)
697
+ sz = meta.get("source_zone")
698
+ if isinstance(sz, str) and sz.strip():
699
+ source_zone = sz.strip()
700
+ except OSError:
701
+ source_zone = None
702
+ cache[key] = source_zone
703
+ return source_zone or zone_col
704
+
705
+ # Near-duplicate transcript suppression (PT-02, s05 — diagnosis cause #3).
706
+ # OFF BY DEFAULT (`_DEFAULT_DEDUP_THRESHOLD = None`). The s05 CV sweep
707
+ # (`eval/pt_dedup_sweep.py`, train+dev, H34) found the lever adds EXACTLY
708
+ # ZERO incremental Recall@10 on top of the zone-authority fix — every one
709
+ # of 5 CV folds selected "disabled" — because (a) the 0.55x zone damp
710
+ # already demotes the flooding transcript zone, and (b) the residual PT→EN
711
+ # failures are e5-small embedder-floor golds at note-rank 100-250, beyond
712
+ # the candidate pool no top-k reordering can reach (diagnosis E4/E6, em-01
713
+ # scope). Shipping it ON by default would add latency + risk for no proven
714
+ # value, so it stays off. The machinery + `BRAIN_DEDUP_THRESHOLD` (float in
715
+ # (0,1)) / `BRAIN_DEDUP_SCOPE=all|transcript` knobs are retained + tested
716
+ # for a future corpus / post-embedder-swap re-eval. See
717
+ # `docs/eval-bench/pt-fix.md`.
718
+ _DEFAULT_DEDUP_THRESHOLD: float | None = None
719
+ _TRANSCRIPT_ZONES = frozenset({"40 Meetings", "raw"})
720
+
721
+ def _dedup_params(self) -> tuple[float | None, str]:
722
+ thr = self._DEFAULT_DEDUP_THRESHOLD
723
+ raw = os.environ.get("BRAIN_DEDUP_THRESHOLD")
724
+ if raw is not None:
725
+ try:
726
+ thr = float(raw)
727
+ except ValueError:
728
+ pass
729
+ scope = os.environ.get("BRAIN_DEDUP_SCOPE", "transcript").strip().lower()
730
+ return thr, scope
731
+
732
+ def _suppress_near_dups(
733
+ self,
734
+ ordered: list[int],
735
+ best_chunk_rowid: dict[int, int],
736
+ zmap: dict[int, str],
737
+ col_zone: dict[int, str],
738
+ in_lex: set[int],
739
+ ) -> list[int]:
740
+ """Retrieval-time near-duplicate SUPPRESSION (H11/H23 — never an
741
+ index-time deletion; suppressed notes are DEMOTED to the tail, so they
742
+ still surface at a larger k and nothing is removed from the index).
743
+
744
+ Root cause #3 of `docs/eval-bench/pt-diagnosis.md`: meeting transcripts
745
+ are near-duplicative (6,752 chunk pairs >=0.80 cosine; 3,988 >=0.97) and
746
+ monopolise the top-k (100% of the failing cross-lingual top-10),
747
+ crowding out the single canonical curated note. This pass walks the
748
+ fused ranking best-first and, when a TRANSCRIPT-zone candidate's
749
+ representative (best-chunk) vector is >= the threshold cosine to an
750
+ already-KEPT candidate's vector, defers it — keeping the cluster's
751
+ highest-ranked representative but freeing the slots its near-clones
752
+ would occupy. It is deliberately CONSERVATIVE (diagnosis §5 F3 risk):
753
+ * only transcript-zone candidates are eligible for suppression
754
+ (scope=transcript, the default) — a curated note is never
755
+ suppressed, so a genuinely-relevant canonical hit cannot be lost;
756
+ * the FIRST (highest-ranked) member of any near-dup cluster always
757
+ survives, so a relevant transcript still surfaces (mono-PT uses
758
+ transcript golds — diagnosis E3b);
759
+ * lexical ("both"/exact) hits are never suppressed;
760
+ * a candidate with no dense best-chunk vector (lexical-only) is never
761
+ suppressed and is not usable as a suppressor reference.
762
+ """
763
+ thr, scope = self._dedup_params()
764
+ if thr is None or not (0.0 < thr < 1.0) or len(ordered) <= 1:
765
+ return ordered
766
+ from .vectors import cosine
767
+
768
+ want = [
769
+ best_chunk_rowid[rid] for rid in ordered if rid in best_chunk_rowid
770
+ ]
771
+ vecs_by_chunk = self.backend.get_vectors(self.conn, want) if want else {}
772
+
773
+ def _vec(rid: int) -> list[float] | None:
774
+ cr = best_chunk_rowid.get(rid)
775
+ return vecs_by_chunk.get(cr) if cr is not None else None
776
+
777
+ def _is_transcript(rid: int) -> bool:
778
+ return (zmap.get(rid, "") in self._TRANSCRIPT_ZONES
779
+ or col_zone.get(rid, "") in self._TRANSCRIPT_ZONES)
780
+
781
+ kept: list[int] = []
782
+ kept_vecs: list[list[float]] = []
783
+ deferred: list[int] = []
784
+ for rid in ordered:
785
+ v = _vec(rid)
786
+ eligible = (
787
+ v is not None
788
+ and rid not in in_lex
789
+ and (scope == "all" or _is_transcript(rid))
790
+ )
791
+ if eligible and any(cosine(v, kv) >= thr for kv in kept_vecs):
792
+ deferred.append(rid)
793
+ continue
794
+ kept.append(rid)
795
+ if v is not None:
796
+ kept_vecs.append(v)
797
+ return kept + deferred
798
+
799
+ # -- ranked sub-lists for fusion (RET-01) ----------------------------
800
+ def _lexical_ranked(self, query: str, n: int) -> list[int]:
801
+ """FTS5 BM25 ranked note rowids, best-first. (`rank` is BM25; lower is
802
+ better, so ``ORDER BY rank`` is best-first.)"""
803
+ c = self.conn
804
+ try:
805
+ toks = [t for t in query.replace('"', " ").split() if t]
806
+ fts_q = " OR ".join(f'"{t}"' for t in toks) if toks else '""'
807
+ return [
808
+ int(rowid)
809
+ for (rowid,) in c.execute(
810
+ "SELECT rowid FROM notes_fts WHERE notes_fts MATCH ? "
811
+ "ORDER BY rank LIMIT ?",
812
+ (fts_q, n),
813
+ )
814
+ ]
815
+ except sqlite3.OperationalError:
816
+ return []
817
+
818
+ def decision_layer_hits(self, query: str, k: int = 8) -> list["Hit"]:
819
+ """RET-10b: TARGETED lexical probe over the DECISION LAYER — live
820
+ ``type: decision`` notes BM25-ranked against ``query``. Exists so a
821
+ dossier's decision layer never depends on decision notes cracking
822
+ the general semantic top-k (measured: a phrasing shift pushed them
823
+ below rank 60 and the sweep's decision layer came back empty while
824
+ the notes plainly existed). Decisions are scarce, FTS is indexed —
825
+ this probe is one cheap query."""
826
+ c = self.conn
827
+ try:
828
+ toks = [t for t in query.replace('"', " ").split() if t]
829
+ fts_q = " OR ".join(f'"{t}"' for t in toks) if toks else '""'
830
+ rowids = [
831
+ int(r) for (r,) in c.execute(
832
+ "SELECT f.rowid FROM notes_fts f JOIN notes n ON n.rowid = f.rowid "
833
+ "WHERE f.notes_fts MATCH ? AND n.type = 'decision' "
834
+ "AND COALESCE(n.is_latest_version,'') != 'false' "
835
+ "ORDER BY f.rank LIMIT ?", (fts_q, k))
836
+ ]
837
+ except sqlite3.OperationalError:
838
+ return []
839
+ date_expr = ("COALESCE(NULLIF(effective_date,''), "
840
+ "NULLIF(document_date,''), created)")
841
+ hits: list[Hit] = []
842
+ for rid in rowids:
843
+ row = self._note_row(rid)
844
+ if not row:
845
+ continue
846
+ (d,) = c.execute(
847
+ f"SELECT {date_expr} FROM notes WHERE rowid = ?", (rid,)).fetchone() # nosec B608 — placeholders only
848
+ hits.append(Hit(
849
+ id=row["id"], title=row["title"],
850
+ classification=row["classification"], zone=row["zone"],
851
+ path=row["path"], score=0.0, source="lexical",
852
+ snippet=self._snippet(row["body"]),
853
+ is_latest_version=row.get("is_latest_version", ""),
854
+ date=str(d or ""), type=row.get("type", ""),
855
+ ))
856
+ return hits
857
+
858
+ def _dense_ranked(
859
+ self, query: str, n: int
860
+ ) -> tuple[list[int], dict[int, str], dict[int, int]]:
861
+ """Dense (vector) ranked note rowids best-first + best chunk text per note
862
+ + best chunk ROWID per note (the last enables retrieval-time near-dup
863
+ suppression to fetch each note's representative vector without
864
+ re-embedding).
865
+
866
+ Embeds the query LAZILY here (with the canonical ``query:`` prefix) — the
867
+ only place a query embedding is computed, so lexical-only tools never pay
868
+ the embed cost. Goes through the ``VectorBackend`` ADAPTER (CORE-01); it
869
+ never depends on sqlite-vec directly, so brute-force is identical.
870
+
871
+ Embedder-pending guard (S02/CS-01): a cold-start install builds the
872
+ index with an offline placeholder (``BRAIN_EMBEDDER=hash``) so lexical
873
+ search works without a network model download. If the LIVE embedder
874
+ (real, once cached) doesn't match what the stored chunk vectors were
875
+ built with (:meth:`model_matches`), embedding the query with the real
876
+ model and comparing it against placeholder passage vectors would be
877
+ pure noise — worse than no dense leg at all. So this degrades to
878
+ FTS-only (empty dense list) until a rebuild/sync re-embeds with the
879
+ matching model (``brain warmup`` then `brain sync`, which self-heals
880
+ via the SAME model-mismatch check `sync()` already applies)."""
881
+ if not self.model_matches():
882
+ return [], {}, {}
883
+ c = self.conn
884
+ qvec = self.embedder.embed(query, is_query=True)
885
+ chunk_hits = self.backend.search(c, qvec, n * 4)
886
+ best: dict[int, float] = {}
887
+ best_chunk_text: dict[int, str] = {}
888
+ best_chunk_rowid: dict[int, int] = {}
889
+ order: list[int] = []
890
+ for chunk_rowid, score in chunk_hits:
891
+ row = c.execute(
892
+ "SELECT note_rowid, text FROM chunks WHERE rowid=?", (chunk_rowid,)
893
+ ).fetchone()
894
+ if not row:
895
+ continue
896
+ nrid, ctext = int(row[0]), row[1]
897
+ if score > best.get(nrid, -1.0):
898
+ best[nrid] = score
899
+ best_chunk_text[nrid] = ctext
900
+ best_chunk_rowid[nrid] = int(chunk_rowid)
901
+ # Re-rank notes by their best chunk score (chunk_hits is chunk-order).
902
+ order = sorted(best, key=lambda r: best[r], reverse=True)[:n]
903
+ return order, best_chunk_text, best_chunk_rowid
904
+
905
+ def search(self, query: str, k: int = 10) -> list[Hit]:
906
+ """Back-compat alias for :meth:`hybrid_search` (reranking off)."""
907
+ return self.hybrid_search(query, k=k)
908
+
909
+ def hybrid_search(
910
+ self,
911
+ query: str,
912
+ k: int = 10,
913
+ *,
914
+ rrf_k: int = 60,
915
+ candidate_factor: int = 8,
916
+ rerank: bool = False,
917
+ reranker: Any | None = None,
918
+ rerank_top: int = 15,
919
+ ) -> list[Hit]:
920
+ """Fuse FTS5 BM25 (lexical, note-level) + dense vectors (semantic,
921
+ chunk-level → folded to note) into ONE ranking via Reciprocal Rank
922
+ Fusion, RRF(k=60). UNFILTERED — classification filtering is the
923
+ integration surface's job (CLI).
924
+
925
+ RRF score for a note = Σ over each list it appears in of
926
+ ``1 / (rrf_k + rank)`` (rank 1-based). RRF needs only the *rank* of each
927
+ item in each list, so the two retrievers' incomparable score scales
928
+ (BM25 vs cosine) never have to be reconciled — the property that makes
929
+ the fusion at-least-as-good-as-either across languages.
930
+
931
+ Adapter seam (HARDENED:codex): the dense list comes through the
932
+ ``VectorBackend`` adapter, NOT a hard-wired sqlite-vec call, so a pre-v1
933
+ sqlite-vec change cannot force a retrieval rewrite and brute-force fuses
934
+ identically. The reranker (RET-02) is strictly optional and bounded to
935
+ the top ``rerank_top`` (10-20) candidates.
936
+ """
937
+ n = max(k * candidate_factor, k)
938
+ lex = self._lexical_ranked(query, n)
939
+ dense, best_chunk_text, best_chunk_rowid = self._dense_ranked(query, n)
940
+
941
+ in_lex = set(lex)
942
+ in_dense = set(dense)
943
+ scores: dict[int, float] = {}
944
+ for rank, rid in enumerate(lex, start=1):
945
+ scores[rid] = scores.get(rid, 0.0) + 1.0 / (rrf_k + rank)
946
+ for rank, rid in enumerate(dense, start=1):
947
+ scores[rid] = scores.get(rid, 0.0) + 1.0 / (rrf_k + rank)
948
+
949
+ # Zone-authority prior (RET-01 anti-burial). Curated/typed notes
950
+ # (Concepts/Decisions/Projects/People/Companies) are authoritative
951
+ # SUMMARIES; raw transcript/source zones are voluminous and near-
952
+ # duplicative, so a single canonical note is easily out-ranked purely by
953
+ # the *volume* of transcript chunks semantically near a query — most
954
+ # acutely for cross-lingual hits, where the canonical note is reachable
955
+ # only via the dense leg. We multiply the fused score by a modest
956
+ # per-zone weight so authority, not volume, decides ties. This mirrors
957
+ # the vault's own typed-zone design (it is NOT tuned to any eval); the
958
+ # weights are deliberately gentle so a genuinely relevant transcript
959
+ # still ranks. Tunable via BRAIN_ZONE_WEIGHTS (json zone->float).
960
+ #
961
+ # SCOPE (RET-01b). A *uniform* boost (scope=all) rescues cross-lingually
962
+ # buried canonical notes but collateral-damages exact-match retrieval: an
963
+ # identifier query's correct hit lives in any zone, and boosting curated
964
+ # zones demotes it. The burial it must fix is, by construction, a
965
+ # DENSE-ONLY phenomenon — a PT query reaching an EN-content canonical note
966
+ # shares no tokens, so it never appears in the lexical leg. So
967
+ # scope=semantic_only applies the prior ONLY to notes found via the dense
968
+ # leg and NOT the lexical leg, leaving exact-match (lexical / "both") hits
969
+ # at weight 1.0. This protects lexical_identifier while still de-burying
970
+ # the cross-lingual golds. It is the DEFAULT because it is principled
971
+ # (an exact-token match needs no authority help and must not be demoted)
972
+ # and dominated scope=all on every segment in the S10 e5-small A/B
973
+ # (identifier 0.958 vs 0.833; overall 0.573 vs 0.526 at the best weight).
974
+ # Tunable via BRAIN_ZONE_SCOPE = all | semantic_only (default
975
+ # semantic_only). Evidence: docs/operations/s10-pt-rootcause-and-fix.md.
976
+ zmap: dict[int, str] = {}
977
+ col_zone: dict[int, str] = {}
978
+ rdate: dict[int, str] = {}
979
+ if scores:
980
+ rids = tuple(scores)
981
+ # FALSE POSITIVE (scanner: string-built SQL / hardcoded_sql_expressions):
982
+ # `qmarks` interpolates only literal "?" placeholder characters (one
983
+ # per element of `rids`) -- the VALUES themselves are bound as query
984
+ # params (`rids`, passed separately below), never string-formatted
985
+ # into the SQL text. See docs/SECURITY_NOTES.md.
986
+ qmarks = ",".join("?" * len(rids))
987
+ # Valid-time fallback: effective_date → document_date → created,
988
+ # the same chain bases-query uses (§2/ADR-0003 Ruling 2).
989
+ date_expr = ("COALESCE(NULLIF(effective_date,''), "
990
+ "NULLIF(document_date,''), created)")
991
+ for r, z, p, d in self.conn.execute(
992
+ f"SELECT rowid, zone, path, {date_expr} FROM notes " # nosec B608
993
+ f"WHERE rowid IN ({qmarks})", rids
994
+ ):
995
+ rid = int(r)
996
+ col_zone[rid] = z or ""
997
+ zmap[rid] = self._resolve_zone(z or "", p or "")
998
+ rdate[rid] = d or ""
999
+ scope = os.environ.get("BRAIN_ZONE_SCOPE", "semantic_only").strip().lower()
1000
+ for rid in scores:
1001
+ if scope == "semantic_only" and rid in in_lex:
1002
+ continue # exact-match hit — authority prior does not apply
1003
+ scores[rid] *= self._zone_weight(zmap.get(rid, ""))
1004
+
1005
+ # Recency prior (RET-07). The RRF fusion above is time-blind: a stale
1006
+ # version of a document outranks its current successor purely on text
1007
+ # similarity, so a "latest developments" query grounds on months-old
1008
+ # material. A gentle, multiplicative staleness penalty (≤1.0, so the
1009
+ # fused score stays under the RRF ceiling like the zone prior) makes
1010
+ # the more recent of two topically-similar hits win — without
1011
+ # reconciling score scales (same reason RRF uses ranks). Neutral
1012
+ # (×1.0) for undated notes, so nothing is penalised for lacking a date.
1013
+ # Knobs: BRAIN_RECENCY_WEIGHT (0 disables), BRAIN_RECENCY_HALFLIFE_DAYS.
1014
+ # Query-intent weighting (2026-07-11, the documented upgrade path
1015
+ # after the G&P benchmark): a query that is ABOUT currentness
1016
+ # ("latest", "current", "as of", PT equivalents) doubles the
1017
+ # staleness penalty and halves the half-life, so months-old
1018
+ # near-ties stop outranking the current claim. Env knobs, when
1019
+ # set, override BOTH modes absolutely (unchanged contract).
1020
+ temporal = bool(_TEMPORAL_INTENT_RE.search(query))
1021
+ rweight = _env_float("BRAIN_RECENCY_WEIGHT", 0.5 if temporal else 0.25)
1022
+ if rweight > 0:
1023
+ rhalf = _env_float("BRAIN_RECENCY_HALFLIFE_DAYS",
1024
+ 90.0 if temporal else 180.0)
1025
+ today = _today()
1026
+ for rid in scores:
1027
+ scores[rid] *= _recency_factor(
1028
+ rdate.get(rid, ""), today, rweight, rhalf)
1029
+
1030
+ def _source(rid: int) -> str:
1031
+ if rid in in_lex and rid in in_dense:
1032
+ return "both"
1033
+ return "lexical" if rid in in_lex else "semantic"
1034
+
1035
+ ordered = sorted(scores, key=lambda r: (-scores[r], r))
1036
+ ordered = self._suppress_near_dups(
1037
+ ordered, best_chunk_rowid, zmap, col_zone, in_lex)
1038
+
1039
+ hits: list[Hit] = []
1040
+ for rid in ordered:
1041
+ row = self._note_row(rid)
1042
+ if not row:
1043
+ continue
1044
+ snippet_src = best_chunk_text.get(rid, row["body"])
1045
+ hits.append(
1046
+ Hit(
1047
+ id=row["id"], title=row["title"],
1048
+ classification=row["classification"], zone=row["zone"],
1049
+ path=row["path"], score=scores[rid], source=_source(rid),
1050
+ snippet=self._snippet(snippet_src),
1051
+ is_latest_version=row.get("is_latest_version", ""),
1052
+ date=rdate.get(rid, ""),
1053
+ type=row.get("type", ""),
1054
+ )
1055
+ )
1056
+
1057
+ if rerank and hits:
1058
+ hits = self._apply_rerank(query, hits, reranker, rerank_top)
1059
+ return hits[:k]
1060
+
1061
+ def freshness(self, newest_hit_date: str, max_tier: str) -> dict[str, Any]:
1062
+ """RET-09: how much NEWER material the vault holds past
1063
+ ``newest_hit_date`` (valid-time chain: effective_date → document_date
1064
+ → created), respecting the caller's egress cap.
1065
+
1066
+ Motivation (2026-07 G&P benchmark): an agent answering a "latest
1067
+ decisions" question from a coherent set of curated hits has no signal
1068
+ that the vault continues PAST its newest hit — it declares victory on
1069
+ stale material. This is that signal: cheap (one aggregate query),
1070
+ generic (no topic modelling), and honest about the cap (a capped
1071
+ caller learns only counts, consistent with the egress report's
1072
+ existing ``withheld`` counter)."""
1073
+ date_expr = ("COALESCE(NULLIF(effective_date,''), "
1074
+ "NULLIF(document_date,''), created)")
1075
+ # Only ISO-shaped dates participate: a garbage `created` value like
1076
+ # "unknown" sorts lexicographically above every real date and would
1077
+ # both inflate the count and win the MAX.
1078
+ where = (f"{date_expr} > ? AND {date_expr} GLOB "
1079
+ "'[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'")
1080
+ params: list[str] = [newest_hit_date]
1081
+ if max_tier != cls_mod.TIERS[-1]:
1082
+ # Below the MNPI cap, count only notes the caller could actually
1083
+ # surface (unlabelled ranks MNPI, so it is excluded here too).
1084
+ allowed = [t for t in cls_mod.TIERS
1085
+ if cls_mod.RANK[t] <= cls_mod.RANK.get(max_tier, 0)]
1086
+ where += f" AND classification IN ({','.join('?' * len(allowed))})"
1087
+ params += allowed
1088
+ row = self.conn.execute(
1089
+ f"SELECT COUNT(*), MAX({date_expr}) FROM notes WHERE {where}", # nosec B608 — placeholders only
1090
+ params).fetchone()
1091
+ return {"newest_hit_date": newest_hit_date,
1092
+ "newer_count": int(row[0] or 0),
1093
+ "vault_newest": row[1] or ""}
1094
+
1095
+ # -- multi-hop graph-augmented retrieval (RET-06) --------------------
1096
+ def _link_graph_cached(self) -> Any:
1097
+ from .graph import build_graph
1098
+ if self._link_graph is None:
1099
+ self._link_graph = build_graph(self.conn)
1100
+ return self._link_graph
1101
+
1102
+ def _entity_lexicon_cached(self) -> Any:
1103
+ from .multihop import EntityLexicon
1104
+ if self._entity_lex is None:
1105
+ self._entity_lex = EntityLexicon.build(self.conn)
1106
+ return self._entity_lex
1107
+
1108
+ def hybrid_search_graph(
1109
+ self,
1110
+ query: str,
1111
+ k: int = 10,
1112
+ *,
1113
+ rerank: bool = False,
1114
+ rerank_top: int = 15,
1115
+ rrf_k: int = 60,
1116
+ depth: int = 2,
1117
+ graph_weight: float = 0.5,
1118
+ seed_flat_top: int = 3,
1119
+ flat_pool: int = 30,
1120
+ return_trace: bool = False,
1121
+ ) -> list[Hit] | tuple[list[Hit], dict]:
1122
+ """Gated graph-augmented multi-hop retrieval (RET-06).
1123
+
1124
+ For a SINGLE-HOP query (the gate does not fire) this returns EXACTLY
1125
+ ``hybrid_search(query, k, rerank=...)`` — same call, same result — so
1126
+ single-hop latency and quality can never regress. For a multi-hop-shaped
1127
+ query (>= 2 named non-hub entities) it fetches a wider flat pool, expands
1128
+ the wikilink graph from the named entities + top flat hits, and fuses the
1129
+ graph candidates into the flat ranking (flat-dominant weighted RRF).
1130
+
1131
+ DISCOVERY-ONLY (RET-03): the graph only nominates candidate note ids that
1132
+ flat retrieval could reach; it never fabricates a note and never
1133
+ overrides an authoritative flat hit. See ``brain.multihop``."""
1134
+ from .multihop import graph_augmented_ranking
1135
+
1136
+ lexicon = self._entity_lexicon_cached()
1137
+ mentions = lexicon.mentions(query)
1138
+ from .multihop import is_multihop_shaped
1139
+
1140
+ if not is_multihop_shaped(mentions):
1141
+ # PASSTHROUGH — byte-identical to flat. The graph is never built.
1142
+ hits = self.hybrid_search(
1143
+ query, k=k, rerank=rerank, rerank_top=rerank_top, rrf_k=rrf_k
1144
+ )
1145
+ return (hits, {"fired": False, "entities": [m.surface for m in mentions]}) \
1146
+ if return_trace else hits
1147
+
1148
+ # Multi-hop path: wider flat pool so tail relevant notes exist to promote.
1149
+ pool = self.hybrid_search(
1150
+ query, k=max(k, flat_pool), rerank=rerank, rerank_top=rerank_top,
1151
+ rrf_k=rrf_k,
1152
+ )
1153
+ pool_by_id = {h.id: h for h in pool}
1154
+ flat_ids = [h.id for h in pool]
1155
+ graph = self._link_graph_cached()
1156
+ fired, ranked_ids, trace = graph_augmented_ranking(
1157
+ query, flat_ids, lexicon, graph,
1158
+ depth=depth, graph_weight=graph_weight, rrf_k=rrf_k,
1159
+ seed_flat_top=seed_flat_top,
1160
+ )
1161
+ # Assemble Hit objects in fused order. Notes flat already retrieved reuse
1162
+ # their Hit (title/snippet); graph-only notes are hydrated from the notes
1163
+ # table and tagged source="graph" so a caller sees the discovery
1164
+ # provenance. CRITICAL: re-stamp a strictly-DESCENDING score encoding the
1165
+ # fused RANK so the fused order survives any ``{path: score}`` round-trip
1166
+ # (e.g. the eval harness re-sorts a run by score) — mirrors the
1167
+ # post-fusion re-stamp in ``BrainCore.search_multi`` (RET-05b).
1168
+ from dataclasses import replace
1169
+
1170
+ top = ranked_ids[:k]
1171
+ n = len(top)
1172
+ out: list[Hit] = []
1173
+ for i, nid in enumerate(top):
1174
+ h = pool_by_id.get(nid)
1175
+ if h is None:
1176
+ h = self._graph_hit(nid)
1177
+ if h is None:
1178
+ continue
1179
+ out.append(replace(h, score=float(n - i)))
1180
+ return (out, trace) if return_trace else out
1181
+
1182
+ def _graph_hit(self, note_id: str) -> Hit | None:
1183
+ """Build a Hit for a graph-ONLY candidate (flat never retrieved it).
1184
+ Tagged source="graph" for discovery provenance; the score is re-stamped
1185
+ by the caller to encode fused rank."""
1186
+ row = self._note_row(self._rowid_of(note_id))
1187
+ if not row:
1188
+ return None
1189
+ return Hit(
1190
+ id=row["id"], title=row["title"],
1191
+ classification=row["classification"], zone=row["zone"],
1192
+ path=row["path"], score=0.0, source="graph",
1193
+ snippet=self._snippet(row["body"]),
1194
+ is_latest_version=row.get("is_latest_version", ""),
1195
+ type=row.get("type", ""),
1196
+ )
1197
+
1198
+ def _apply_rerank(
1199
+ self, query: str, hits: list[Hit], reranker: Any | None, rerank_top: int
1200
+ ) -> list[Hit]:
1201
+ """Re-order ONLY the top ``rerank_top`` hits with a cross-encoder; the
1202
+ tail is left untouched and appended after. The window is clamped to
1203
+ [10, ceiling] where ceiling is 20 by default but raisable via
1204
+ BRAIN_RERANK_MAX; ``BRAIN_RERANK_TOP`` overrides the requested window
1205
+ size itself (so a wide-candidate pass can be driven by env without
1206
+ changing call sites). Skippable: a None/identity reranker is a no-op
1207
+ (RET-02)."""
1208
+ from .rerank import NoopReranker, clamp_rerank_top, get_reranker, _resolve_reranker_model
1209
+
1210
+ env_top = os.environ.get("BRAIN_RERANK_TOP")
1211
+ if env_top:
1212
+ try:
1213
+ rerank_top = int(env_top)
1214
+ except ValueError:
1215
+ pass
1216
+ # Resolve the reranker ONCE and cache it on the instance. A caller-supplied
1217
+ # reranker wins; otherwise we honour $BRAIN_RERANKER_MODEL, caching the
1218
+ # constructed cross-encoder so its ONNX session is loaded only once per
1219
+ # index lifetime (not per query — S11 perf fix).
1220
+ if reranker is not None:
1221
+ rr = reranker
1222
+ else:
1223
+ mid = _resolve_reranker_model()
1224
+ if self._reranker_cache and self._reranker_cache[0] == mid:
1225
+ rr = self._reranker_cache[1]
1226
+ else:
1227
+ rr = get_reranker("auto")
1228
+ self._reranker_cache = (mid, rr)
1229
+ top_n = clamp_rerank_top(rerank_top)
1230
+ head, tail = hits[:top_n], hits[top_n:]
1231
+ passages = [
1232
+ (self._note_row(self._rowid_of(h.id)) or {}).get("body", h.snippet) or h.snippet
1233
+ for h in head
1234
+ ]
1235
+ passages = [p[:2000] for p in passages]
1236
+ try:
1237
+ rel = rr.rerank(query, passages)
1238
+ except Exception:
1239
+ # SKIPPABLE contract (RET-02): if the cross-encoder runtime/model is
1240
+ # unavailable (offline, not bundled), degrade to identity — never let
1241
+ # an absent precision-booster break retrieval.
1242
+ rel = NoopReranker().rerank(query, passages)
1243
+ reordered = [h for _, h in sorted(zip(rel, head), key=lambda t: t[0], reverse=True)]
1244
+ return reordered + tail
1245
+
1246
+ def _rowid_of(self, note_id: str) -> int:
1247
+ r = self.conn.execute("SELECT rowid FROM notes WHERE id=?", (note_id,)).fetchone()
1248
+ return int(r[0]) if r else -1
1249
+
1250
+ # -- agentic tool surface (RET-04): grep + bases_query ---------------
1251
+ def grep(
1252
+ self, pattern: str, *, k: int = 20, ignore_case: bool = True, regex: bool = False
1253
+ ) -> list[dict[str, Any]]:
1254
+ """Lexical-first exact/regex scan over note bodies — NO embedding.
1255
+
1256
+ The agent's lexical-first entry point: it never embeds the query, so it
1257
+ is the cheap first probe before escalating to :meth:`hybrid_search`.
1258
+ Returns note-shaped dicts (filterable by the CLI egress gate) with the
1259
+ first matching line as the snippet and a match count.
1260
+
1261
+ Bounded against ReDoS / resource exhaustion (RET-04 hardening):
1262
+ ``pattern`` is length-capped (:data:`MAX_GREP_PATTERN_LEN`) before
1263
+ compilation, and every match is wall-clock-bounded
1264
+ (:data:`GREP_REGEX_TIMEOUT_S`) when the optional `regex` engine is
1265
+ installed — see :func:`_grep_bounded_search`.
1266
+ """
1267
+ if len(pattern) > MAX_GREP_PATTERN_LEN:
1268
+ raise GrepPatternError(
1269
+ f"grep pattern too long ({len(pattern)} chars; max "
1270
+ f"{MAX_GREP_PATTERN_LEN}) — refusing to compile "
1271
+ "(ReDoS / resource-exhaustion guard)"
1272
+ )
1273
+ # M-1: without the `regex` engine, a user-supplied --regex pattern has
1274
+ # NO wall-clock bound (stdlib `re` can hang on catastrophic backtracking
1275
+ # even under MAX_GREP_PATTERN_LEN, e.g. `(a+)+$`). Refuse explicit regex
1276
+ # mode outright on the minimal build rather than silently degrading to
1277
+ # an unbounded engine (VM-reachable surface).
1278
+ if regex and not _GREP_HAS_TIMEOUT:
1279
+ raise GrepPatternError(
1280
+ "grep --regex requires the 'regex' engine (pip install "
1281
+ "'brainiac-cli[index]') for a bounded match timeout; "
1282
+ "the minimal build has no ReDoS-safe regex path"
1283
+ )
1284
+ _re = _grep_engine # the timeout-capable `regex` engine when available, else stdlib re
1285
+
1286
+ flags = _re.IGNORECASE if ignore_case else 0
1287
+ # Multi-word NATURAL-LANGUAGE handling: a literal full-question pattern
1288
+ # never matches a line verbatim, so a non-regex multi-token query is
1289
+ # treated as OR-of-terms (significant tokens only) and ranked by how many
1290
+ # DISTINCT terms a note hits, then total matches. Single-token and
1291
+ # explicit --regex patterns keep exact literal/regex behaviour (so the
1292
+ # tool's precise-pattern contract — and its tests — are unchanged).
1293
+ _STOP = {"the", "a", "an", "of", "to", "is", "are", "was", "were", "what",
1294
+ "which", "who", "and", "or", "for", "on", "in", "about", "que",
1295
+ "qual", "quais", "foi", "sobre", "ele", "ela", "com", "para",
1296
+ "uma", "dos", "das", "no", "na", "em", "se", "de", "do", "da"}
1297
+ terms: list[str] = []
1298
+ if not regex:
1299
+ terms = [t for t in _re.split(r"\W+", pattern, flags=_re.UNICODE)
1300
+ if len(t) > 2 and t.lower() not in _STOP]
1301
+ multi = (not regex) and len(terms) > 1
1302
+ if multi:
1303
+ rxs = [_re.compile(_re.escape(t), flags) for t in terms]
1304
+ else:
1305
+ try:
1306
+ rx = _re.compile(pattern if regex else _re.escape(pattern), flags)
1307
+ except _re.error:
1308
+ rx = _re.compile(_re.escape(pattern), flags)
1309
+ rxs = [rx]
1310
+ rows = self.conn.execute(
1311
+ "SELECT id,title,classification,zone,path,body FROM notes"
1312
+ ).fetchall()
1313
+ out: list[dict[str, Any]] = []
1314
+ for r in rows:
1315
+ body = r[5] or ""
1316
+ matches = [ln for ln in body.splitlines()
1317
+ if any(_grep_bounded_search(x, ln) for x in rxs)]
1318
+ if not matches:
1319
+ continue
1320
+ distinct = (
1321
+ sum(1 for x in rxs if any(_grep_bounded_search(x, ln) for ln in matches))
1322
+ if multi else 1
1323
+ )
1324
+ out.append({
1325
+ "id": r[0], "title": r[1], "classification": r[2],
1326
+ "zone": r[3], "path": r[4],
1327
+ "match_count": len(matches),
1328
+ "terms_matched": distinct,
1329
+ "snippet": self._snippet(matches[0]),
1330
+ "source": "grep",
1331
+ })
1332
+ out.sort(key=lambda d: (-d.get("terms_matched", 1), -d["match_count"], d["id"]))
1333
+ return out[:k]
1334
+
1335
+ def bases_query(
1336
+ self,
1337
+ filters: dict[str, str] | None = None,
1338
+ *,
1339
+ k: int = 50,
1340
+ order_by: str = "updated",
1341
+ latest_only: bool = False,
1342
+ as_of: str | None = None,
1343
+ ) -> list[dict[str, Any]]:
1344
+ """Structured frontmatter query (an Obsidian-Bases-style view) over the
1345
+ indexed columns — NO embedding. Filters are exact-match on
1346
+ id/title/type/classification/zone/path; unknown keys are ignored. Returns
1347
+ note-shaped dicts for the CLI egress gate.
1348
+
1349
+ TMP-02 temporal views (ADR-0003 Ruling 2/8 — the Latest Only / As Of
1350
+ Bases):
1351
+
1352
+ - ``latest_only``: excludes any note explicitly retired
1353
+ (``is_latest_version: false``). A note that never entered a
1354
+ supersession chain has no opinion here and is included (it IS the
1355
+ current — only — version of itself).
1356
+ - ``as_of``: an ISO date; returns notes valid AT that date under
1357
+ valid-time semantics — ``effective_date`` if present, else
1358
+ ``document_date``, else ``created`` (fallback chain, per the ADR) —
1359
+ excluding anything not yet effective by that date or already
1360
+ superseded by then. Composable with ``latest_only`` (as-of naturally
1361
+ admits a since-superseded note back in, latest_only would then
1362
+ exclude it again — apply them together only if that's the intent;
1363
+ the CLI keeps them as independent flags).
1364
+ """
1365
+ cols = {"id", "title", "type", "classification", "zone", "path",
1366
+ "created", "updated"}
1367
+ filters = filters or {}
1368
+ where, params = [], []
1369
+ # FALSE POSITIVE (scanner: string-built SQL / hardcoded_sql_expressions):
1370
+ # `key` / `order_col` are only ever interpolated after an explicit
1371
+ # `in cols` allowlist check against the fixed column set above -- an
1372
+ # unrecognised key/order_by is dropped/defaulted, never reaches the SQL
1373
+ # text. Every VALUE (`val`, `k`) is a bound param, never interpolated.
1374
+ # See docs/SECURITY_NOTES.md.
1375
+ for key, val in filters.items():
1376
+ if key in cols:
1377
+ where.append(f"{key} = ?") # nosec B608 - key is allowlisted above
1378
+ params.append(val)
1379
+ if latest_only:
1380
+ where.append("is_latest_version IS NOT 'false'")
1381
+ if as_of:
1382
+ where.append(
1383
+ "COALESCE(NULLIF(effective_date,''), NULLIF(document_date,''), created) <= ?"
1384
+ )
1385
+ params.append(as_of)
1386
+ where.append("(superseded_date IS NULL OR superseded_date = '' OR superseded_date > ?)")
1387
+ params.append(as_of)
1388
+ order_col = order_by if order_by in cols else "updated"
1389
+ sql = (
1390
+ "SELECT id,title,classification,zone,path,type,updated,is_latest_version FROM notes"
1391
+ + (" WHERE " + " AND ".join(where) if where else "")
1392
+ + f" ORDER BY {order_col} DESC, id ASC LIMIT ?" # nosec B608 - order_col is allowlisted above
1393
+ )
1394
+ params.append(k)
1395
+ rows = self.conn.execute(sql, params).fetchall()
1396
+ keys = ["id", "title", "classification", "zone", "path", "type", "updated",
1397
+ "is_latest_version"]
1398
+ return [dict(zip(keys, r)) for r in rows]
1399
+
1400
+ def graph_expand(
1401
+ self, seeds: list[str], *, depth: int = 2, k: int = 10, use_ppr: bool = True,
1402
+ extra_edges: list[tuple[str, str]] | None = None,
1403
+ ) -> dict[str, Any]:
1404
+ """On-demand wikilink-BFS + PPR expansion (RET-03). DISCOVERY-ONLY — the
1405
+ derived graph is never authoritative; results carry that flag.
1406
+ ``extra_edges`` (GRF-01, optional) folds graphify's INFERRED edges in."""
1407
+ from .graph import graph_expand as _expand
1408
+
1409
+ return _expand(self.conn, seeds, depth=depth, k=k, use_ppr=use_ppr,
1410
+ extra_edges=extra_edges)
1411
+
1412
+ def get(self, note_id: str) -> dict[str, Any] | None:
1413
+ r = self.conn.execute(
1414
+ "SELECT id,title,type,classification,zone,path,created,updated,sha256,body,"
1415
+ "is_latest_version,superseded_by,previous_version,superseded_date"
1416
+ " FROM notes WHERE id=?",
1417
+ (note_id,),
1418
+ ).fetchone()
1419
+ if not r:
1420
+ return None
1421
+ keys = ["id", "title", "type", "classification", "zone", "path",
1422
+ "created", "updated", "sha256", "body",
1423
+ "is_latest_version", "superseded_by", "previous_version", "superseded_date"]
1424
+ return dict(zip(keys, r))
1425
+
1426
+ def recent(self, limit: int = 10) -> list[dict[str, Any]]:
1427
+ rows = self.conn.execute(
1428
+ "SELECT id,title,classification,zone,path,updated FROM notes "
1429
+ "ORDER BY updated DESC, id ASC LIMIT ?",
1430
+ (limit,),
1431
+ ).fetchall()
1432
+ keys = ["id", "title", "classification", "zone", "path", "updated"]
1433
+ return [dict(zip(keys, r)) for r in rows]
1434
+
1435
+ # -- maintenance: near-dup scan (G1) + unclassified lint --------------
1436
+ def near_dup(self, *, min_score: float = 0.95, k: int = 5) -> list[dict[str, Any]]:
1437
+ """Corpus-wide near-duplicate scan (brain-cli-gaps.md G1).
1438
+
1439
+ Repoints the old SC-cosine integrity-scan §A directly onto the brain
1440
+ vector backend — no MCP round-trip, no raw pairwise O(n^2) python cosine
1441
+ matrix. For EVERY note, probe the backend ANN index with that note's own
1442
+ representative (first) chunk vector to NOMINATE its ``k`` nearest
1443
+ neighbours, then score each nominated pair by **true cosine** between the
1444
+ two notes' own vectors. Cost is O(n) batched embeds + O(n) backend
1445
+ searches (each sub-linear under sqlite-vec, linear-but-cheap under the
1446
+ brute-force fallback) — NOT the naive O(n^2) the gap doc flagged as the
1447
+ heavy path.
1448
+
1449
+ BACKEND-INDEPENDENT THRESHOLD (the load-bearing detail): the backend's
1450
+ own ``search`` SCORE is metric-dependent — brute-force returns cosine,
1451
+ but sqlite-vec's ``vec0`` returns an L2-derived ``1/(1+d)`` similarity on
1452
+ a DIFFERENT scale (the same near-dup pair scores ~0.96 cosine vs ~0.79
1453
+ under vec0). A ``min_score`` like 0.95 would mean two different things
1454
+ across backends. So we use ``search`` ONLY to nominate candidates and
1455
+ recompute the actual pair score as cosine over the embedder vectors
1456
+ (which are L2-normalised), making ``min_score`` mean the same cosine
1457
+ threshold on EVERY backend.
1458
+
1459
+ UNFILTERED (note-shaped, by id) — the CLI egress-gates BOTH members of
1460
+ every pair before surfacing (G1's explicit requirement)."""
1461
+ from .chunk import Chunk
1462
+ from .vectors import cosine
1463
+
1464
+ c = self.conn
1465
+ rows = c.execute(
1466
+ "SELECT n.rowid, n.id, n.title, n.zone, c.heading, c.lang, c.text "
1467
+ "FROM notes n JOIN chunks c ON c.note_rowid = n.rowid AND c.ordinal = 0"
1468
+ ).fetchall()
1469
+ if len(rows) < 2:
1470
+ return []
1471
+ rowid_to_id = {int(r[0]): r[1] for r in rows}
1472
+ # Re-derive the EXACT representation each chunk was STORED with — the
1473
+ # contextual-prefix + chunk text ``embed_input`` (IDX-02) — so the probe
1474
+ # vector is apples-to-apples comparable to the stored passage vectors.
1475
+ # Probing with the bare chunk text alone (no prefix) under-measures
1476
+ # similarity by a wide margin (the prefix tokens dominate a short
1477
+ # chunk's bag-of-tokens) and uses symmetric is_query=False ("passage:")
1478
+ # encoding throughout — near-dup is a passage<->passage comparison, not
1479
+ # a query->passage retrieval, so the asymmetric query prefix a
1480
+ # search()-style probe would use is the wrong encoding here.
1481
+ texts = [
1482
+ Chunk(ordinal=0, heading=r[4] or "", text=r[6] or "", lang=r[5] or "en")
1483
+ .embed_input(r[2] or r[1], r[3] or "", "")
1484
+ for r in rows
1485
+ ]
1486
+ vecs = self.embedder.embed_batch(texts, is_query=False)
1487
+ vec_by_note: dict[int, list[float]] = {
1488
+ int(r[0]): v for r, v in zip(rows, vecs)
1489
+ }
1490
+ # chunk rowid (ordinal=0) -> owning note rowid, for mapping search hits back.
1491
+ chunk_to_note: dict[int, int] = {
1492
+ int(crid): int(nrid)
1493
+ for nrid, crid in c.execute(
1494
+ "SELECT note_rowid, rowid FROM chunks WHERE ordinal = 0"
1495
+ ).fetchall()
1496
+ }
1497
+ best: dict[tuple[str, str], float] = {}
1498
+ for (note_rowid, *_rest), vec in zip(rows, vecs):
1499
+ for hit_chunk_rowid, _backend_score in self.backend.search(c, vec, k + 1):
1500
+ other_rowid = chunk_to_note.get(int(hit_chunk_rowid))
1501
+ if other_rowid is None or other_rowid == note_rowid:
1502
+ continue
1503
+ # Recompute the pair score as TRUE cosine (backend-independent).
1504
+ score = cosine(vec, vec_by_note[other_rowid])
1505
+ if score < min_score:
1506
+ continue
1507
+ a, b = rowid_to_id[note_rowid], rowid_to_id[other_rowid]
1508
+ key = (a, b) if a < b else (b, a)
1509
+ if score > best.get(key, -1.0):
1510
+ best[key] = score
1511
+
1512
+ out: list[dict[str, Any]] = []
1513
+ for (a_id, b_id), score in best.items():
1514
+ a_row, b_row = self._note_row(self._rowid_of(a_id)), self._note_row(self._rowid_of(b_id))
1515
+ if not a_row or not b_row:
1516
+ continue
1517
+ out.append({
1518
+ "a": {"id": a_row["id"], "title": a_row["title"],
1519
+ "classification": a_row["classification"], "zone": a_row["zone"],
1520
+ "path": a_row["path"]},
1521
+ "b": {"id": b_row["id"], "title": b_row["title"],
1522
+ "classification": b_row["classification"], "zone": b_row["zone"],
1523
+ "path": b_row["path"]},
1524
+ "score": round(score, 6),
1525
+ })
1526
+ out.sort(key=lambda d: -d["score"])
1527
+ return out
1528
+
1529
+ def stale_wikilink_targets(self) -> list[dict[str, Any]]:
1530
+ """Wikilinks whose target vanished or moved to ``archive/`` (AUT-02,
1531
+ curation Sunday fold). Reuses the ``graph`` module's derived graph —
1532
+ DISCOVERY-ONLY, UNFILTERED (the CLI egress-gates before surfacing)."""
1533
+ from .graph import stale_wikilink_targets as _stale
1534
+
1535
+ return _stale(self.conn)
1536
+
1537
+ def revisit_sample(self, *, today: Any = None, k: int = 10) -> list[dict[str, Any]]:
1538
+ """Staleness revisit sample ranked by age x whole-corpus PageRank
1539
+ centrality (AUT-02, curation Sunday fold). UNFILTERED — the CLI
1540
+ egress-gates before surfacing."""
1541
+ import datetime as _dt
1542
+
1543
+ from .graph import revisit_sample as _revisit
1544
+
1545
+ return _revisit(self.conn, today or _dt.date.today(), k=k)
1546
+
1547
+ def unclassified_notes(self, *, k: int = 100) -> list[dict[str, Any]]:
1548
+ """Notes whose ``classification`` is missing/empty or not a recognised
1549
+ tier — the curation-lint default-deny finding (no wikilink-graph orphan
1550
+ detection here; that stays vault-overlay tooling, see G4 / task-disposition
1551
+ row 4). UNFILTERED — note-shaped for the CLI egress gate."""
1552
+ from . import classification as cls
1553
+
1554
+ rows = self.conn.execute(
1555
+ "SELECT id,title,classification,zone,path FROM notes ORDER BY id"
1556
+ ).fetchall()
1557
+ out = []
1558
+ for r in rows:
1559
+ if cls.is_default_denied(r[2]):
1560
+ out.append({"id": r[0], "title": r[1], "classification": r[2],
1561
+ "zone": r[3], "path": r[4]})
1562
+ return out[:k]
1563
+
1564
+ def stats(self) -> dict[str, Any]:
1565
+ c = self.conn
1566
+ notes = int(c.execute("SELECT COUNT(*) FROM notes").fetchone()[0])
1567
+ chunks = int(c.execute("SELECT COUNT(*) FROM chunks").fetchone()[0])
1568
+ return {
1569
+ "notes": notes,
1570
+ "chunks": chunks,
1571
+ "schema_version": self.get_meta("schema_version"),
1572
+ "vector_backend": self.get_meta("vector_backend"),
1573
+ "embed_model": self.get_meta("embed_model"),
1574
+ "embed_dim": self.get_meta("embed_dim"),
1575
+ "db": str(self.db_path),
1576
+ }