diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 05fffb066..fdbd55f76 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -9,7 +9,7 @@ This is the reference for power users whose project has a documentation idiom or A single optional TOML file at the root of a project (or any ancestor). It exposes two power-user surfaces: - `[noise]` — onboard-time belief filter. Changes how `aelf onboard` ingests beliefs; nothing else. -- `[retrieval]` (v1.3+) — retrieval-time tier toggles + ranking. Knobs: `entity_index_enabled` (L2.5), `bfs_enabled` (L3), `posterior_weight` (partial Bayesian-weighted L1 ranking), `use_bm25f_anchors` (BM25F-with-anchor-text since v1.7), `use_heat_kernel` (authority scoring lane, opt-in), `use_hrr_structural` (HRR structural-query lane, opt-in), `use_type_aware_compression` (per-belief retention-class compression, opt-in since v2.1). Two placeholder flags (`use_signed_laplacian`, `use_posterior_ranking`) are recognised but emit a deprecation warning if set — their lanes have not yet shipped. +- `[retrieval]` (v1.3+) — retrieval-time tier toggles + ranking. Knobs: `entity_index_enabled` (L2.5), `bfs_enabled` (L3), `posterior_weight` (partial Bayesian-weighted L1 ranking), `use_bm25f_anchors` (BM25F-with-anchor-text since v1.7), `use_heat_kernel` (authority scoring lane, opt-in), `use_hrr_structural` (HRR structural-query lane, opt-in), `use_type_aware_compression` (per-belief retention-class compression, opt-in since v2.1), `use_vocab_bridge` (HRR query-side vocabulary bridge, opt-in since v2.1). Two placeholder flags (`use_signed_laplacian`, `use_posterior_ranking`) are recognised but emit a deprecation warning if set — their lanes have not yet shipped. Locks, hooks, MCP tools, and the Bayesian feedback math are not affected. @@ -88,6 +88,16 @@ use_hrr_structural = false # overrides. use_type_aware_compression = false +# v2.1 #433 HRR vocabulary bridge. When true, retrieve_v2 builds a +# per-store VocabBridge over surface forms (anchor text + belief +# content) and rewrites the query before lane fan-out, appending +# canonical-entity tokens whose recovery cosine clears the noise +# floor. Original tokens are preserved verbatim; the rewrite is +# additive, never substitutive. Default-OFF until the lab-side +# bench gate (A2 in docs/feature-hrr-vocab-bridge.md) clears. +# AELFRICE_VOCAB_BRIDGE=1 env var overrides. +use_vocab_bridge = false + # Placeholder flags reserved by #154 — recognised so callers can # write forward-compat config, but their lanes have not yet # shipped. Setting either to true emits a one-shot stderr @@ -309,6 +319,23 @@ When disabled (default), `compressed_beliefs` is empty and `beliefs` is byte-ide Precedence (first decisive wins): env var `AELFRICE_TYPE_AWARE_COMPRESSION=0`/`1` > explicit Python kwarg `use_type_aware_compression=` > TOML `[retrieval] use_type_aware_compression` > default `false`. The default-on flip is gated on the lab-side bench in `tests/bench_gate/test_compression_uplift.py` plus the pack-loop budget rewrite (follow-up). +### `use_vocab_bridge` + +Boolean, default `false`, opt-in (v2.1+, #433). Enables the HRR vocabulary-bridge query rewrite. When on, `retrieve_v2` builds (or fetches a cached) `VocabBridge` over the per-project store and prepends the rewrite stage before lane fan-out: + +``` +query + -> [bridge.rewrite(query) if use_vocab_bridge else query] + -> retrieve() lane fan-out: BM25F + heat-kernel + HRR-structural + BFS + -> compose -> rank -> pack +``` + +The bridge is **not** a lane — it does not contribute scores. It harvests surface-form tokens from incoming anchor text (#148) and belief content (entity-extractor lane), constructs a single HRR composite per `(token, canonical)` pair, and at query time unbinds the query token to recover one or more canonical-entity strings via cleanup memory. Tokens that are themselves canonical self-recover and are appended once; tokens with no canonical above the noise floor (`1/sqrt(dim)`) drop. Original-query tokens are preserved verbatim — bridged candidates are appended, never substituted. + +`use_hrr` on `retrieve_v2` is a deprecated alias for `use_vocab_bridge` and survives one minor version. Lab v2.0.0 adapters that pass `use_hrr=True` route to the bridge automatically; new callers should use `use_vocab_bridge` directly. + +Precedence (first decisive wins): env var `AELFRICE_VOCAB_BRIDGE=0`/`1` > explicit Python kwarg `use_vocab_bridge=` > TOML `[retrieval] use_vocab_bridge` > default `false`. The default-on flip is gated on the lab-side bench in `tests/bench_gate/test_vocab_bridge_uplift.py` plus the strict A2 NDCG@k follow-up. + ### Placeholder flags `use_signed_laplacian` and `use_posterior_ranking` are reserved by #154 but their owning lanes have not yet shipped. The flags are recognised by `warn_placeholder_flags()` so writing them in `.aelfrice.toml` does not error; setting either to `true` emits a one-shot stderr deprecation warning and is otherwise a no-op. Source of truth: `PLACEHOLDER_FLAGS` in `src/aelfrice/retrieval.py`. diff --git a/src/aelfrice/retrieval.py b/src/aelfrice/retrieval.py index 16094aa1d..a89cf90e9 100644 --- a/src/aelfrice/retrieval.py +++ b/src/aelfrice/retrieval.py @@ -68,6 +68,7 @@ ) from aelfrice.bm25 import BM25IndexCache from aelfrice.compression import CompressedBelief, compress_for_retrieval +from aelfrice.vocab_bridge import VocabBridge, VocabBridgeCache from aelfrice.entity_extractor import extract_entities from aelfrice.graph_spectral import ( DEFAULT_BM25_SEED_TOP_K, @@ -137,6 +138,11 @@ # CompressedBelief renderings; OFF leaves the field empty for byte-identical # behavior with v1.x adapters. TYPE_AWARE_COMPRESSION_FLAG: Final[str] = "use_type_aware_compression" +# v2.1 #433 HRR vocabulary-bridge flag. Default-OFF at v2.0.0 until the +# lab-side bench gate (A2 in docs/feature-hrr-vocab-bridge.md) clears. +# When ON, retrieve_v2 builds (or fetches a cached) VocabBridge against +# the store and rewrites the query before lane fan-out. +VOCAB_BRIDGE_FLAG: Final[str] = "use_vocab_bridge" PLACEHOLDER_FLAGS: Final[tuple[str, ...]] = ( SIGNED_LAPLACIAN_FLAG, @@ -163,6 +169,8 @@ ENV_HRR_STRUCTURAL: Final[str] = "AELFRICE_HRR_STRUCTURAL" # v2.1 #434 type-aware compression env override. Tri-state. ENV_TYPE_AWARE_COMPRESSION: Final[str] = "AELFRICE_TYPE_AWARE_COMPRESSION" +# v2.1 #433 vocabulary-bridge env override. Tri-state. +ENV_VOCAB_BRIDGE: Final[str] = "AELFRICE_VOCAB_BRIDGE" # v1.3.0 posterior-weight env override. Float-typed; "0.0" is the # only value that fully disables (collapsing to BM25-only ordering). # Empty / non-numeric values fall through to the next precedence @@ -320,6 +328,21 @@ def _env_hrr_structural_override() -> bool | None: return None +def _env_vocab_bridge_override() -> bool | None: + """Return True/False if AELFRICE_VOCAB_BRIDGE is set to a recognised + truthy/falsy value, else None. Symmetric to + `_env_bm25f_override`.""" + raw = os.environ.get(ENV_VOCAB_BRIDGE) + if raw is None: + return None + norm = raw.strip().lower() + if norm in _ENV_FALSY: + return False + if norm in _ENV_TRUTHY: + return True + return None + + def _env_type_aware_compression_override() -> bool | None: """Return True/False if AELFRICE_TYPE_AWARE_COMPRESSION is set to a recognised truthy/falsy value, else None. Symmetric to @@ -762,6 +785,32 @@ def resolve_use_type_aware_compression( return False +def resolve_use_vocab_bridge( + explicit: bool | None = None, + *, + start: Path | None = None, +) -> bool: + """Resolve the HRR vocabulary-bridge flag (#433). + + Precedence (first decisive wins): + 1. AELFRICE_VOCAB_BRIDGE env var (truthy / falsy normalised). + 2. Explicit `explicit` kwarg from the caller. + 3. `[retrieval] use_vocab_bridge` in `.aelfrice.toml`. + 4. Default: False — ships behind the flag at v2.0.0; the bench gate + (A2 in docs/feature-hrr-vocab-bridge.md) flips the default + after lab-side benchmark evidence clears. + """ + env = _env_vocab_bridge_override() + if env is not None: + return env + if explicit is not None: + return explicit + toml_value = _read_toml_flag_for(VOCAB_BRIDGE_FLAG, start) + if toml_value is not None: + return toml_value + return False + + _PLACEHOLDER_WARNED: set[str] = set() @@ -1448,7 +1497,7 @@ def retrieve_v2( query: str, budget: int = DEFAULT_TOKEN_BUDGET, include_locked: bool = True, - use_hrr: bool = False, # noqa: ARG001 + use_hrr: bool | None = None, use_bfs: bool | None = None, use_entity_index: bool | None = None, l1_limit: int = DEFAULT_L1_LIMIT, @@ -1462,6 +1511,8 @@ def retrieve_v2( temporal_sort: bool = False, temporal_half_life_seconds: float | None = None, use_type_aware_compression: bool | None = None, + use_vocab_bridge: bool | None = None, + vocab_bridge_cache: VocabBridgeCache | None = None, ) -> RetrievalResult: """Lab-compatible retrieval wrapper for academic-suite adapters. @@ -1471,9 +1522,21 @@ def retrieve_v2( - `budget` (lab kwarg) maps to `token_budget` (public kwarg). - `include_locked=False` filters out lock_level != LOCK_NONE post-retrieval (public always returns L0 first; this wrapper drops them on demand). - - `use_hrr` is accepted but no-op at v1.3.0 — the HRR vocabulary - bridge has not yet ported. Callers can pass it for forward-compat - without conditionals. + - `use_hrr` is a deprecated alias for `use_vocab_bridge` (#433). + Lab v2.0.0 adapters that pass `use_hrr=True` route to the + vocabulary bridge; new callers should use `use_vocab_bridge` + directly. The alias survives for one minor version. + - `use_vocab_bridge` (v2.1 #433) — when True, retrieve_v2 builds + (or fetches a cached) `VocabBridge` against the store and + rewrites the query before lane fan-out. Default-OFF until the + bench gate (A2 in docs/feature-hrr-vocab-bridge.md) clears. + Original-query tokens are preserved verbatim; canonical-entity + rewrites are appended, never substituted. + - `vocab_bridge_cache` (v2.1 #433) — explicit `VocabBridgeCache` + to reuse an already-built bridge. None falls through to a + fresh `VocabBridgeCache(store)` per call. Long-running + consumers should pass an explicit cache to amortise the + build cost across queries. - `use_bfs` (v1.3.0) maps to `retrieve()`'s `bfs_enabled` kwarg. None falls through to the default-OFF resolution (env / TOML / False at v1.3.0). Setting it True opts a single retrieve_v2 @@ -1504,6 +1567,19 @@ def retrieve_v2( `result.beliefs` (and stub diagnostics fields, plus the new v1.3 `entity_hits` and `bfs_chains`). """ + # v2.1 #433 vocabulary-bridge query rewrite. Runs before lane + # fan-out so every lane sees the widened query string. The bridge + # appends canonical-entity rewrites; the original tokens are + # preserved verbatim, so a flag-OFF call is byte-identical to the + # pre-bridge behaviour. `use_hrr` is a deprecated alias. + bridge_explicit = use_vocab_bridge + if bridge_explicit is None and use_hrr is not None: + bridge_explicit = use_hrr + if resolve_use_vocab_bridge(bridge_explicit): + cache = vocab_bridge_cache or VocabBridgeCache(store) + bridge: VocabBridge = cache.get() + query = bridge.rewrite(query) + ( out, locked_ids_list, diff --git a/src/aelfrice/vocab_bridge.py b/src/aelfrice/vocab_bridge.py new file mode 100644 index 000000000..bf9d48f10 --- /dev/null +++ b/src/aelfrice/vocab_bridge.py @@ -0,0 +1,403 @@ +"""HRR vocabulary bridge (#433). + +Closes the vocabulary-gap-recovery claim on the **query side**. A query +token whose surface form does not appear verbatim in the corpus's +canonical vocabulary gets bridged to one or more canonical-entity +tokens before any retrieval lane fires. + +Pure linear algebra over the surface-form token universe the corpus +already exposes. No learned components, no LLM call, no embedding +model. Deterministic build from a path-derived seed (mirrors +``HRRStructIndex`` at :mod:`aelfrice.hrr_index`). + +Spec: ``docs/feature-hrr-vocab-bridge.md``. + +Algorithm (build): + + bridge_vec = sum_{c in canonicals} sum_{s in surface_forms(c)} + bind(token_vec[s], canonical_vec[c]) + +A single ``(dim,)`` superposition encodes every (surface, canonical) +pair the corpus exposes. Cleanup memory holds ``(canonical_token, +canonical_vec)`` so the rewrite step can map a recovered vector back +to a string. + +Algorithm (rewrite, per query token ``t``): + + recovered = unbind(token_vec[t], bridge_vec) + for (canonical, score) in cleanup_memory.query(recovered, top_k): + if score >= min_score and canonical not in already_appended: + append canonical + +Tokens not seen at build-time short-circuit (no ``token_vec[t]``); +tokens with no canonical above ``noise_floor() = 1/sqrt(dim)`` drop; +tokens that are themselves canonical self-recover (cosine ≈ 1) and +are appended once. + +In-memory only at v2.0.0. Persistence is the ``.npz`` round-trip +pattern at :mod:`aelfrice.hrr_index` if a future revision needs it. +""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Final + +import numpy as np + +from aelfrice.bm25 import tokenize as bm25_tokenize +from aelfrice.entity_extractor import extract_entities +from aelfrice.hrr import DEFAULT_DIM, Vector, bind, random_vector, unbind +from aelfrice.models import LOCK_USER +from aelfrice.store import MemoryStore + +# XOR salt for the surface-form (token) draw stream. The id stream uses +# the unsalted seed; the role/token stream uses ``seed ^ _TOKEN_SALT``. +# Mirrors the role/id stream split in ``HRRStructIndex.build`` at +# ``hrr_index.py:148-149``. Constant chosen as a 64-bit pattern so it +# fans out evenly under XOR; value is documentation, not crypto. +_TOKEN_SALT: Final[int] = 0xC3C3C3C3C3C3C3C3 + +# Default top-K canonical rewrites appended per query token. +DEFAULT_TOP_K: Final[int] = 3 + + +def _seed_from_path(path: str | None, salt: int = 0) -> int: + """Stable 64-bit seed from a store-path string. Same convention as + ``hrr_index._seed_from_path`` so two builds against the same path + produce byte-identical bridges.""" + payload = (path or "").encode("utf-8") + digest = hashlib.md5(payload).digest() + base = int.from_bytes(digest[:8], byteorder="big", signed=False) + return (base ^ salt) & 0xFFFFFFFFFFFFFFFF + + +@dataclass +class _Vocabulary: + """Surface-form → canonical-token grouping built during ``build()``. + + `canonical_to_surfaces[c]` is the set of lowercased surface-form + tokens the corpus has used for canonical token ``c``. ``c`` itself + is always a member of its own surface-form set (the canonical + self-recovery case). + """ + + canonical_to_surfaces: dict[str, set[str]] = field(default_factory=dict) + + def add(self, canonical: str, surface: str) -> None: + bucket = self.canonical_to_surfaces.setdefault(canonical, set()) + bucket.add(surface) + + def canonicals(self) -> list[str]: + return sorted(self.canonical_to_surfaces.keys()) + + def surface_forms(self, canonical: str) -> list[str]: + return sorted(self.canonical_to_surfaces.get(canonical, set())) + + def all_surfaces(self) -> list[str]: + out: set[str] = set() + for surfaces in self.canonical_to_surfaces.values(): + out.update(surfaces) + return sorted(out) + + +@dataclass +class VocabBridge: + """In-memory vocabulary bridge over a ``MemoryStore``. + + Build is offline (one walk over beliefs + their incoming + anchor-text edges); rewrite is one ``unbind`` and one + cleanup-memory query per query token. Storage cost is dominated + by the surface-form ``(N_surfaces, dim)`` matrix at ``8 * + N_surfaces * dim`` bytes; at ``N=10k, dim=2048`` that is ~160 MB. + + Determinism: ``random_vector`` draws are reproducible from + ``np.random.default_rng(seed)``. Two builds against the same store + path with an unchanged corpus produce a byte-identical + ``bridge_vec`` (acceptance #3 in the spec). + """ + + dim: int = DEFAULT_DIM + seed: int = 0 + canonicals: list[str] = field(default_factory=lambda: []) + surfaces: list[str] = field(default_factory=lambda: []) + canonical_vecs: dict[str, Vector] = field(default_factory=lambda: {}) + token_vecs: dict[str, Vector] = field(default_factory=lambda: {}) + bridge_vec: Vector = field( + default_factory=lambda: np.zeros(0, dtype=np.float64), + ) + _cleanup_matrix: Vector | None = field(default=None, init=False) + _cleanup_norms: Vector | None = field(default=None, init=False) + + # ------------------------------------------------------------------ + # Build + # ------------------------------------------------------------------ + + def build( + self, + store: MemoryStore, + *, + store_path: str | None = None, + seed: int | None = None, + ) -> None: + """Walk the store and materialise ``bridge_vec`` plus the + canonical-vec / token-vec tables. + + ``seed`` (explicit) wins over ``store_path`` (derived); if + neither is supplied, the dataclass field's ``seed`` is used. + Re-running ``build`` over the same store with the same seed + produces a byte-identical ``bridge_vec``. + """ + if seed is None and store_path is not None: + seed = _seed_from_path(store_path) + if seed is not None: + self.seed = seed + + vocab = self._harvest(store) + self.canonicals = vocab.canonicals() + self.surfaces = vocab.all_surfaces() + + if not self.canonicals: + self.canonical_vecs = {} + self.token_vecs = {} + self.bridge_vec = np.zeros(self.dim, dtype=np.float64) + self._cleanup_matrix = None + self._cleanup_norms = None + return + + # Two Generators, mirroring the id / role stream split at + # ``hrr_index.py:148-149``. Adding a new surface form in a + # later build does not rotate the canonical-vec stream. + canonical_rng = np.random.default_rng(self.seed) + token_rng = np.random.default_rng(self.seed ^ _TOKEN_SALT) + + self.canonical_vecs = { + c: random_vector(self.dim, canonical_rng) for c in self.canonicals + } + self.token_vecs = { + s: random_vector(self.dim, token_rng) for s in self.surfaces + } + + bridge = np.zeros(self.dim, dtype=np.float64) + for c in self.canonicals: + cv = self.canonical_vecs[c] + for s in vocab.surface_forms(c): + tv = self.token_vecs[s] + bridge += bind(tv, cv) + self.bridge_vec = bridge + + # Cleanup memory: one row per canonical, used by + # ``rewrite()`` to map a recovered vector back to a string. + # We materialise the matrix and per-row norms once so each + # rewrite call avoids the ``CleanupMemory.query`` rebuild. + matrix = np.array( + [self.canonical_vecs[c] for c in self.canonicals], + dtype=np.float64, + ) + norms = np.linalg.norm(matrix, axis=1).astype(np.float64) + norms = np.where(norms > 0, norms, 1.0).astype(np.float64) + self._cleanup_matrix = matrix + self._cleanup_norms = norms + + def _harvest(self, store: MemoryStore) -> _Vocabulary: + """Walk the store and build the surface-form → canonical map. + + Three sources, in priority order (per spec § Build): + + 1. Anchor-text under ``iter_incoming_anchor_text()`` — same + field BM25F (#148) consumes. + 2. Belief content tokens — extracted via the entity-index + lane fallback (the ``beliefs.entity_id`` column does not + yet exist on the v2.0.0 schema; spec open-question 1 + accepts this fallback). + 3. Lock-asserted statements (``Belief.lock_state == LOCK_USER``) + — included via source #2 with a single membership pass. + + Sources are unioned, not weighted: the same canonical may + receive surface forms from any of the three. Belief / edge + iteration order is the store's; ``surface_forms()`` returns + sorted output downstream so build-determinism is preserved. + """ + vocab = _Vocabulary() + + def _ingest(text: str) -> None: + for ent in extract_entities(text): + # Single-token canonicals only. Multi-word noun phrases + # from ``KIND_NOUN_PHRASE`` are dropped — the bridge + # rewrites query tokens, not query spans, so a phrase + # canonical can never be self-recovered by a single + # query token. Identifiers, file paths, URLs, error + # codes, versions, and branches all pass this filter. + low = ent.lower.strip() + if not low or " " in low: + continue + vocab.add(low, low) + # Plain word tokens from BM25 tokenize as a fallback so + # corpora without identifier-shaped names still produce a + # canonical surface. Tokens shorter than 3 chars are + # dropped — one- and two-letter words bind generically and + # bloat the bridge with no recoverable signal. + for tok in bm25_tokenize(text): + if len(tok) < 3: + continue + vocab.add(tok, tok) + + # Source #1: incoming anchor text on every edge. + for _dst, anchor_text in store.iter_incoming_anchor_text(): + _ingest(anchor_text) + + # Source #2 + #3: belief content (locked beliefs included). + for bid in store.list_belief_ids(): + belief = store.get_belief(bid) + if belief is None: + continue + _ingest(belief.content) + # Source #3 is a flag on top of source #2 in this MVP. The + # spec lists it as a separate source for traceability; + # weighting locked surfaces is deferred to a future spec + # revision (open question 1). + if belief.lock_level == LOCK_USER: + pass + + return vocab + + # ------------------------------------------------------------------ + # Rewrite + # ------------------------------------------------------------------ + + def rewrite( + self, + query: str, + *, + top_k: int = DEFAULT_TOP_K, + min_score: float | None = None, + ) -> str: + """Append canonical-entity rewrites to the query. + + Returns ``query + " " + " ".join(appended)``. The original + query is preserved verbatim — bridged candidates are + appended, never substituted, so downstream lanes see no + regression on already-canonical tokens. + + Tokens unseen at build time short-circuit; tokens that are + themselves canonical self-recover and are appended once; + tokens with no canonical above ``min_score`` drop silently. + """ + if not self.canonicals or self.bridge_vec.size == 0: + return query + if top_k <= 0: + return query + threshold = min_score if min_score is not None else self.noise_floor() + + appended: list[str] = [] + appended_set: set[str] = set() + # Canonical tokens already present in the raw query do not + # need rebridging — skip them so the rewriter is idempotent + # over its own output. + already_in_query: set[str] = set(bm25_tokenize(query)) + + for token in bm25_tokenize(query): + tv = self.token_vecs.get(token) + if tv is None: + continue + recovered = unbind(tv, self.bridge_vec) + for canonical, score in self._cleanup_query(recovered, top_k): + if score < threshold: + continue + if canonical in appended_set: + continue + if canonical in already_in_query: + continue + appended.append(canonical) + appended_set.add(canonical) + + if not appended: + return query + return f"{query} {' '.join(appended)}" + + def _cleanup_query( + self, probe: Vector, top_k: int, + ) -> list[tuple[str, float]]: + """Top-K canonical labels by cosine similarity to ``probe``. + + Inlined cleanup-memory query — the matrix and norms are + materialised once at build time so we skip + ``CleanupMemory.query``'s lazy-rebuild check. + """ + if self._cleanup_matrix is None or self._cleanup_norms is None: + return [] + if not self.canonicals: + return [] + probe_norm = float(np.linalg.norm(probe)) + if probe_norm == 0: + return [] + normalized = self._cleanup_matrix / self._cleanup_norms[:, np.newaxis] + sims = (normalized @ (probe / probe_norm)).astype(np.float64) + n = len(self.canonicals) + k = min(top_k, n) + if k <= 0: + return [] + if k == n: + order = np.argsort(-sims) + else: + top_idx = np.argpartition(-sims, k - 1)[:k] + order = top_idx[np.argsort(-sims[top_idx])] + return [ + (self.canonicals[int(i)], float(sims[int(i)])) for i in order + ] + + def noise_floor(self) -> float: + """Per-bound-pair orthogonal-noise magnitude (``~1/sqrt(dim)``). + + Same convention as :meth:`HRRStructIndex.noise_floor`. Probe + scores below this floor carry no signal; the rewrite step uses + it as the default ``min_score``. + """ + return 1.0 / float(np.sqrt(self.dim)) + + # ------------------------------------------------------------------ + # Inspection + # ------------------------------------------------------------------ + + def size(self) -> int: + """Number of canonical entities in the bridge.""" + return len(self.canonicals) + + +@dataclass +class VocabBridgeCache: + """Lazy, invalidation-aware wrapper around a single ``VocabBridge``. + + Subscribes to the store's invalidation callback registry on + construction, so any belief / edge mutation drops the cached + bridge. The next ``get()`` rebuilds. + + Per-instance: two caches pointing at different stores never share + state. Thread safety is the caller's responsibility (matches the + contract of :class:`aelfrice.bm25.BM25IndexCache`). + """ + + store: MemoryStore + dim: int = DEFAULT_DIM + store_path: str | None = None + seed: int | None = None + _bridge: VocabBridge | None = field(default=None, init=False, repr=False) + _subscribed: bool = field(default=False, init=False, repr=False) + + def __post_init__(self) -> None: + if not self._subscribed: + self.store.add_invalidation_callback(self.invalidate) + self._subscribed = True + + def get(self) -> VocabBridge: + """Return the current bridge, building or rebuilding as needed.""" + if self._bridge is None: + bridge = VocabBridge(dim=self.dim) + bridge.build(self.store, store_path=self.store_path, seed=self.seed) + self._bridge = bridge + return self._bridge + + def invalidate(self) -> None: + """Drop the cached bridge. Wired to the store mutation hook.""" + self._bridge = None diff --git a/tests/bench_gate/test_vocab_bridge_uplift.py b/tests/bench_gate/test_vocab_bridge_uplift.py new file mode 100644 index 000000000..7a4dd59bf --- /dev/null +++ b/tests/bench_gate/test_vocab_bridge_uplift.py @@ -0,0 +1,135 @@ +"""Bench gate for #433 HRR vocabulary bridge. + +Measures the upstream invariant A2 depends on: on a labeled +``vocab_bridge`` corpus, a query that *should* recover canonical +entities (via the corpus's expected_canonicals annotation) gets at +least one of those canonicals appended by ``VocabBridge.rewrite`` +above the noise floor. + +A2's strict NDCG@k claim requires running the full retrieve_v2 path +against the labeled corpus and computing recall against ground-truth +relevant beliefs. That is the lab-side budget-rewrite-style follow-up; +this gate only checks the precondition (the bridge actually augments +queries it should augment) so a regression in harvest or rewrite logic +trips before the heavier NDCG measurement runs. + +Skips on public CI (autouse `bench_gated` marker handles +``AELFRICE_CORPUS_ROOT`` absence). Skips again when the +``tests/corpus/v2_0/vocab_bridge/`` directory is empty. + +Expected row schema (``tests/corpus/v2_0/vocab_bridge/*.jsonl``): + + { + "id": "row-id", + "query": "raw query string passed to retrieve", + "store_beliefs": [ + {"id": "b1", "content": "...", "anchors": ["text", "..."]}, + ... + ], + "expected_canonicals": ["sqlite", "python", "..."] + } + +`store_beliefs[i].anchors` is optional; when present, each anchor +string is added as an inbound edge from a synthetic citing belief +to seed the bridge with anchor-source surface forms (#148 parity). +`expected_canonicals` is the set of canonical-entity tokens that +the bridge SHOULD append for this query; the test asserts at least +one is present in the rewritten output. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from aelfrice.models import ( + BELIEF_FACTUAL, + LOCK_NONE, + Belief, + Edge, +) +from aelfrice.store import MemoryStore +from aelfrice.vocab_bridge import VocabBridge +from tests.conftest import load_corpus_module + + +def _mk_belief(bid: str, content: str) -> Belief: + return Belief( + id=bid, + content=content, + content_hash=f"h_{bid}", + alpha=1.0, + beta=1.0, + type=BELIEF_FACTUAL, + lock_level=LOCK_NONE, + locked_at=None, + demotion_pressure=0, + created_at="2026-05-08T00:00:00Z", + last_retrieved_at=None, + retention_class="fact", + ) + + +def _seed_store(store_beliefs: list[dict[str, Any]]) -> MemoryStore: + s = MemoryStore(":memory:") + for entry in store_beliefs: + bid = str(entry["id"]) + content = str(entry["content"]) + s.insert_belief(_mk_belief(bid, content)) + # Second pass for anchors so the citing-belief id space is stable + # before edge insertion. Each anchor seeds one inbound edge from a + # synthetic "anchor citer" so iter_incoming_anchor_text() yields + # the surface form. + for entry in store_beliefs: + anchors = entry.get("anchors") or [] + for j, anchor in enumerate(anchors): + citer_id = f"_a_{entry['id']}_{j}" + if not s.get_belief(citer_id): + s.insert_belief(_mk_belief(citer_id, "anchor citer")) + s.insert_edge(Edge( + src=citer_id, + dst=str(entry["id"]), + type="CITES", + weight=1.0, + anchor_text=str(anchor), + )) + return s + + +@pytest.mark.bench_gated +def test_bridge_appends_at_least_one_expected_canonical( + aelfrice_corpus_root: Path, +) -> None: + rows = load_corpus_module(aelfrice_corpus_root, "vocab_bridge") + + n_rows = 0 + n_hits = 0 + for row in rows: + store = _seed_store(row["store_beliefs"]) + bridge = VocabBridge() + bridge.build(store, store_path=f"/bench/{row['id']}") + rewritten = bridge.rewrite(str(row["query"])) + appended_tokens = set(rewritten.split()) - set(str(row["query"]).split()) + expected = {str(c).lower() for c in row.get("expected_canonicals", [])} + if not expected: + continue + n_rows += 1 + if appended_tokens & expected: + n_hits += 1 + + if n_rows == 0: + pytest.skip( + "vocab_bridge corpus has no rows with expected_canonicals; " + "annotate at least one row before this gate can fire" + ) + + # Loose threshold: a regression in harvest or rewrite that drops + # ALL bridging is caught immediately. Tightening to a per-row + # threshold (or NDCG@k uplift) is the A2-strict follow-up. + coverage = n_hits / float(n_rows) + assert coverage >= 0.5, ( + f"vocab_bridge appended ≥1 expected canonical on only " + f"{n_hits}/{n_rows} rows ({coverage:.1%}); harvest/rewrite " + f"regression suspected (was the surface-form pipeline broken?)" + ) diff --git a/tests/test_vocab_bridge.py b/tests/test_vocab_bridge.py new file mode 100644 index 000000000..32f1d9829 --- /dev/null +++ b/tests/test_vocab_bridge.py @@ -0,0 +1,283 @@ +"""Unit tests for HRR vocabulary bridge (#433). + +Covers the algebraic invariants — self-recovery cosine, build +determinism from a path-derived seed, token-universe filter, +empty-store no-op — without depending on small-corpus cross-talk +magnitudes (which are inherent to HRR and corpus-size-dependent; +the bench gate checks signal-to-noise on a real corpus). +""" +from __future__ import annotations + +import numpy as np +import pytest + +from aelfrice.hrr import unbind +from aelfrice.models import ( + BELIEF_FACTUAL, + LOCK_NONE, + LOCK_USER, + Belief, + Edge, +) +from aelfrice.store import MemoryStore +from aelfrice.vocab_bridge import ( + DEFAULT_TOP_K, + VocabBridge, + _seed_from_path, +) + + +def _mk_belief( + bid: str, + content: str, + *, + lock_level: str = LOCK_NONE, +) -> Belief: + return Belief( + id=bid, + content=content, + content_hash=f"h_{bid}", + alpha=1.0, + beta=1.0, + type=BELIEF_FACTUAL, + lock_level=lock_level, + locked_at=None, + demotion_pressure=0, + created_at="2026-05-08T00:00:00Z", + last_retrieved_at=None, + retention_class="fact", + ) + + +def _populate(store: MemoryStore) -> None: + store.insert_belief(_mk_belief("b1", "SQLite is the storage substrate.")) + store.insert_belief(_mk_belief("b2", "Python provides the language.")) + store.insert_belief(_mk_belief("b3", "Numpy provides matrix algebra.")) + store.insert_belief( + _mk_belief("b4", "User locked SQLite as canonical.", lock_level=LOCK_USER) + ) + + +# --- Build determinism ------------------------------------------------- + + +def test_empty_store_builds_no_op_bridge() -> None: + s = MemoryStore(":memory:") + vb = VocabBridge() + vb.build(s, store_path="/tmp/empty") + assert vb.size() == 0 + assert vb.canonicals == [] + assert vb.surfaces == [] + # No-op rewrite: empty bridge returns the query verbatim. + assert vb.rewrite("anything") == "anything" + + +def test_build_is_deterministic_given_same_path() -> None: + s = MemoryStore(":memory:") + _populate(s) + a = VocabBridge() + a.build(s, store_path="/tmp/det-a") + b = VocabBridge() + b.build(s, store_path="/tmp/det-a") + assert a.canonicals == b.canonicals + assert a.surfaces == b.surfaces + np.testing.assert_array_equal(a.bridge_vec, b.bridge_vec) + for c in a.canonicals: + np.testing.assert_array_equal(a.canonical_vecs[c], b.canonical_vecs[c]) + + +def test_build_differs_across_paths() -> None: + s = MemoryStore(":memory:") + _populate(s) + a = VocabBridge() + a.build(s, store_path="/tmp/det-a") + b = VocabBridge() + b.build(s, store_path="/tmp/det-b") + # Canonicals/surfaces are corpus-derived, identical across paths. + assert a.canonicals == b.canonicals + # bridge_vec should differ (different seed → different vectors). + assert not np.array_equal(a.bridge_vec, b.bridge_vec) + + +def test_seed_from_path_is_64_bit_and_stable() -> None: + s1 = _seed_from_path("/store/path") + s2 = _seed_from_path("/store/path") + assert s1 == s2 + assert 0 <= s1 <= 0xFFFFFFFFFFFFFFFF + # Different paths fan out. + assert _seed_from_path("/other") != s1 + # XOR salt fans out further. + assert _seed_from_path("/store/path", salt=0xDEAD) != s1 + + +def test_explicit_seed_overrides_store_path() -> None: + s = MemoryStore(":memory:") + _populate(s) + a = VocabBridge() + a.build(s, store_path="/tmp/a", seed=42) + b = VocabBridge() + b.build(s, store_path="/tmp/b", seed=42) + np.testing.assert_array_equal(a.bridge_vec, b.bridge_vec) + + +# --- Algebraic invariants ---------------------------------------------- + + +def test_self_recovery_cosine_above_noise_floor() -> None: + """unbind(token_vec[t], bridge_vec) should recover canonical_vec[t] + above the noise floor when t is itself a canonical surface form.""" + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/recover") + floor = vb.noise_floor() + # Every canonical that is its own surface form (the standard MVP + # case) should self-recover above the noise floor. + for c in vb.canonicals: + if c not in vb.token_vecs: + continue + recovered = unbind(vb.token_vecs[c], vb.bridge_vec) + norm = float(np.linalg.norm(recovered)) + assert norm > 0.0 + cv = vb.canonical_vecs[c] + cosine = float( + (recovered / norm) @ (cv / float(np.linalg.norm(cv))) + ) + assert cosine > floor, ( + f"self-recovery for {c!r} cosine {cosine:.4f} <= floor " + f"{floor:.4f}" + ) + + +def test_unseen_token_does_not_appear_in_rewrite() -> None: + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/unseen") + out = vb.rewrite("xxnotpresentxx") + # Unseen tokens short-circuit — query is preserved. + assert out == "xxnotpresentxx" + + +def test_short_tokens_below_three_chars_drop() -> None: + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/short") + # Query "is the" has tokens "is" and "the", but only "the" is + # ≥3 chars and gets harvested; "is" is filtered out. + assert "is" not in vb.canonicals + # "the" passes the filter. + assert "the" in vb.canonicals or "the" in vb.surfaces + + +def test_rewrite_preserves_original_query() -> None: + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/preserve") + out = vb.rewrite("sqlite", top_k=3) + # The original token is always the first whitespace-separated + # member of the rewritten query. + assert out.split()[0] == "sqlite" + + +def test_rewrite_is_deterministic() -> None: + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/det-rewrite") + a = vb.rewrite("sqlite python", top_k=3) + b = vb.rewrite("sqlite python", top_k=3) + assert a == b + + +def test_high_min_score_drops_all_appendees() -> None: + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/strict") + # Threshold 1.5 is unreachable; nothing passes — query verbatim. + assert vb.rewrite("sqlite", min_score=1.5) == "sqlite" + + +def test_top_k_zero_short_circuits() -> None: + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/topk0") + assert vb.rewrite("sqlite", top_k=0) == "sqlite" + + +def test_token_vecs_match_canonical_vecs_for_self_canonicals() -> None: + """In the MVP, every canonical is its own surface form. Their + token_vec and canonical_vec are different draws (different + Generators), so they must NOT be equal.""" + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/distinct-streams") + for c in vb.canonicals: + tv = vb.token_vecs.get(c) + cv = vb.canonical_vecs.get(c) + if tv is None or cv is None: + continue + assert not np.array_equal(tv, cv), ( + f"{c!r} token_vec and canonical_vec collided — " + f"streams should be distinct" + ) + + +# --- Anchor-text harvest ------------------------------------------------ + + +def test_anchor_text_is_a_surface_source() -> None: + s = MemoryStore(":memory:") + s.insert_belief(_mk_belief("b1", "Storage layer details.")) + s.insert_belief(_mk_belief("b2", "Performance discussion.")) + # Add an edge with anchor_text mentioning a token absent from + # belief contents — the bridge should still harvest it. + s.insert_edge( + Edge(src="b2", dst="b1", type="CITES", weight=1.0, + anchor_text="see also: dynamodb migration notes"), + ) + vb = VocabBridge() + vb.build(s, store_path="/tmp/anchor") + # "dynamodb" is harvested from anchor_text only. + assert "dynamodb" in vb.canonicals or "dynamodb" in vb.surfaces + + +# --- Inspection / boundary -------------------------------------------- + + +def test_size_reflects_canonical_count() -> None: + s = MemoryStore(":memory:") + _populate(s) + vb = VocabBridge() + vb.build(s, store_path="/tmp/size") + assert vb.size() == len(vb.canonicals) + assert vb.size() > 0 + + +def test_default_top_k_is_three() -> None: + # Spec: "Default `3`." + assert DEFAULT_TOP_K == 3 + + +def test_noise_floor_matches_one_over_sqrt_dim() -> None: + vb = VocabBridge(dim=2048) + expected = 1.0 / float(np.sqrt(2048)) + assert vb.noise_floor() == pytest.approx(expected) + + +def test_rebuild_overwrites_previous_state() -> None: + s = MemoryStore(":memory:") + s.insert_belief(_mk_belief("b1", "First content with database tokens.")) + vb = VocabBridge() + vb.build(s, store_path="/tmp/rebuild") + first_canonicals = list(vb.canonicals) + # Add a new belief and rebuild. + s.insert_belief(_mk_belief("b2", "Second content with cache tokens.")) + vb.build(s, store_path="/tmp/rebuild") + assert set(vb.canonicals) >= set(first_canonicals) + assert "cache" in vb.canonicals diff --git a/tests/test_vocab_bridge_integration.py b/tests/test_vocab_bridge_integration.py new file mode 100644 index 000000000..0ef1cd62f --- /dev/null +++ b/tests/test_vocab_bridge_integration.py @@ -0,0 +1,241 @@ +"""Integration tests for #433 HRR vocabulary bridge in retrieve_v2. + +Covers flag-precedence resolution, the deprecated `use_hrr` alias, the +`vocab_bridge_cache` injection point, and the byte-identical default- +OFF path. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aelfrice.models import ( + BELIEF_FACTUAL, + LOCK_NONE, + Belief, +) +from aelfrice.retrieval import ( + ENV_VOCAB_BRIDGE, + resolve_use_vocab_bridge, + retrieve_v2, +) +from aelfrice.store import MemoryStore +from aelfrice.vocab_bridge import VocabBridgeCache + + +def _mk(bid: str, content: str) -> Belief: + return Belief( + id=bid, + content=content, + content_hash=f"h_{bid}", + alpha=1.0, + beta=1.0, + type=BELIEF_FACTUAL, + lock_level=LOCK_NONE, + locked_at=None, + demotion_pressure=0, + created_at="2026-05-08T00:00:00Z", + last_retrieved_at=None, + retention_class="fact", + ) + + +@pytest.fixture +def _no_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(ENV_VOCAB_BRIDGE, raising=False) + + +@pytest.fixture +def _isolated_cwd(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _populate_store() -> MemoryStore: + s = MemoryStore(":memory:") + s.insert_belief(_mk("b1", "SQLite is the storage substrate.")) + s.insert_belief(_mk("b2", "Python is the language used.")) + s.insert_belief(_mk("b3", "Numpy provides matrix algebra.")) + return s + + +# --- Flag resolution --------------------------------------------------- + + +def test_default_is_off(_no_env: None, _isolated_cwd: Path) -> None: + assert resolve_use_vocab_bridge() is False + + +def test_explicit_kwarg_overrides_default( + _no_env: None, _isolated_cwd: Path, +) -> None: + assert resolve_use_vocab_bridge(True) is True + assert resolve_use_vocab_bridge(False) is False + + +def test_env_overrides_explicit_kwarg( + monkeypatch: pytest.MonkeyPatch, _isolated_cwd: Path, +) -> None: + monkeypatch.setenv(ENV_VOCAB_BRIDGE, "1") + assert resolve_use_vocab_bridge(False) is True + monkeypatch.setenv(ENV_VOCAB_BRIDGE, "0") + assert resolve_use_vocab_bridge(True) is False + + +def test_env_garbage_falls_through( + monkeypatch: pytest.MonkeyPatch, _isolated_cwd: Path, +) -> None: + monkeypatch.setenv(ENV_VOCAB_BRIDGE, "maybe") + assert resolve_use_vocab_bridge() is False + assert resolve_use_vocab_bridge(True) is True + + +def test_toml_resolves_when_kwarg_and_env_unset( + _no_env: None, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + cfg = tmp_path / ".aelfrice.toml" + cfg.write_text("[retrieval]\nuse_vocab_bridge = true\n") + monkeypatch.chdir(tmp_path) + assert resolve_use_vocab_bridge() is True + + +# --- retrieve_v2 byte-identity (default-OFF path) ---------------------- + + +def test_default_call_byte_identical_to_pre_bridge( + _no_env: None, _isolated_cwd: Path, +) -> None: + """Default-OFF path: bridge does not run, so beliefs returned match + a control retrieve_v2 call without any vocab-bridge kwargs.""" + s = _populate_store() + a = retrieve_v2(s, "sqlite storage") + b = retrieve_v2(s, "sqlite storage") + assert [b_.id for b_ in a.beliefs] == [b_.id for b_ in b.beliefs] + + +def test_explicit_off_byte_identical_to_default( + _no_env: None, _isolated_cwd: Path, +) -> None: + s = _populate_store() + default_call = retrieve_v2(s, "sqlite storage") + off_call = retrieve_v2(s, "sqlite storage", use_vocab_bridge=False) + assert [b.id for b in default_call.beliefs] == [ + b.id for b in off_call.beliefs + ] + + +# --- retrieve_v2 with bridge ON ---------------------------------------- + + +def test_flag_on_does_not_raise( + _no_env: None, _isolated_cwd: Path, +) -> None: + """Smoke: flag-ON path completes without raising and returns a + well-shaped RetrievalResult. The bridge cannot empirically + *improve* recall on a 3-belief fixture (cross-talk dominates at + small N), so this test only verifies structural correctness.""" + s = _populate_store() + result = retrieve_v2(s, "sqlite storage", use_vocab_bridge=True) + assert hasattr(result, "beliefs") + assert isinstance(result.beliefs, list) + + +def test_env_var_alone_enables_bridge( + monkeypatch: pytest.MonkeyPatch, _isolated_cwd: Path, +) -> None: + """Env var alone (no kwarg, no TOML) opts retrieve_v2 into the + bridge. We can't assert visibly different output on a small + fixture; we verify the call completes and structure is valid.""" + monkeypatch.setenv(ENV_VOCAB_BRIDGE, "1") + s = _populate_store() + result = retrieve_v2(s, "sqlite") + assert isinstance(result.beliefs, list) + + +# --- Deprecated `use_hrr` alias --------------------------------------- + + +def test_use_hrr_true_routes_to_bridge( + _no_env: None, _isolated_cwd: Path, +) -> None: + """`use_hrr=True` is the deprecated alias path — it should opt the + call into the bridge identically to `use_vocab_bridge=True`. Test + by checking the result-shape is consistent across both paths.""" + s = _populate_store() + via_alias = retrieve_v2(s, "sqlite", use_hrr=True) + via_canonical = retrieve_v2(s, "sqlite", use_vocab_bridge=True) + # Same belief id set (order-insensitive) — both paths invoked the + # bridge and the lane fan-out saw the same widened query. + assert {b.id for b in via_alias.beliefs} == { + b.id for b in via_canonical.beliefs + } + + +def test_use_vocab_bridge_overrides_use_hrr( + _no_env: None, _isolated_cwd: Path, +) -> None: + """When both are passed, use_vocab_bridge wins (canonical name + beats deprecated alias).""" + s = _populate_store() + # use_vocab_bridge=False, use_hrr=True → canonical False wins. + result = retrieve_v2( + s, "sqlite", use_hrr=True, use_vocab_bridge=False, + ) + # Identical to a flag-OFF call. + control = retrieve_v2(s, "sqlite", use_vocab_bridge=False) + assert [b.id for b in result.beliefs] == [b.id for b in control.beliefs] + + +def test_use_hrr_none_does_not_force_bridge( + _no_env: None, _isolated_cwd: Path, +) -> None: + """Explicit `use_hrr=None` (the new default) does NOT route to the + bridge — it falls through to use_vocab_bridge resolution, which is + OFF by default.""" + s = _populate_store() + result = retrieve_v2(s, "sqlite", use_hrr=None) + control = retrieve_v2(s, "sqlite") + assert [b.id for b in result.beliefs] == [b.id for b in control.beliefs] + + +# --- VocabBridgeCache injection --------------------------------------- + + +def test_explicit_cache_is_used( + _no_env: None, _isolated_cwd: Path, +) -> None: + """An explicit cache is consulted (not bypassed) when the flag is + on. We verify by warming the cache, then checking that a follow-up + retrieve_v2 with the same cache returns the same result shape.""" + s = _populate_store() + cache = VocabBridgeCache(store=s, store_path="/tmp/explicit-cache") + # Pre-warm. + cache.get() + a = retrieve_v2( + s, "sqlite", use_vocab_bridge=True, vocab_bridge_cache=cache, + ) + b = retrieve_v2( + s, "sqlite", use_vocab_bridge=True, vocab_bridge_cache=cache, + ) + assert [x.id for x in a.beliefs] == [x.id for x in b.beliefs] + + +def test_cache_invalidates_on_store_mutation( + _no_env: None, _isolated_cwd: Path, +) -> None: + s = _populate_store() + cache = VocabBridgeCache(store=s) + bridge_v1 = cache.get() + canonicals_v1 = list(bridge_v1.canonicals) + # Mutate: add a new belief; the invalidation hook should drop the + # cached bridge so the next get() rebuilds. + s.insert_belief(_mk("b4", "Redis can serve as a cache layer.")) + bridge_v2 = cache.get() + # Cache rebuilt — bridge object identity changed, and the new + # bridge picks up the new canonicals. + assert bridge_v2 is not bridge_v1 + assert "redis" in bridge_v2.canonicals + assert set(canonicals_v1).issubset(set(bridge_v2.canonicals))