arborist/aborist/concepts/query.py
russell@unturf.com 50a1bbdc0e
qa(concepts): direct-neighbor expansion + degree caps + manual/derived split
Two compounding crashes/hangs in the corpus-derived synonym layer
that the bench surfaced 2026-05-02:

(1) Union-find chained reciprocal-link clusters into one giant
    connected component (54,538 tokens for any seed in the 4-shard
    Wikipedia corpus). One query token expanded to the entire
    synonym alphabet, tripping SQLite's expression-tree-depth=1000
    limit on the OR-clause in `_search_titles`. Replaced with
    direct-neighbor adjacency only.

(2) Even direct-neighbor expansion was too noisy on generic tokens:
    a 19-token query expanded to 419 tokens (every token had ~22
    reciprocal-link neighbors averaging out to topic-adjacency
    noise). Title-LIKE on 419 patterns × 3.47M docs × 4 shards
    hung indefinitely.

Fix: split index by evidence_kind, cap derived expansion only.

- ``manual_index`` — manual_legacy + manual rows. Curated; ALWAYS
  expand regardless of per-token degree. The brain-tech / AMD-
  family / Mac / Linux / etc. seed groups have legitimately many
  members per token after seed.py started writing clique edges.
- ``derived_index`` — link_reciprocity & corpus-extracted edges.
  Subject to MAX_NEIGHBORS_PER_TOKEN=8 cap. Generic tokens
  ("person", "thoughts") have huge degree from Wikipedia link
  noise; specific named entities have small focused neighborhoods
  that pass the cap.
- ``MAX_TOTAL_TOKENS=50`` overall cap on expanded set. Bounds
  the SQL clause count so title-LIKE search stays tractable.

seed.py: write CLIQUE edges within each legacy group (every
member-pair, not just anchor→member). Preserves the legacy
frozenset semantic where any member retrieves every other
member. Quadratic in group size but groups stay small (largest
is the 30-member brain-tech → 435 pairs).

Live shard 000 re-seeded: 80 → 747 manual_legacy rows. Idempotent
re-seed via INSERT OR IGNORE — re-running adds nothing new.

Verified:
- 19-token query: 419 → 50 expanded (cap saturated)
- thoughts: 30+ tokens incl. brain-tech members preserved
- athlon: full AMD-family clique (amd, duron, opteron, ryzen, …)

Tests: 14/14 concept tests pass.
2026-05-01 22:34:23 -04:00

267 lines
10 KiB
Python

