arborist/tests/test_concepts.py
russell@unturf.com 5fd458aa41
qa(concepts): corpus-derived concept_relations table replaces frozensets
Phase 2 of the concepts/ layer. The Apr 27 commit (c6182ae) shipped
hand-curated frozensets in aborist/qa/concepts.py with a TODO to
"derive from Wikipedia's category graph or 'See also' sections" —
that's this commit. Fox's 2026-05-01 critique landed it: a 7-entry
list of arbitrary frozensets won't scale to a 3.47M-doc corpus, &
adding domains shouldn't require a Python edit + commit + redeploy.

Architecture:

(1) Per-shard concept_relations SQLite table. Append-only, with
    UNIQUE (source_root, relation_kind, token, target, evidence_kind)
    so re-derivation is idempotent. Lives next to documents in each
    shard so mesh sync moves relations alongside the docs that
    derived them. A SECONDARY index — writes here NEVER affect
    document_root / chunk_root / cache_key, so backfilling is safe
    across the entire corpus.

(2) aborist/concepts/ package:
    - store.py:   add_concept_relation, concept_relations_for_token,
                   purge_by_evidence_kind, list_evidence_kinds
    - query.py:   cross-shard synonym_expand, rivalry_excluded;
                   union-find collapse on synonym edges so partial
                   pairs build full equivalence classes; mtime-keyed
                   per-process LRU so retrieval doesn't re-walk
                   shards on hot loops
    - seed.py:    one-shot migration of legacy frozensets (8 groups
                   incl. brain-tech) to evidence_kind='manual_legacy'
                   rows under source_root='__legacy__concepts__'
    - extract.py: pluggable extractor registry. Built-in:
                   link_reciprocity_synonym — for any reciprocal
                   edge pair (A→B AND B→A) in the existing edges
                   table, emit synonym edges between the docs'
                   title-tokens. Works for Wikipedia (See-also
                   bidirectional), HTML site internal-link clusters
                   (russell.ballestrini.net pattern), or any link
                   graph the corpus already encodes — no new
                   crawler needed; the html_page parser already
                   populates `edges` rows on ingest.

(3) aborist/qa/concepts.py rewritten as a backwards-compat shim —
    same public API (synonym_expand, rivalry_excluded,
    has_compare_phrasing) so query.py call sites unchanged.
    shards_dir threaded through _filter_by_title_relevance &
    _search_corpus' synonym_expand calls. Without shards_dir
    (legacy 2-arg call shape), helpers degenerate to no-op —
    matches the behavior the frozenset code had when no group hit.

(4) Cross-shard UNION view: concept_relations added to
    _SHARDABLE_TABLES in store.py so connect_query() exposes a
    unified view across all shards (same pattern as documents,
    chunks, providence_cache, etc.).

Live-verified on the 4-shard 3.47M-doc corpus + the
crawl_russell_ballestrini_net.db shard:

  Q: what technology are currently or soon available which may
     enable one person to reconstruct and understand some or a
     portion of another persons thoughts or ideas without speaking
     or sign language?

  Result: same as the bde1bd6 in-memory frozenset version —
  Telepathy E4 cited alongside Videoconferencing E1/E2,
  POINTER-LINKED-PARTIAL 6/10. The DB-backed lookup reproduces
  the frozenset behavior byte-for-byte.

Tests: 633 passed (was 624). 9 new concept tests covering
DB-backed synonym/rivalry lookup, shards_dir=None degenerate
behavior, brain-tech group seed, cache invalidation. Old tests
that called helpers directly without shards_dir kept as no-op
assertions (synonym_expand({"athlon"}) without shards_dir returns
{"athlon"} unchanged).

Backfill mechanics: existing live shards needed a one-shot
`connect()` to auto-create the new concept_relations table
(SCHEMA_SQL has CREATE TABLE IF NOT EXISTS). Re-derivation never
mutates the Merkle tree — concept_relations is fully orthogonal
to document_root / chunk_root. Cache_key dimensions are
unaffected.

Deferred (follow-on):
- CLI commands: aborist concepts {seed,list,add,derive,purge}
  (currently fox runs the helpers via python -c)
