sibyllabs's picture
Snapshot mirror of Sibyl-Labs/Sibyl-Memory@0506bc7d98d7c6cc0f2c658d700ef931642f1829
973d116
Raw
History Blame Contribute Delete
25.6 kB
"""Folded-trigram search shadow (v0.5.0 multi-language search, spec §4.2).
A single standalone FTS5 ``trigram`` virtual table (``search_shadow``) holding a
FOLDED rendering of the searchable text across all four tiers (entity / state /
reference / journal), maintained by DB-side triggers and consulted ONLY as a
zero-hit fallback in ``MemoryClient.search()`` (client.py §4.3). The primary
``porter unicode61`` pipeline is never touched, so the fallback is strictly
additive: it fires only where today's answer is ``[]``.
Why a shadow (and why THIS shape):
* ``trigram`` gives SUBSTRING semantics, which is the only thing that can match
inside an unbroken indexed token — CJK scriptio-continua (``北京`` inside
``北京烤鸭``), Thai fragment glue, Zulu/Bantu locative compounds, German
compounds — none of which any query-side change to ``porter unicode61`` can
reach (a prefix ``*`` covers leading substrings only).
* A FOLDED copy closes the non-decomposable gap (``ł ß ø æ đ ı œ þ ð``): no
``remove_diacritics`` setting folds these, so ``Belzyce`` cannot find a stored
``Bełżyce`` without an explicit fold map applied on BOTH sides. External-
content trigram can't carry a divergent folded copy; a standalone table can.
* ``trigram`` is built into SQLite (>= 3.34; >= 3.45 for ``remove_diacritics``);
no native/loadable extension, so the plugin keeps shipping pure-Python wheels.
The fold map + runtime tokenizer clause are the single source of truth for both
the trigger/backfill SQL (index side) and the query side (``fold_py``); keeping
them here — not in the static ``schema.sql`` — is why the shadow DDL is generated.
Business-key linkage (NOT ``content_rowid``): shadow rows carry the base-table
business key, so a VACUUM that renumbers rowids can never desync the shadow from
its base table.
TRAP (do not reintroduce): the shadow tables are maintained with PLAIN INSERT and
PLAIN DELETE. The external-content ``INSERT INTO x(x, rowid, ...) VALUES('delete',
...)`` idiom fed by a ``SELECT`` from the shadow FAILS inside a trigger body
("SQL logic error": an FTS5 table cannot be read from within a trigger). A
standalone FTS5 table supports native ``DELETE`` — use it. And NEVER write to the
base tables with ``INSERT OR REPLACE``: with ``recursive_triggers`` off it skips
the DELETE triggers and silently desyncs the shadow. The existing write paths are
safe (set_entity does explicit INSERT-else-UPDATE; state/reference use
``ON CONFLICT ... DO UPDATE`` -> a true UPDATE fires the AU trigger).
"""
from __future__ import annotations
import json as _json
import re
import sqlite3
# ---------------------------------------------------------------------------
# Fold map — non-decomposables ONLY. Decomposable diacritics (é ñ ö ż ...) are
# the tokenizer's job (``remove_diacritics``), and fold_py deliberately does NOT
# touch them so the query side and the index side fold IDENTICALLY (both get the
# tokenizer's decomposable folding, or neither does on pre-3.45 SQLite). This map
# is applied identically by fold_sql (index side, in trigger + backfill SQL) and
# fold_py (query side).
# ---------------------------------------------------------------------------
FOLD_MAP = {
"ł": "l", "Ł": "l", # ł Ł
"ß": "ss", "ẞ": "ss", # ß ẞ
"ø": "o", "Ø": "o", # ø Ø
"æ": "ae", "Æ": "ae", # æ Æ
"đ": "d", "Đ": "d", # đ Đ
"ı": "i", # ı (dotless i; İ handled by tokenizer fold)
"œ": "oe", "Œ": "oe", # œ Œ
"þ": "th", "Þ": "th", # þ Þ
"ð": "d", "Ð": "d", # ð Ð
}
SHADOW_TABLE = "search_shadow"
# The four searchable tiers this shadow mirrors.
_TIERS = ("entity", "state", "reference", "journal")
# The complete set of shadow-maintenance triggers (single source of truth,
# consumed by drop_shadow + the migration fast-path completeness check). entity/
# state/reference each get AI/AU/AD (3x3=9); journal is append-only (AI only) =
# 10 total. F1 (Fable hardening 2026-08-06): the v4 migration fast path must
# require ALL 10 to be present, not just the shadow TABLE — an out-of-band
# trigger drop would otherwise pass the table-only precondition and leave the
# shadow silently un-maintained (writes stop propagating). Mirrors how the v3
# FTS triggers self-heal on every open.
SHADOW_TRIGGER_NAMES = (
"entities_ai_shadow", "entities_au_shadow", "entities_ad_shadow",
"state_documents_ai_shadow", "state_documents_au_shadow",
"state_documents_ad_shadow",
"reference_documents_ai_shadow", "reference_documents_au_shadow",
"reference_documents_ad_shadow",
"journal_events_ai_shadow",
)
def fold_sql(expr: str) -> str:
"""SQL expression applying ASCII ``lower()`` + FOLD_MAP to ``expr``.
Wraps ``lower(expr)`` in one nested ``replace()`` per FOLD_MAP entry. SQLite's
built-in ``lower()`` folds ASCII only; the trigram tokenizer applies unicode
case folding (and, on >= 3.45, diacritic removal) on top when it indexes the
stored text, which is exactly the symmetry ``fold_py`` preserves on the query
side. FOLD_MAP sources/targets are fixed ASCII/Latin-1 literals (never user
input), so this string interpolation is injection-safe.
"""
out = f"lower({expr})"
for src, dst in FOLD_MAP.items():
out = f"replace({out}, '{src}', '{dst}')"
return out
def fold_py(text: str) -> str:
"""Query-side twin of ``fold_sql``: ASCII-lower + FOLD_MAP, nothing else.
NEVER strips decomposable accents — that stays the tokenizer's job on BOTH
sides (see FOLD_MAP note). ASCII case-folds; non-ASCII letters are left as-is
for the trigram tokenizer to case/diacritic fold, keeping query and index
folding identical.
"""
out = text.lower() if text.isascii() else "".join(
c.lower() if c.isascii() else c for c in text)
for src, dst in FOLD_MAP.items():
out = out.replace(src, dst)
return out
def trigram_tokenizer_clause() -> str:
"""Runtime-selected tokenizer clause across the SQLite 3.45 boundary.
``remove_diacritics`` reached the ``trigram`` tokenizer in 3.45.0. On older
SQLite the option is rejected at vtable construction ("unrecognized"), so we
fall back to a bare ``trigram``. The only degradation on pre-3.45 SQLite is
accent-insensitive SUBSTRING matching in the fallback; whole-word accented
matching still works via the primary porter-unicode61 index, and fold_py's
non-decomposable map is unaffected.
"""
if sqlite3.sqlite_version_info >= (3, 45, 0):
return "tokenize = 'trigram remove_diacritics 1'"
return "tokenize = 'trigram'"
# ---------------------------------------------------------------------------
# DDL / DML generators (single source of truth, consumed by storage.py migration)
# ---------------------------------------------------------------------------
def create_table_sql() -> str:
"""CREATE for the unified shadow table with the runtime tokenizer clause."""
return (
f"CREATE VIRTUAL TABLE IF NOT EXISTS {SHADOW_TABLE} USING fts5(\n"
" txt, tier UNINDEXED, k1 UNINDEXED, k2 UNINDEXED, tenant_id UNINDEXED,\n"
f" {trigram_tokenizer_clause()}\n"
")"
)
# Per-tier fold expressions, IDENTICAL on the trigger side (new./old.) and the
# backfill side (bare columns). Mirrors the base-table columns each tier's
# primary FTS5 index feeds on; journal reuses the exact concat of
# journal_events_ai_fts.
def _entity_txt(p: str) -> str:
return fold_sql(f"{p}name || ' ' || {p}category || ' ' || {p}body")
def _state_txt(p: str) -> str:
return fold_sql(f"{p}document_key || ' ' || {p}body")
def _reference_txt(p: str) -> str:
return fold_sql(f"{p}doc_key || ' ' || COALESCE({p}body, '')")
def _journal_txt(p: str) -> str:
return fold_sql(
f"COALESCE({p}evaluated, '') || ' ' || COALESCE({p}acted, '') || ' ' || "
f"COALESCE({p}forward, '') || ' ' || COALESCE({p}extra, '')"
)
def trigger_sqls() -> list[str]:
"""The shadow-maintenance triggers.
entity / state / reference each get AFTER INSERT / UPDATE / DELETE; journal
gets AFTER INSERT only (append-only — mirrors ``journal_events_ai_fts``, which
also has no AU/AD). All use PLAIN INSERT / PLAIN DELETE keyed on the tier's
BUSINESS key (see module docstring TRAP note). Ordering the DELETE before the
re-INSERT in the AU triggers keeps a rename/rekey clean.
"""
stmts: list[str] = []
# --- entities: business key (tenant_id, category, name) = (tenant, k1, k2)
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS entities_ai_shadow
AFTER INSERT ON entities BEGIN
INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
VALUES ({_entity_txt('new.')}, 'entity', new.category, new.name, new.tenant_id);
END""")
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS entities_ad_shadow
AFTER DELETE ON entities BEGIN
DELETE FROM {SHADOW_TABLE}
WHERE tier = 'entity' AND k1 = old.category AND k2 = old.name
AND tenant_id = old.tenant_id;
END""")
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS entities_au_shadow
AFTER UPDATE ON entities BEGIN
DELETE FROM {SHADOW_TABLE}
WHERE tier = 'entity' AND k1 = old.category AND k2 = old.name
AND tenant_id = old.tenant_id;
INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
VALUES ({_entity_txt('new.')}, 'entity', new.category, new.name, new.tenant_id);
END""")
# --- state_documents: business key (tenant_id, document_key) = (tenant, k2)
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS state_documents_ai_shadow
AFTER INSERT ON state_documents BEGIN
INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
VALUES ({_state_txt('new.')}, 'state', '', new.document_key, new.tenant_id);
END""")
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS state_documents_ad_shadow
AFTER DELETE ON state_documents BEGIN
DELETE FROM {SHADOW_TABLE}
WHERE tier = 'state' AND k2 = old.document_key AND tenant_id = old.tenant_id;
END""")
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS state_documents_au_shadow
AFTER UPDATE ON state_documents BEGIN
DELETE FROM {SHADOW_TABLE}
WHERE tier = 'state' AND k2 = old.document_key AND tenant_id = old.tenant_id;
INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
VALUES ({_state_txt('new.')}, 'state', '', new.document_key, new.tenant_id);
END""")
# --- reference_documents: business key (tenant_id, doc_key) = (tenant, k2)
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS reference_documents_ai_shadow
AFTER INSERT ON reference_documents BEGIN
INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
VALUES ({_reference_txt('new.')}, 'reference', '', new.doc_key, new.tenant_id);
END""")
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS reference_documents_ad_shadow
AFTER DELETE ON reference_documents BEGIN
DELETE FROM {SHADOW_TABLE}
WHERE tier = 'reference' AND k2 = old.doc_key AND tenant_id = old.tenant_id;
END""")
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS reference_documents_au_shadow
AFTER UPDATE ON reference_documents BEGIN
DELETE FROM {SHADOW_TABLE}
WHERE tier = 'reference' AND k2 = old.doc_key AND tenant_id = old.tenant_id;
INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
VALUES ({_reference_txt('new.')}, 'reference', '', new.doc_key, new.tenant_id);
END""")
# --- journal_events: append-only, AI only. Business key (tenant_id, id).
stmts.append(f"""
CREATE TRIGGER IF NOT EXISTS journal_events_ai_shadow
AFTER INSERT ON journal_events BEGIN
INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id)
VALUES ({_journal_txt('new.')}, 'journal', '', new.id, new.tenant_id);
END""")
return stmts
def backfill_sqls() -> list[str]:
"""One INSERT..SELECT per tier, folding with the SAME expressions the
triggers use so a backfilled row is byte-identical to a trigger-written one."""
return [
f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
f"SELECT {_entity_txt('')}, 'entity', category, name, tenant_id FROM entities",
f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
f"SELECT {_state_txt('')}, 'state', '', document_key, tenant_id FROM state_documents",
f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
f"SELECT {_reference_txt('')}, 'reference', '', doc_key, tenant_id FROM reference_documents",
f"INSERT INTO {SHADOW_TABLE}(txt, tier, k1, k2, tenant_id) "
f"SELECT {_journal_txt('')}, 'journal', '', id, tenant_id FROM journal_events",
]
def shadow_table_exists(conn: sqlite3.Connection) -> bool:
"""True iff the shadow virtual table is present (cheap sqlite_master lookup)."""
try:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(SHADOW_TABLE,),
).fetchone()
except sqlite3.Error:
return False
return row is not None
def shadow_trigger_count(conn: sqlite3.Connection) -> int:
"""Count how many of the canonical shadow triggers currently exist.
Cheap ``sqlite_master`` lookup restricted to the known trigger names, so a
stray user trigger can never inflate the count. Returns 0 on any read error
(the safe direction: it forces the migration to re-run apply_shadow_migration
rather than short-circuit on a bad read)."""
placeholders = ", ".join("?" for _ in SHADOW_TRIGGER_NAMES)
try:
row = conn.execute(
f"SELECT COUNT(*) FROM sqlite_master "
f"WHERE type='trigger' AND name IN ({placeholders})",
SHADOW_TRIGGER_NAMES,
).fetchone()
except sqlite3.Error:
return 0
return int(row[0]) if row else 0
def shadow_triggers_complete(conn: sqlite3.Connection) -> bool:
"""True iff ALL 10 shadow-maintenance triggers are present (F1).
The v4 migration fast path uses this alongside ``shadow_table_exists`` so an
out-of-band trigger drop self-heals: a mismatch (count != 10) falls through
to the idempotent ``apply_shadow_migration``, which recreates every trigger
(CREATE TRIGGER IF NOT EXISTS) and re-backfills the shadow to consistency."""
return shadow_trigger_count(conn) == len(SHADOW_TRIGGER_NAMES)
def apply_shadow_migration(conn: sqlite3.Connection) -> None:
"""Create the shadow table + triggers and (re)backfill all four tiers.
Runs via INDIVIDUAL ``execute`` statements (NOT ``executescript``, which
issues an implicit COMMIT and would break the caller's BEGIN IMMEDIATE). The
caller (storage.py) wraps this in one transaction and stamps the schema
marker in the SAME transaction, so a crash anywhere rolls the whole thing
back and the next open retries — idempotent by construction. Clearing with
``DELETE`` before the backfill keeps a re-run (heal / crash-retry)
duplicate-free.
"""
conn.execute(create_table_sql())
for stmt in trigger_sqls():
conn.execute(stmt)
conn.execute(f"DELETE FROM {SHADOW_TABLE}")
for stmt in backfill_sqls():
conn.execute(stmt)
def rebuild_shadow(conn: sqlite3.Connection) -> None:
"""Clear + re-backfill the shadow from the base tables (heal/rebuild path).
No-op when the shadow table is absent (e.g. a fresh DB whose shadow is
created by the later v4 migration step, so the v3 FTS-rebuild that also calls
this must not fail). Idempotent."""
if not shadow_table_exists(conn):
return
conn.execute(f"DELETE FROM {SHADOW_TABLE}")
for stmt in backfill_sqls():
conn.execute(stmt)
def drop_shadow(conn: sqlite3.Connection) -> None:
"""Drop the shadow table + all its triggers. Used by the portability heal and
documented as the rollback recovery (restores exact v3 behaviour when paired
with ``PRAGMA user_version = 3``). Base-table data is never touched."""
for name in SHADOW_TRIGGER_NAMES:
conn.execute(f"DROP TRIGGER IF EXISTS {name}")
conn.execute(f"DROP TABLE IF EXISTS {SHADOW_TABLE}")
# Portability / corruption error markers: a DB created on SQLite >= 3.45 (tokenizer
# clause 'trigram remove_diacritics 1') opened on < 3.45 fails vtable construction
# with an "unrecognized"-class message; a corrupt shadow surfaces as a malformed /
# vtable-constructor error. Both are HEALABLE by dropping and recreating the
# shadow with the locally-supported clause. Matched case-insensitively.
_HEALABLE_MARKERS = (
"unrecognized", "no such tokenize", "no such module",
"vtable constructor", "malformed", "not a database",
)
def _is_healable(err: Exception) -> bool:
msg = str(err).lower()
return any(m in msg for m in _HEALABLE_MARKERS)
def _heal(conn: sqlite3.Connection) -> None:
"""Best-effort portability heal: rebuild the shadow with the local tokenizer
clause. Runs inside the §4.2 containment so a failure here can never crash
search. Wrapped in its own transaction; swallows any error."""
try:
conn.execute("BEGIN IMMEDIATE")
drop_shadow(conn)
conn.execute(create_table_sql())
for stmt in trigger_sqls():
conn.execute(stmt)
for stmt in backfill_sqls():
conn.execute(stmt)
conn.execute("COMMIT")
except sqlite3.Error:
try:
conn.execute("ROLLBACK")
except sqlite3.Error:
pass
_LIKE_ESC = re.compile(r"([%_\\])")
def _tier_filter(allowed: set[str]) -> tuple[str, list[str]]:
"""Return (`` AND tier IN (?,...)``, params) restricting to ``allowed`` tiers,
or ("", []) when all four tiers are allowed (no clause needed)."""
if not allowed or allowed >= set(_TIERS):
return "", []
ordered = [t for t in _TIERS if t in allowed]
placeholders = ", ".join("?" for _ in ordered)
return f" AND tier IN ({placeholders})", ordered
def _shape_hit(conn: sqlite3.Connection, tenant_id: str, tier: str,
k1: str, k2: str, txt: str, rank) -> dict | None:
"""Join a shadow row back to its base table by business key and return the
exact dict shape ``_search_strict`` produces for that tier. Returns None when
the base record no longer resolves (the shadow raced a delete) — the caller
skips it. ``snippet`` = first ~120 chars of the folded text; ``rank`` = shadow
BM25 but POSITIONAL-ONLY to the caller: since F2 (2026-08-12) shadow hits are
APPENDED after the primary hits in MemoryClient.search (never re-sorted into
them), this rank orders shadow candidates only among themselves and is never
cross-compared with the primary BM25 index — so the two rank scales never mix."""
snippet = txt[:120]
if tier == "entity":
row = conn.execute(
"SELECT body, updated_at FROM entities "
"WHERE tenant_id = ? AND category = ? AND name = ?",
(tenant_id, k1, k2),
).fetchone()
if row is None:
return None
return {"tier": "entity", "key": k2, "category": k1,
"body": _json.loads(row[0]), "snippet": snippet,
"rank": rank, "ts": row[1]}
if tier == "state":
row = conn.execute(
"SELECT body, updated_at FROM state_documents "
"WHERE tenant_id = ? AND document_key = ?",
(tenant_id, k2),
).fetchone()
if row is None:
return None
return {"tier": "state", "key": k2, "category": None,
"body": _json.loads(row[0]), "snippet": snippet,
"rank": rank, "ts": row[1]}
if tier == "reference":
row = conn.execute(
"SELECT body, updated_at FROM reference_documents "
"WHERE tenant_id = ? AND doc_key = ?",
(tenant_id, k2),
).fetchone()
if row is None:
return None
return {"tier": "reference", "key": k2, "category": None,
"body": row[0], "snippet": snippet,
"rank": rank, "ts": row[1]}
if tier == "journal":
row = conn.execute(
"SELECT ts, evaluated, acted, forward, extra FROM journal_events "
"WHERE tenant_id = ? AND id = ?",
(tenant_id, k2),
).fetchone()
if row is None:
return None
return {"tier": "journal", "key": k2, "category": None,
"body": {
"evaluated": _json.loads(row[1]) if row[1] else None,
"acted": _json.loads(row[2]) if row[2] else None,
"forward": _json.loads(row[3]) if row[3] else None,
"extra": _json.loads(row[4]) if row[4] else None,
},
"snippet": snippet, "rank": rank, "ts": row[0]}
return None
def shadow_search(conn: sqlite3.Connection, tenant_id: str, query: str,
*, limit: int = 20, tiers: tuple[str, ...] | None = None) -> list[dict]:
"""Folded-trigram substring fallback. Returns ``_search_strict``-shaped dicts.
Substring semantics via the trigram MATCH (>=3-char folded tokens) with a
bounded LIKE post-filter/scan for 1-2 char non-ASCII tokens (the CJK 2-char
case — trigram MATCH cannot see below 3 chars). Short ASCII tokens are
stopword-grade noise and dropped. A no-op ``[]`` when the shadow table is
absent (pre-migration DB) so it is safe to call unconditionally.
"""
if limit <= 0: # CORE-5: a non-positive limit must never broaden — mirror clamp
return []
if not shadow_table_exists(conn):
return []
allowed = set(tiers) if tiers else set(_TIERS)
allowed &= set(_TIERS)
if not allowed:
return []
folded = fold_py(query)
toks = [t for t in re.findall(r"\w+", folded) if t]
match_toks = [t for t in toks if len(t) >= 3]
# short non-ASCII tokens are real words (2-char CJK/Hangul); short ASCII is noise
like_toks = [t for t in toks if len(t) < 3 and not t.isascii()]
if not match_toks and not like_toks:
return []
tier_clause, tier_params = _tier_filter(allowed)
fetch = max(limit, 1) * 4
try:
rows: list = []
if match_toks:
mq = " ".join('"' + t.replace('"', '""') + '"' for t in match_toks)
# !!! CORE-3 TENANT-ISOLATION LOCK (2026-06-25 pre-launch audit) !!!
# `AND tenant_id = ?` is the ONLY thing keeping this query inside the
# caller's tenant. tenant_id is UNINDEXED in the shadow FTS5 table, so
# this is a trailing post-filter, NOT index-enforced isolation. DO NOT
# remove, reorder, or make this clause conditional. Covered by the
# cross-tenant leak test (test_trigram_shadow_2026_08_06).
rows = conn.execute(
f"SELECT txt, tier, k1, k2, rank FROM {SHADOW_TABLE} "
f"WHERE {SHADOW_TABLE} MATCH ? AND tenant_id = ?" + tier_clause +
" ORDER BY rank LIMIT ?",
[mq, tenant_id, *tier_params, fetch],
).fetchall()
if like_toks:
rows = [r for r in rows if all(t in r[0] for t in like_toks)]
elif like_toks:
conds = " AND ".join(f"txt LIKE ? ESCAPE '\\'" for _ in like_toks)
like_params = ["%" + _LIKE_ESC.sub(r"\\\1", t) + "%" for t in like_toks]
# !!! CORE-3 TENANT-ISOLATION LOCK (2026-06-25 pre-launch audit) !!!
# `tenant_id = ?` is the ONLY tenant boundary (tenant_id UNINDEXED ->
# trailing post-filter, not index-enforced). DO NOT remove/reorder/
# conditionalize. Covered by test_trigram_shadow_2026_08_06.
rows = conn.execute(
f"SELECT txt, tier, k1, k2, 0.0 AS rank FROM {SHADOW_TABLE} "
f"WHERE tenant_id = ?" + tier_clause + " AND " + conds + " LIMIT ?",
[tenant_id, *tier_params, *like_params, fetch],
).fetchall()
except (sqlite3.OperationalError, sqlite3.DatabaseError) as err:
# A broken shadow must NEVER take down search: contain the error, return
# the primary path's empty result, and heal the shadow if the failure is
# the portability/corruption class (next query then succeeds).
if _is_healable(err):
_heal(conn)
return []
# Hit shaping. Journal is capped at max(1, limit//4) for symmetry with
# _search_strict (contentless journal rows share many common terms and would
# otherwise dominate). Rows whose base record no longer resolves are skipped.
journal_cap = max(1, limit // 4) if limit > 0 else 0
journal_used = 0
hits: list[dict] = []
for txt, tier, k1, k2, rank in rows:
if tier == "journal":
if journal_used >= journal_cap:
continue
# F3 (Fable robustness 2026-08-06): shape ONE row at a time behind a
# try/except so a single undecodable base-table JSON body (corrupt row,
# partial write, manual edit — _shape_hit calls json.loads) is SKIPPED,
# not allowed to void the entire fallback result set. Mirrors the §4.2
# containment stance: a broken row must never take down search. Skips on
# any per-row error (decode or a transient row-level sqlite error).
try:
shaped = _shape_hit(conn, tenant_id, tier, k1, k2, txt, rank)
except Exception:
continue
if shaped is None:
continue
if tier == "journal":
journal_used += 1
hits.append(shaped)
if len(hits) >= limit:
break
return hits