arborist/aborist/concepts/seed.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

132 lines
5.2 KiB
Python

"""One-time migration: legacy frozensets → concept_relations rows.
Pre-2026-05-01 the synonym & rivalry data lived as hand-curated frozensets
in ``aborist.qa.concepts``. This module preserves those tuples & writes
them as ``evidence_kind='manual_legacy'`` rows so the DB-backed lookup
returns the same answers the frozenset lookup did.
Idempotent: re-running ``seed_legacy_concepts`` is safe because the
INSERT OR IGNORE in ``add_concept_relation`` falls through on duplicates.
The legacy data writes to a designated ``source_root`` value
(``__legacy__concepts__``) since these relations were not derived from
any specific document. New extractors writing relations from a real
document use the document's actual ``document_root``.
"""
from __future__ import annotations
import sqlite3
from aborist.concepts.store import add_concept_relation
# Sentinel source_root for legacy seed data. Real concept relations
# from corpus extractors use the document's actual document_root so
# the per-source-root provenance stays intact.
LEGACY_SOURCE_ROOT = "__legacy__concepts__"
LEGACY_EVIDENCE_KIND = "manual_legacy"
# Snapshot of the frozenset groups as of commit bde1bd6 (2026-05-01).
# Each group becomes a fully-connected synonym cluster: every pair of
# tokens within the group becomes a synonym edge. Union-find at lookup
# time collapses these back into the same equivalence class.
LEGACY_SYNONYM_GROUPS: list[tuple[str, ...]] = [
# AMD CPU family
("amd", "athlon", "duron", "opteron", "ryzen", "epyc",
"thunderbird", "palomino", "thoroughbred", "barton",
"k6", "k7", "k8", "k10", "5x86"),
# Intel CPU family
("intel", "pentium", "celeron", "xeon", "itanium",
"i7", "i5", "i3", "i9",
"core2", "coreduo", "skylake", "haswell", "ivy",
"8086", "80286", "80386", "80486"),
# HTTP / web protocol family
("http", "https", "hypertext", "rfc2068", "rfc2616"),
# FTP / file-transfer family
("ftp", "sftp", "ftps"),
# Mac family
("macintosh", "mac", "macos", "osx", "apple"),
# Windows family
("windows", "microsoft", "win32", "winnt", "win9x"),
# Linux family
("linux", "gnu", "ubuntu", "debian", "fedora", "redhat", "kernel"),
# Mind-reading / brain-computer-interface family (added 2026-05-01)
("telepathy", "telepathic", "neurotechnology", "neuroscience",
"neuroimaging", "neural", "neuron", "neurons",
"brain", "brains", "mind", "minds", "cognition", "cognitive",
"consciousness", "thoughts", "thinking", "thought",
"psychic", "psychokinesis", "esp", "clairvoyance",
"fmri", "eeg", "fnirs", "meg", "ecog",
"tms", "transcranial", "bci"),
]
# Legacy rivalry pairs: a single edge between any token in group A and
# any token in group B captures the rivalry; lookup-time closure
# expands it to the whole synonym cluster on each side. We pick the
# canonical first token of each group as the representative.
LEGACY_RIVALRY_REPRESENTATIVES: list[tuple[str, str]] = [
("amd", "intel"), # AMD ↔ Intel
("apple", "windows"), # Mac ↔ Windows (group reps)
]
def seed_legacy_concepts(conn: sqlite3.Connection) -> dict:
"""Seed the legacy frozensets into ``concept_relations`` on ``conn``.
Returns ``{"synonyms_inserted": N, "rivalries_inserted": M,
"synonyms_skipped": K, "rivalries_skipped": L}``. Skipped counts
indicate rows that already existed (idempotent re-seed).
"""
syn_ins = syn_skip = riv_ins = riv_skip = 0
# Synonyms: emit a clique edge for every (a, b) pair within each
# group. Lookup is direct-neighbor (no transitive closure across
# groups) so we need the full N*(N-1)/2 pairs to preserve the
# legacy frozenset semantic where every member retrieved every
# other member. Quadratic in group size but groups stay small
# (largest is the 30-member brain-tech set → 435 pairs).
for group in LEGACY_SYNONYM_GROUPS:
if len(group) < 2:
continue
members = list(group)
for i, a in enumerate(members):
for b in members[i + 1:]:
inserted = add_concept_relation(
conn,
source_root=LEGACY_SOURCE_ROOT,
relation_kind="synonym",
token=a,
target=b,
evidence_kind=LEGACY_EVIDENCE_KIND,
derived_from="aborist.qa.concepts (legacy frozensets)",
)
if inserted:
syn_ins += 1
else:
syn_skip += 1
# Rivalries: one row per representative pair. Group closure happens
# at lookup time via the synonym index.
for a, b in LEGACY_RIVALRY_REPRESENTATIVES:
inserted = add_concept_relation(
conn,
source_root=LEGACY_SOURCE_ROOT,
relation_kind="rivalry",
token=a,
target=b,
evidence_kind=LEGACY_EVIDENCE_KIND,
derived_from="aborist.qa.concepts (legacy frozensets)",
)
if inserted:
riv_ins += 1
else:
riv_skip += 1
conn.commit()
return {
"synonyms_inserted": syn_ins,
"rivalries_inserted": riv_ins,
"synonyms_skipped": syn_skip,
"rivalries_skipped": riv_skip,
}