"""Retrieval-time concept lookup. Cross-shard, read-only.
Public API matches the legacy ``aborist.qa.concepts`` shape so existing
call sites in ``query.py`` keep working unchanged. Behavior changes:
- Backed by the ``concept_relations`` SQLite table instead of in-Python
frozensets.
- Walks every shard in ``shards_dir`` (same UNION pattern as cross-shard
FTS5 search). Concept relations from shard 003 are visible to a query
routed at shard 000 — exactly what we want for a 3.47M-doc corpus
split across many shards.
- Token comparison is case-insensitive at the SQL layer (NOCASE on
LOWER()). Stored capitalization is preserved.
Cache: a per-process LRU keyed on ``shards_dir`` mtime. Lookups in a
hot loop don't re-walk shards. Cache invalidates when any shard file's
mtime changes (e.g. after `aborist concepts derive` writes new rows).
"""
from __future__ import annotations
import re
import time
from pathlib import Path
from aborist.store import connect_query
# Tokens that mean "user wants both sides of any rivalry shown" —
# kept here (not in DB) because compare-phrasing detection is a
# query-classification task, not a corpus-derived signal.
COMPARE_WORDS: frozenset[str] = frozenset({
"vs", "versus", "compare", "compared", "comparison", "compares",
"between", "difference", "differences", "or", "either",
})
def has_compare_phrasing(question: str) -> bool:
"""True if the question contains comparison language."""
lower = (question or "").lower()
words = set(re.findall(r"[a-z]+", lower))
return bool(words & COMPARE_WORDS)
# ---------------------------------------------------------------------------
# Cross-shard lookup with mtime-keyed cache
# ---------------------------------------------------------------------------
# Cache shape: { shards_dir_str: (mtime_sig, manual_index, derived_index, rivalry_pairs) }
# - manual_index: curated synonym edges; always expanded
# - derived_index: corpus-derived synonym edges; expansion subject to per-token cap
# - rivalry_pairs: list of (set_a, set_b) — frozensets of lowercase tokens
_CACHE: dict[str, tuple[tuple, dict, dict, list]] = {}
def _shards_mtime_signature(shards_dir: Path) -> tuple:
"""Return a tuple of (path, mtime_ns) for every *.db in shards_dir.
Stable across runs as long as no shard's mtime changes."""
if not shards_dir.is_dir():
return ()
return tuple(
(str(p.resolve()), p.stat().st_mtime_ns)
for p in sorted(shards_dir.glob("*.db"))
)
def _load_indices(shards_dir: Path) -> tuple[dict, dict, list]:
"""Walk all shards, materialize the synonym & rivalry indices.
Synonyms map every token to its **direct neighbors only**, NOT the
transitive closure across reciprocal-link chains. Why: union-find
over the full Wikipedia reciprocal-link graph collapses everything
into one giant connected component (54k+ tokens for any seed in
a 4-shard corpus). Direct-neighbor expansion preserves the legacy
frozenset semantics (every member of a group expanded to the others
in that group, but the groups didn't chain).
Two synonym indices are built, one per evidence class:
- ``manual_index`` — manual_legacy + manual rows. Curated; always
expanded regardless of per-token degree.
Captures the brain-tech / AMD-family / etc.
seed groups whose anchor token has many
deliberate members (e.g. telepathy → 29
members of the brain-tech group).
- ``derived_index`` — link_reciprocity & other corpus-derived
extractors. Subject to per-token degree
cap because the Wikipedia link graph
carries topic-adjacency noise on generic
tokens (person, thoughts, language).
Rivalry pairs use the union of both indices for closure.
"""
conn = connect_query(shards_dir=shards_dir)
rows = conn.execute(
"SELECT relation_kind, evidence_kind, token, target FROM concept_relations"
).fetchall()
conn.close()
manual_index: dict[str, set[str]] = {}
derived_index: dict[str, set[str]] = {}
rivalry_rows: list[tuple[str, str]] = []
# Curated evidence kinds — never capped at expansion time.
MANUAL_KINDS = {"manual", "manual_legacy"}
for r in rows:
kind = r["relation_kind"]
evidence_kind = r["evidence_kind"]
a = (r["token"] or "").lower()
b = (r["target"] or "").lower()
if not a or not b or a == b:
continue
if kind == "synonym":
target_index = (
manual_index if evidence_kind in MANUAL_KINDS else derived_index
)
target_index.setdefault(a, set()).add(b)
target_index.setdefault(b, set()).add(a)
elif kind == "rivalry":
rivalry_rows.append((a, b))
# Rivalry pairs use union of manual + derived neighborhoods.
rivalry_pairs: list[tuple[frozenset[str], frozenset[str]]] = []
for a, b in rivalry_rows:
ga = frozenset(
manual_index.get(a, set()) | derived_index.get(a, set()) | {a}
)
gb = frozenset(
manual_index.get(b, set()) | derived_index.get(b, set()) | {b}
)
rivalry_pairs.append((ga, gb))
return manual_index, derived_index, rivalry_pairs
def _get_indices(shards_dir: Path | str | None) -> tuple[dict, dict, list]:
"""Return (manual_index, derived_index, rivalry_pairs), using
cached values when the shard mtime signature is unchanged."""
if shards_dir is None:
return {}, {}, []
p = Path(shards_dir)
key = str(p.resolve())
sig = _shards_mtime_signature(p)
cached = _CACHE.get(key)
if cached is not None and cached[0] == sig:
return cached[1], cached[2], cached[3]
manual, derived, riv = _load_indices(p)
_CACHE[key] = (sig, manual, derived, riv)
return manual, derived, riv
def invalidate_cache() -> None:
"""Drop all cached indices. Call after a writer commits new rows
(the mtime check would catch this on next read, but invalidating
explicitly is faster on the same-process write+read pattern)."""
_CACHE.clear()
# ---------------------------------------------------------------------------
# Public API — matches the legacy ``aborist.qa.concepts`` shape
# ---------------------------------------------------------------------------
# Caps that match the legacy (frozenset) expansion size. The corpus-
# derived synonym graph is noisier than hand-curated frozensets:
# generic tokens like "person" / "thoughts" / "language" have ~20+
# reciprocal-link neighbors each, most of which are topic-adjacency
# noise rather than actual synonyms. Without these caps a 19-token
# query expands to ~400 accept tokens, and the title-LIKE search
# multiplies that by the document count to a many-minute hang.
#
# MAX_NEIGHBORS_PER_TOKEN: any token with more than this many
# direct neighbors is treated as "too generic to expand" — we add
# only the token itself, not its neighbors. Mirrors how the legacy
# frozensets covered named entities (athlon, pentium) but not common
# words (person, thoughts).
#
# MAX_TOTAL_TOKENS: overall cap on the expanded set. Original query
# tokens are always preserved; once total exceeds the cap, neighbors
# are sorted alphabetically & truncated. Bound on title-LIKE clause
# count keeps the SQL tractable on a 3.47M-doc corpus.
MAX_NEIGHBORS_PER_TOKEN = 8
MAX_TOTAL_TOKENS = 50
def synonym_expand(
tokens: set[str],
*,
shards_dir: Path | str | None = None,
max_neighbors_per_token: int = MAX_NEIGHBORS_PER_TOKEN,
max_total: int = MAX_TOTAL_TOKENS,
) -> set[str]:
"""Add direct synonym neighbors for any input token whose degree
is bounded enough that its neighbors are likely topical, not
topic-adjacency noise.
Two caps protect retrieval performance & quality:
1. ``max_neighbors_per_token`` — tokens with more direct neighbors
than this contribute NO expansion. Generic tokens ("person",
"thoughts") have huge degree in the Wikipedia reciprocal-link
graph; expanding them dumps random topical-cluster noise.
Specific named entities ("athlon", "telepathy") have small
focused neighborhoods that pass the cap.
2. ``max_total`` — overall cap on expanded set size. Bounds the
SQL clause count downstream. Original query tokens are always
preserved; if total > cap, neighbors are sorted alphabetically
& truncated.
"""
if not tokens:
return set()
if shards_dir is None:
return set(tokens)
manual_index, derived_index, _ = _get_indices(shards_dir)
qlower = {t.lower() for t in tokens}
expanded: set[str] = set(qlower)
# Manual (curated) synonyms always expand: brain-tech / AMD-family /
# etc. seed groups have legitimately many members per anchor & we
# trust the curation.
for t in qlower:
if t in manual_index:
expanded |= manual_index[t]
# Derived (corpus-extracted) synonyms cap on per-token degree.
# Generic tokens like "person" / "thoughts" / "language" have wide
# noisy neighborhoods in the reciprocal-link graph — skip those.
# Specific tokens with bounded degree expand cleanly.
for t in qlower:
if t not in derived_index:
continue
neighbors = derived_index[t]
if len(neighbors) > max_neighbors_per_token:
continue
expanded |= neighbors
if len(expanded) > max_total:
ordered_neighbors = sorted(expanded - qlower)
budget = max(0, max_total - len(qlower))
expanded = qlower | set(ordered_neighbors[:budget])
return expanded
def rivalry_excluded(
tokens: set[str],
*,
shards_dir: Path | str | None = None,
compare_phrasing: bool = False,
) -> set[str]:
"""Tokens whose presence in a doc title means EXCLUDE that doc.
For each rivalry pair (A, B): if exactly ONE side appears in the
query AND no comparison language was used, exclude the OTHER side's
tokens. If both sides appear, or if the user asked for a comparison,
no exclusion (they wanted both).
"""
if compare_phrasing or not tokens or shards_dir is None:
return set()
_, _, rivalry_pairs = _get_indices(shards_dir)
qlower = {t.lower() for t in tokens}
excluded: set[str] = set()
for a, b in rivalry_pairs:
a_in = bool(qlower & a)
b_in = bool(qlower & b)
if a_in and not b_in:
excluded |= b
elif b_in and not a_in:
excluded |= a
return excluded