- Wikipedia See-also extractor (requires parsing wikitext sections
  beyond what's already in edges)
- Wikipedia category extractor (requires reading Category: links
  from chunked wikitext)
2026-05-01 21:14:53 -04:00

176 lines
6 KiB
Python

"""Concept overlay: synonym expansion + rivalry exclusion.
Pre-2026-05-01: data lived as Python frozensets, tests called the
helpers directly. Post-2026-05-01: data lives in per-shard SQLite
tables, the helpers walk shards via shards_dir. Each test seeds
a temporary shard dir using the legacy seed so the assertions stay
identical.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from aborist.concepts import invalidate_cache as _invalidate_cache
from aborist.concepts.query import invalidate_cache
from aborist.concepts.seed import seed_legacy_concepts
from aborist.qa.concepts import (
has_compare_phrasing,
rivalry_excluded,
synonym_expand,
)
from aborist.store import connect
# ---------------------------------------------------------------------------
# Fixture: a tmp shards_dir containing one shard pre-seeded with legacy data.
# ---------------------------------------------------------------------------
@pytest.fixture
def seeded_shards(tmp_path: Path) -> Path:
shards_dir = tmp_path / "shards"
shards_dir.mkdir()
shard_path = shards_dir / "000.db"
conn = connect(shard_path)
seed_legacy_concepts(conn)
conn.close()
invalidate_cache()
return shards_dir
# ---------------------------------------------------------------------------
# synonym_expand
# ---------------------------------------------------------------------------
def test_synonym_expand_amd_pulls_athlon_and_back(seeded_shards):
expanded = synonym_expand({"athlon"}, shards_dir=seeded_shards)
assert "amd" in expanded
assert "duron" in expanded
assert "thunderbird" in expanded
expanded = synonym_expand({"amd"}, shards_dir=seeded_shards)
assert "athlon" in expanded
def test_synonym_expand_unrelated_token_unchanged(seeded_shards):
expanded = synonym_expand({"banana"}, shards_dir=seeded_shards)
assert expanded == {"banana"}
def test_synonym_expand_doesnt_cross_groups(seeded_shards):
"""AMD and Intel are in different groups — expanding one shouldn't
pull in the other."""
expanded = synonym_expand({"amd"}, shards_dir=seeded_shards)
assert "intel" not in expanded
assert "pentium" not in expanded
def test_synonym_expand_no_shards_dir_is_noop():
"""Without a shards_dir, expand returns the input unchanged.
Same degenerate behavior the legacy frozenset code had when
a query token didn't hit any group."""
assert synonym_expand({"athlon"}) == {"athlon"}
# ---------------------------------------------------------------------------
# rivalry_excluded
# ---------------------------------------------------------------------------
def test_rivalry_excluded_amd_query_drops_intel(seeded_shards):
excluded = rivalry_excluded({"amd"}, compare_phrasing=False, shards_dir=seeded_shards)
assert "intel" in excluded
assert "pentium" in excluded
def test_rivalry_excluded_intel_query_drops_amd(seeded_shards):
excluded = rivalry_excluded({"intel"}, compare_phrasing=False, shards_dir=seeded_shards)
assert "amd" in excluded
assert "athlon" in excluded
def test_rivalry_excluded_both_sides_no_exclusion(seeded_shards):
excluded = rivalry_excluded(
{"amd", "intel"}, compare_phrasing=False, shards_dir=seeded_shards
)
assert excluded == set()
def test_rivalry_excluded_compare_phrasing_suppresses(seeded_shards):
excluded = rivalry_excluded(
{"amd"}, compare_phrasing=True, shards_dir=seeded_shards
)
assert excluded == set()
def test_rivalry_excluded_no_shards_dir_is_noop():
"""Without shards_dir, no exclusion (caller hasn't loaded the
concept layer)."""
assert rivalry_excluded({"amd"}, compare_phrasing=False) == set()
# ---------------------------------------------------------------------------
# Compare-phrasing detection (independent of shards)
# ---------------------------------------------------------------------------
def test_compare_phrasing_detection():
assert has_compare_phrasing("compare AMD and Intel")
assert has_compare_phrasing("AMD vs Intel")
assert has_compare_phrasing("difference between Mac and Windows")
assert not has_compare_phrasing("what is the fastest AMD CPU?")
assert not has_compare_phrasing("tell me about Athlon")
def test_mac_windows_rivalry(seeded_shards):
"""Independent rivalry pair — Mac vs Windows."""
excluded = rivalry_excluded(
{"macintosh"}, compare_phrasing=False, shards_dir=seeded_shards
)
assert "windows" in excluded
excluded = rivalry_excluded(
{"windows"}, compare_phrasing=False, shards_dir=seeded_shards
)
assert "macintosh" in excluded
assert "macos" in excluded
# ---------------------------------------------------------------------------
# Brain-tech group (added 2026-05-01)
# ---------------------------------------------------------------------------
def test_brain_tech_synonym_group_includes_telepathy_and_thoughts(seeded_shards):
"""Run 1 retrieval-fix group: 'thoughts' should pull 'telepathy',
'neurotechnology', etc. so the title-relevance accept path admits
brain-tech pages."""
expanded = synonym_expand({"thoughts"}, shards_dir=seeded_shards)
assert "telepathy" in expanded
assert "neurotechnology" in expanded
assert "mind" in expanded
# ---------------------------------------------------------------------------
# Cache invalidation
# ---------------------------------------------------------------------------
def test_invalidate_cache_does_not_break_lookup(seeded_shards):
"""After explicit cache invalidation, a fresh load reproduces
the same answers."""
expanded_1 = synonym_expand({"amd"}, shards_dir=seeded_shards)
invalidate_cache()
expanded_2 = synonym_expand({"amd"}, shards_dir=seeded_shards)
assert expanded_1 == expanded_2
def test_export_invalidate_cache_alias(seeded_shards):
"""The package-level ``invalidate_cache`` is the same callable as
``aborist.concepts.query.invalidate_cache`` — exposed at top level
for callers that don't want to import the implementation module."""
assert _invalidate_cache is invalidate_cache