arborist/tests/test_concepts_extract.py
russell@unturf.com 58027e9760
#000054: acronym-parens concept extractor (closes abbreviation→expansion retrieval gap)
`arborist/concepts/extract.py:acronym_parens_synonym` — new
corpus-agnostic extractor. Scans each doc's lead chunk (first 4000
chars) for `<Multi-Word Phrase> (ACRO)` where the all-caps acronym's
letters strictly match the content-word initials of the phrase, in
order, after function-word filtering. Emits bidirectional synonym
edges between the lowercased acronym and each ≥3-char content token
of the phrase, evidence_kind="acronym_parens", anchored to that doc's
document_root. Idempotent like link_reciprocity_synonym.

Why this complements link_reciprocity: Wikipedia represents
abbreviation→expansion as a one-way *redirect* (CPU →
Central processing unit), which the ingest does not record as an
edge — so the existing reciprocal-link extractor never learned the
relation. The relation IS in body text by near-universal convention
("Central processing unit (CPU) is..."), which this extractor reads.
Corpus-agnostic: HTML, blogs, textbooks benefit equally.

Conservative: strict 1:1 acronym-to-atom match (rejects HTTP-shape,
where letters land mid-word), function words filtered, repeated
definitions deduped per doc, ≥3-char target floor. 8 new tests
covering CPU bidirectional emit, RAM idempotency, FBI function-word
filter, HTTP length-mismatch reject, XYZ initial-mismatch reject,
ROM hyphenated-word handling, per-doc dedupe, registry presence.

Retrieval-side only — synonym edges reshape FTS5 candidate selection
via synonym_expand at query time, never enter audit_mode / cache_key
/ audit_event_hash. No governance hash bump, no cache invalidation.

Closes #000050 §2a's CPU/GPU abbreviation rows *upstream* of vec;
the Orwell-shape conceptual-allusion row remains the genuine #000050
justification. Operational follow-up (not code): run on each shard
via `arborist concepts derive --extractor acronym_parens` (CLI
surface itself is aspirational in docstrings; extractors are called
programmatically today). Next ID 000054 -> 000055.
2026-05-13 07:00:25 -04:00

423 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for ``arborist.concepts.extract`` — concept-relation
extractors that derive synonyms / IDF / FTS5-titles from corpus
state. Module had zero direct tests despite being the write-side
of every concept-relations row the existing test_concepts.py
exercises on the read-side.
Coverage:
- _title_tokens (pure private helper) — stopword strip,
underscore→space, length≥4 filter, lowercase, hyphen-handling
- EXTRACTORS registry contract — keys + signature
- backfill_documents_fts on a synthetic mini-shard
- link_reciprocity_synonym idempotency on a 2-doc reciprocal pair
The extractors operate on real shard schema; we build a minimal
in-memory schema that matches store.py's contract just enough to
exercise the code paths.
"""
from __future__ import annotations
import sqlite3
import pytest
from arborist.concepts.extract import (
EXTRACTORS,
_title_tokens,
backfill_documents_fts,
backfill_token_idf,
link_reciprocity_synonym,
)
from arborist.store import SCHEMA_SQL
# --- _title_tokens (pure) ------------------------------------------
def test_title_tokens_empty_string():
assert _title_tokens("") == set()
def test_title_tokens_strips_stopwords():
"""Documented stopwords (the/a/an/of/and/...) drop out."""
out = _title_tokens("the history of computing")
assert "the" not in out # stopword
assert "history" in out
assert "computing" in out
# "of" stopword + length<4 anyway
assert "of" not in out
def test_title_tokens_underscore_to_space():
"""Wikipedia title style 'Foo_Bar' → split on underscore."""
out = _title_tokens("New_York_City")
assert "york" in out
assert "city" in out
def test_title_tokens_length_four_floor():
"""Tokens length<4 dropped (3-letter words = noisy anchors)."""
out = _title_tokens("the cat ran")
assert "cat" not in out # 3 chars
assert "ran" not in out # 3 chars
def test_title_tokens_lowercase():
"""Output always lowercase regardless of input case."""
out = _title_tokens("Quantum CHROMODYNAMICS")
assert "quantum" in out
assert "chromodynamics" in out
def test_title_tokens_handles_hyphenated_token():
"""Title-token regex `[a-z][a-z0-9'\\-]+` keeps hyphens inside
a token. 'state-of-the-art''state-of-the-art' as one token
(length>=4)."""
out = _title_tokens("state-of-the-art technology")
# The regex starts on a letter then captures alphanum/apos/hyphen.
# 'state-of-the-art' becomes one token.
assert any("-" in t for t in out)
assert "technology" in out
def test_title_tokens_dedupe():
"""Output is a set — duplicates collapse."""
out = _title_tokens("Quantum quantum mechanics")
quantum_count = sum(1 for t in out if t == "quantum")
assert quantum_count == 1
def test_title_tokens_skips_pure_digits():
"""Token must START with [a-z] per the regex; 1984 → no match."""
out = _title_tokens("1984 novel")
assert "1984" not in out
assert "novel" in out
# --- EXTRACTORS registry --------------------------------------------
def test_extractors_registry_has_documented_keys():
"""Three extractors documented in the module-level docstring."""
assert "link_reciprocity" in EXTRACTORS
assert "token_idf" in EXTRACTORS
assert "documents_fts" in EXTRACTORS
def test_extractors_registry_callables():
"""Each registry value is callable."""
for name, fn in EXTRACTORS.items():
assert callable(fn), f"EXTRACTORS[{name!r}] is not callable"
def test_extractors_registry_link_reciprocity_routes_to_function():
"""The dispatch table maps to the actual implementations."""
assert EXTRACTORS["link_reciprocity"] is link_reciprocity_synonym
assert EXTRACTORS["token_idf"] is backfill_token_idf
assert EXTRACTORS["documents_fts"] is backfill_documents_fts
# --- backfill_documents_fts ----------------------------------------
@pytest.fixture
def empty_shard(tmp_path):
"""Fresh SQLite with arborist's full schema applied."""
db_path = tmp_path / "shard.db"
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL)
yield conn
conn.close()
def _insert_doc(conn, root: str, uri: str, title: str):
conn.execute(
"INSERT INTO documents "
"(document_root, document_uri, source_type, kind, "
" compression_depth, title, chunking_version, "
" canonicalization_version, schema_version, ingest_ts) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(root, uri, "html", "surface", 0, title,
"tok-512-v1", "norm-v1", "v9.8.0", 0),
)
def test_backfill_documents_fts_indexes_titles(empty_shard):
_insert_doc(empty_shard, "a" * 64, "uri-a", "Apple Inc")
_insert_doc(empty_shard, "b" * 64, "uri-b", "Banana fruit")
_insert_doc(empty_shard, "c" * 64, "uri-c", "Cherry tree")
empty_shard.commit()
result = backfill_documents_fts(empty_shard)
assert result["rows_indexed"] == 3
assert result["elapsed_ms"] >= 0
def test_backfill_documents_fts_idempotent(empty_shard):
"""Re-running on the same shard yields identical row count
(DELETE + INSERT inside)."""
_insert_doc(empty_shard, "a" * 64, "uri-a", "First")
_insert_doc(empty_shard, "b" * 64, "uri-b", "Second")
empty_shard.commit()
r1 = backfill_documents_fts(empty_shard)
r2 = backfill_documents_fts(empty_shard)
assert r1["rows_indexed"] == r2["rows_indexed"] == 2
def test_backfill_documents_fts_skips_null_titles(empty_shard):
"""Documents with NULL title are not indexed (no FTS5 row)."""
_insert_doc(empty_shard, "a" * 64, "uri-a", "Has Title")
# Insert a row with no title.
empty_shard.execute(
"INSERT INTO documents "
"(document_root, document_uri, source_type, kind, "
" compression_depth, title, chunking_version, "
" canonicalization_version, schema_version, ingest_ts) "
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)",
("b" * 64, "uri-b", "html", "surface", 0,
"tok-512-v1", "norm-v1", "v9.8.0", 0),
)
empty_shard.commit()
result = backfill_documents_fts(empty_shard)
assert result["rows_indexed"] == 1 # only the titled doc
def test_backfill_documents_fts_default_derived_from_doesnt_break(empty_shard):
"""Default derived_from kwarg path works (None → derived from
the function name internally)."""
_insert_doc(empty_shard, "a" * 64, "uri-a", "Test")
empty_shard.commit()
# Default kwarg path
r1 = backfill_documents_fts(empty_shard)
# Explicit kwarg path
r2 = backfill_documents_fts(empty_shard, derived_from="custom-extractor")
assert r1["rows_indexed"] == r2["rows_indexed"]
# --- link_reciprocity_synonym --------------------------------------
def _insert_edge(conn, src: str, dst: str, dst_root: str):
conn.execute(
"INSERT INTO edges "
"(src_root, edge_type, dst_root, dst_uri, anchor) "
"VALUES (?, 'related_doc', ?, ?, '')",
(src, dst_root, dst),
)
def test_link_reciprocity_no_edges_returns_zero(empty_shard):
result = link_reciprocity_synonym(empty_shard)
assert result["reciprocal_pairs"] == 0
assert result["synonyms_inserted"] == 0
def test_link_reciprocity_one_way_edge_no_pair(empty_shard):
"""Edge A→B without reverse B→A → no reciprocal pair → no
synonym."""
a, b = "a" * 64, "b" * 64
_insert_doc(empty_shard, a, "uri-a", "Apple Inc")
_insert_doc(empty_shard, b, "uri-b", "Banana fruit")
_insert_edge(empty_shard, a, "uri-b", b) # only A→B
empty_shard.commit()
result = link_reciprocity_synonym(empty_shard)
assert result["reciprocal_pairs"] == 0
def test_link_reciprocity_pair_creates_synonyms(empty_shard):
"""A↔B with title tokens → synonym edges."""
a, b = "a" * 64, "b" * 64
_insert_doc(empty_shard, a, "uri-a", "Apple computing")
_insert_doc(empty_shard, b, "uri-b", "Banana technology")
_insert_edge(empty_shard, a, "uri-b", b)
_insert_edge(empty_shard, b, "uri-a", a)
empty_shard.commit()
result = link_reciprocity_synonym(empty_shard)
assert result["reciprocal_pairs"] == 1
# Cross-product of 2 tokens × 2 tokens, minus self-overlap.
# apple/computing × banana/technology — no overlap → 4 pairs.
assert result["synonyms_inserted"] == 4
def test_link_reciprocity_idempotent(empty_shard):
"""Re-running adds no new rows (concept_relations PK enforces
uniqueness)."""
a, b = "a" * 64, "b" * 64
_insert_doc(empty_shard, a, "uri-a", "Apple computing")
_insert_doc(empty_shard, b, "uri-b", "Banana technology")
_insert_edge(empty_shard, a, "uri-b", b)
_insert_edge(empty_shard, b, "uri-a", a)
empty_shard.commit()
r1 = link_reciprocity_synonym(empty_shard)
r2 = link_reciprocity_synonym(empty_shard)
assert r1["synonyms_inserted"] >= 1
assert r2["synonyms_skipped"] == r1["synonyms_inserted"]
assert r2["synonyms_inserted"] == 0
def test_link_reciprocity_skips_self_overlap_token(empty_shard):
"""If both titles share a token (e.g. 'computing' in both),
that's not a synonym (same word) — skipped from the cross
product."""
a, b = "a" * 64, "b" * 64
_insert_doc(empty_shard, a, "uri-a", "Apple computing")
_insert_doc(empty_shard, b, "uri-b", "Banana computing")
_insert_edge(empty_shard, a, "uri-b", b)
_insert_edge(empty_shard, b, "uri-a", a)
empty_shard.commit()
result = link_reciprocity_synonym(empty_shard)
# 4 cross pairs minus the (computing, computing) self-overlap = 3
# apple→banana, apple→computing, banana→computing, computing→apple,
# computing→banana. Self-pair (computing, computing) excluded.
# Implementation: cross = {(a,b) for a in A for b in B if a != b}
# A = {apple, computing}, B = {banana, computing}
# → (apple, banana), (apple, computing), (computing, banana) = 3
assert result["synonyms_inserted"] == 3
# --- acronym_parens_synonym (#000054) -------------------------------
def _insert_lead_chunk(conn, doc_root: str, text: str):
"""Insert a single idx=0 chunk for ``doc_root`` carrying ``text``
in the schema-expected packed form."""
from arborist.compress import pack_chunk
content = pack_chunk(text)
conn.execute(
"INSERT INTO chunks "
"(document_root, idx, leaf_hash, content, tier) "
"VALUES (?, ?, ?, ?, ?)",
(doc_root, 0, "0" * 64, content, "hot"),
)
def _doc_with_lead(conn, root: str, uri: str, title: str, body: str):
_insert_doc(conn, root, uri, title)
_insert_lead_chunk(conn, root, body)
def test_acronym_parens_emits_bidirectional_synonym_on_cpu_case(empty_shard):
from arborist.concepts.extract import acronym_parens_synonym
_doc_with_lead(
empty_shard,
"a" * 64, "uri-a", "Central processing unit",
"A central processing unit (CPU) is the most important component "
"of a computer.",
)
empty_shard.commit()
r = acronym_parens_synonym(empty_shard)
assert r["pairs_found"] == 1
# 3 content words (central, processing, unit) × 2 directions = 6 edges
assert r["synonyms_inserted"] == 6
rows = empty_shard.execute(
"SELECT token, target FROM concept_relations "
"WHERE evidence_kind = 'acronym_parens' ORDER BY token, target"
).fetchall()
pairs = {(r["token"], r["target"]) for r in rows}
for word in ("central", "processing", "unit"):
assert ("cpu", word) in pairs
assert (word, "cpu") in pairs
def test_acronym_parens_idempotent(empty_shard):
from arborist.concepts.extract import acronym_parens_synonym
_doc_with_lead(
empty_shard, "a" * 64, "uri-a", "RAM",
"Random Access Memory (RAM) is volatile.",
)
empty_shard.commit()
r1 = acronym_parens_synonym(empty_shard)
r2 = acronym_parens_synonym(empty_shard)
assert r1["synonyms_inserted"] >= 1
assert r2["synonyms_inserted"] == 0
assert r2["synonyms_skipped"] == r1["synonyms_inserted"]
def test_acronym_parens_filters_function_words_in_phrase(empty_shard):
"""'Federal Bureau of Investigation (FBI)''of' is dropped before
initial matching, leaving F/B/I to match Federal/Bureau/Investigation."""
from arborist.concepts.extract import acronym_parens_synonym
_doc_with_lead(
empty_shard, "a" * 64, "uri-a", "FBI",
"The Federal Bureau of Investigation (FBI) is a US agency.",
)
empty_shard.commit()
r = acronym_parens_synonym(empty_shard)
assert r["pairs_found"] == 1
rows = empty_shard.execute(
"SELECT token, target FROM concept_relations "
"WHERE token = 'fbi'"
).fetchall()
targets = {row["target"] for row in rows}
assert {"federal", "bureau", "investigation"} <= targets
assert "of" not in targets # function word — never an edge
def test_acronym_parens_rejects_mismatched_initials(empty_shard):
"""'Hypertext Transfer Protocol (HTTP)' — 4-letter acronym but only
3 content words → strict 1:1 fails → no edge emitted."""
from arborist.concepts.extract import acronym_parens_synonym
_doc_with_lead(
empty_shard, "a" * 64, "uri-a", "HTTP",
"Hypertext Transfer Protocol (HTTP) is a network protocol.",
)
empty_shard.commit()
r = acronym_parens_synonym(empty_shard)
assert r["pairs_found"] == 0
assert r["synonyms_inserted"] == 0
def test_acronym_parens_rejects_initials_mismatch_at_position(empty_shard):
"""'Apple Banana Carrot (XYZ)' — letters don't match in order → no edge."""
from arborist.concepts.extract import acronym_parens_synonym
_doc_with_lead(
empty_shard, "a" * 64, "uri-a", "Misleading",
"Apple Banana Carrot (XYZ) is a fake expansion.",
)
empty_shard.commit()
r = acronym_parens_synonym(empty_shard)
assert r["pairs_found"] == 0
def test_acronym_parens_handles_hyphenated_words(empty_shard):
"""'Read-only memory (ROM)''Read-only' splits into Read+only;
'only' is a function-style word but NOT in skipwords, so atoms =
[Read, only, memory] → ROM (R/O/M) → match."""
from arborist.concepts.extract import acronym_parens_synonym
_doc_with_lead(
empty_shard, "a" * 64, "uri-a", "ROM",
"Read-only memory (ROM) cannot be written.",
)
empty_shard.commit()
r = acronym_parens_synonym(empty_shard)
assert r["pairs_found"] == 1
rows = empty_shard.execute(
"SELECT token, target FROM concept_relations WHERE token = 'rom'"
).fetchall()
targets = {row["target"] for row in rows}
assert {"read", "memory"} <= targets # "only" is 4 chars, kept; "read" 4 chars
# 'only' is included as a target since len >= 3
assert "only" in targets
def test_acronym_parens_dedupes_repeated_definition_in_same_doc(empty_shard):
"""Same parenthetical pattern repeated → one edge set per doc."""
from arborist.concepts.extract import acronym_parens_synonym
_doc_with_lead(
empty_shard, "a" * 64, "uri-a", "CPU",
"A central processing unit (CPU) is the brain. "
"The central processing unit (CPU) executes instructions.",
)
empty_shard.commit()
r = acronym_parens_synonym(empty_shard)
assert r["pairs_found"] == 1
assert r["synonyms_inserted"] == 6 # 3 words × 2 directions
def test_acronym_parens_in_extractors_registry():
from arborist.concepts.extract import EXTRACTORS
assert "acronym_parens" in EXTRACTORS
assert callable(EXTRACTORS["acronym_parens"])