diff --git a/CHANGELOG.md b/CHANGELOG.md index c1add398..aca88a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ installable release; see the roadmap in [README.md](README.md). - **PreCompact hook + rebuild logic, augment mode** ([#139](https://github.com/robotrocketscience/aelfrice/issues/139), [docs/context_rebuilder.md](docs/context_rebuilder.md)). v1.4.0 milestone: replaces the v1.2.0a0 alpha's per-token union retrieval workaround with the v1.3 `retrieve()` codepath (L0 + L1 + L2.5 in one call). New `aelfrice.context_rebuilder.rebuild_v14()` pure function packs L0 locked beliefs first (full, never trimmed), then session-scoped beliefs whose `session_id` matches the latest transcript turn's session, then the L2.5/L1 tail from `retrieve()` — within a configurable token budget. Query string is built from entity + triple extraction over the recent-turn window (no LLM). `aelfrice.context_rebuilder.main()` is the new module-level Claude Code PreCompact hook entry point; `aelfrice.hook.pre_compact()` continues to dispatch through the same logic and now wraps output in the harness's `hookSpecificOutput.additionalContext` JSON envelope. New `[rebuilder] turn_window_n` and `[rebuilder] token_budget` keys in `.aelfrice.toml` (defaults 50 and 4000); CLI flags on `aelf rebuild --n` / `--budget` override per-call. `aelf rebuild` now drives the same `rebuild_v14()` codepath the hook uses. `aelf setup --rebuilder` (already shipped at v1.2.0a0) installs the PreCompact hook idempotently. Augment-mode only: both the harness's compaction summary and the rebuild block land in the new context. Suppress mode is parked for v2.x. Empty-transcript / missing-store edge cases exit 0 with no `additionalContext` written. Reproducible: same transcript tail + same store state → byte-identical envelope. Latency budget: median ≤ 200 ms on a 10k-belief store; measured ~2 ms on a workstation. 15 new deterministic tests in `tests/test_context_rebuilder_hook.py` cover ordering, edge cases, reproducibility, latency, the `[rebuilder]` config parser, the JSON envelope shape, and session-scoping invariants. +- **Partial Bayesian-weighted ranking (v1.3.0)** ([#146](https://github.com/robotrocketscience/aelfrice/issues/146), [docs/bayesian_ranking.md](docs/bayesian_ranking.md)). L1 BM25 ranking now consumes the Beta-Bernoulli posterior log-additively per the spec's adopted Path B contract: `score = log(-bm25_raw) + posterior_weight * log(posterior_mean(α, β))`. `posterior_weight` defaults to `0.5` (the synthetic-graph optimum from the v1.3 calibration); `0.0` reproduces v1.0.x BM25-only ordering byte-for-byte (regression-tested). Locked beliefs (L0) bypass scoring entirely; L2.5 entity-index hits and L3 BFS expansions are unaffected — the weight only reranks the L1 candidate set. New `scoring.partial_bayesian_score(bm25_raw, alpha, beta, posterior_weight)` reuses `scoring.posterior_mean` (Jeffreys prior `α / (α+β)`); the Laplace `(α+1) / (α+β+2)` form sketched in #151 is explicitly rejected at this layer per spec rationale. New `MemoryStore.search_beliefs_scored(query, limit) -> list[tuple[Belief, float]]` exposes the FTS5 BM25 score; `MemoryStore.search_beliefs` is unchanged. `retrieve()`, `retrieve_with_tiers()`, and `retrieve_v2()` gain a `posterior_weight: float | None` kwarg. New `aelfrice.retrieval.resolve_posterior_weight()` resolves precedence env > kwarg > TOML > default; `AELFRICE_POSTERIOR_WEIGHT=` env override and `[retrieval] posterior_weight = ` in `.aelfrice.toml`. Negative values clamp to `0.0`. `bm25 == 0` (FTS5 non-match) is floored at `PARTIAL_BAYESIAN_BM25_FLOOR = 1e-12` so `log(0)` cannot raise. `RetrievalCache` key tuple gains `posterior_weight` (rounded to four decimals via `POSTERIOR_WEIGHT_KEY_PRECISION`) so two callers passing different weights against the same store do not collide; cache invalidation is unchanged — `apply_feedback`'s `store.update_belief()` already triggers `_fire_invalidation()` and wipes the cache, no new hook in `apply_feedback`. 22 deterministic acceptance tests in `tests/test_bayesian_ranking.py` cover the 14-criterion spec (byte-identical v1.0.x at weight 0.0; equal-BM25 reranked by posterior DESC; high-BM25/low-posterior dethroned by low-BM25/high-posterior; one `apply_feedback(+1)` round promotes a rank-3 belief to ≤ 2 at default weight; lock bypass invariant across weights; cold-belief neutrality at all-prior corpus; cache hit/miss matrix; cache wiped through store callback without direct `cache.invalidate()`; bm25=0 edge case finite). Full feedback-into-ranking eval (10-round MRR uplift, ECE calibration, BM25F + heat-kernel composition, real-feedback retest) lands at v2.0.0. + ### Fixed - **`project-warm`: sentinel debounce keyed off git-common-dir, not worktree path** ([#161](https://github.com/robotrocketscience/aelfrice/issues/161)). Previously `_project_id` was derived from `git rev-parse --show-toplevel`, giving each worktree of the same repo a distinct sentinel under `~/.aelfrice/projects//.last_warm`. Two worktrees of one repo share a single DB (via `git-common-dir`), so they should share one sentinel. `resolve_project_root` now calls `git rev-parse --path-format=absolute --show-toplevel --git-common-dir` in a single subprocess and keys `ProjectRef.id` off the git-common-dir while keeping `ProjectRef.root` as the worktree working directory (for `os.chdir` in `_warm_store`). New test `test_resolve_project_root_worktrees_share_id` verifies that two worktrees of one repo produce identical `ProjectRef.id` values. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d4579596..216bb06b 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. At v1.3.0 there are two knobs: the entity-index (L2.5) flag and the BFS multi-hop (L3) flag. +- `[retrieval]` (v1.3+) — retrieval-time tier toggles + ranking. At v1.3.0 there are three knobs: the entity-index (L2.5) flag, the BFS multi-hop (L3) flag, and `posterior_weight` for partial Bayesian-weighted L1 ranking. Locks, hooks, MCP tools, and the Bayesian feedback math are not affected. @@ -50,6 +50,14 @@ entity_index_enabled = true # 0.10 path-score floor; shares the unified token budget. bfs_enabled = false +# v1.3+. Default 0.5. Posterior-weighted ranking on the L1 BM25 +# tier: score = log(-bm25) + posterior_weight * log(posterior_mean). +# Set to 0.0 to reproduce v1.0.x BM25-only ordering byte-for-byte. +# AELFRICE_POSTERIOR_WEIGHT env var overrides; explicit kwargs on +# retrieve() / retrieve_v2() override TOML in turn. Locked beliefs +# (L0) bypass scoring entirely. +posterior_weight = 0.5 + [onboard.llm] # v1.3.0+. Opt in to the LLM-Haiku classifier at onboard time. # Default: false. Requires the [onboard-llm] extra and the @@ -174,6 +182,30 @@ Precedence (first decisive wins): env var `AELFRICE_ENTITY_INDEX=0` > explicit P The on-write index is always populated regardless of this flag — disabling only affects reads. Re-enabling sees an up-to-date index without a backfill pass. +### `posterior_weight` + +Float ≥ 0, default `0.5` at v1.3.0. Combines the L1 BM25 score with the Beta-Bernoulli posterior mean log-additively: + +``` +score = log(-bm25_raw) + posterior_weight * log(posterior_mean(α, β)) +``` + +`-bm25_raw` flips SQLite FTS5's signed score to positive (smaller-magnitude-negative is better in SQLite; we negate before taking `log`). `posterior_mean(α, β) = α / (α+β)` reuses the existing scoring helper — Jeffreys prior, reads `0.5` for unobserved beliefs. + +Behaviour at the boundaries: + +- **`0.0`** — score collapses to `log(-bm25_raw)`, byte-identical to v1.0.x `ORDER BY bm25(beliefs_fts)` ordering. Use for diff-tooling and bisection. +- **`0.5`** (default) — synthetic-graph optimum from the v1.3 calibration. Posterior moves rank without overwhelming BM25. +- **`> 1.0`** — posterior dominates; high-confidence beliefs surface even on weak keyword matches. Useful when feedback density is high and BM25 noise is the limiting factor. + +Locked beliefs (L0) bypass scoring entirely; the weight only reranks the L1 BM25 candidate set. L2.5 entity-index hits and L3 BFS expansions are unaffected. + +Precedence (first decisive wins): env var `AELFRICE_POSTERIOR_WEIGHT=` > explicit Python kwarg `posterior_weight=` on `retrieve()` / `retrieve_v2()` > TOML `[retrieval] posterior_weight` > default `0.5`. + +Negative values clamp to `0.0`. Non-numeric env values trace to stderr and fall through. The cache key is extended with the resolved weight (rounded to four decimals), so two callers passing different weights against the same store do not collide on a shared `RetrievalCache`. + +The full feedback-into-ranking eval — 10-round MRR uplift, ECE calibration, BM25F + heat-kernel composition — lands at v2.0.0. See [`docs/bayesian_ranking.md`](bayesian_ranking.md) for the v1.3 contract and the rejected-alternatives analysis. + ### `bfs_enabled` Boolean, default `false` at v1.3.0. Toggles the L3 BFS multi-hop graph traversal retrieval tier. diff --git a/src/aelfrice/retrieval.py b/src/aelfrice/retrieval.py index b8835b72..8f9aef78 100644 --- a/src/aelfrice/retrieval.py +++ b/src/aelfrice/retrieval.py @@ -67,6 +67,10 @@ ) from aelfrice.entity_extractor import extract_entities from aelfrice.models import LOCK_NONE, Belief +from aelfrice.scoring import ( + DEFAULT_POSTERIOR_WEIGHT, + partial_bayesian_score, +) from aelfrice.store import MemoryStore # v1.0 / v1.2 baseline. Used by the disabled-flag fallback so the @@ -95,6 +99,7 @@ RETRIEVAL_SECTION: Final[str] = "retrieval" ENTITY_INDEX_FLAG: Final[str] = "entity_index_enabled" BFS_FLAG: Final[str] = "bfs_enabled" +POSTERIOR_WEIGHT_FLAG: Final[str] = "posterior_weight" # Env var override. Set to "0", "false", or "no" to force-disable # the index. Unset / any other value falls through to the TOML @@ -106,6 +111,16 @@ # default-off contract means the env-var omission is the same as # the explicit-off case. ENV_BFS: Final[str] = "AELFRICE_BFS" +# 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 +# layer (kwarg → TOML → DEFAULT_POSTERIOR_WEIGHT) and trace to +# stderr. Same shape as `_read_toml_flag_for` tolerance. +ENV_POSTERIOR_WEIGHT: Final[str] = "AELFRICE_POSTERIOR_WEIGHT" +# Number of decimal places used to round `posterior_weight` before +# inclusion in the cache key. Two callers passing weights that +# differ by less than this granularity collapse to the same key. +POSTERIOR_WEIGHT_KEY_PRECISION: Final[int] = 4 _ENV_FALSY: Final[frozenset[str]] = frozenset({"0", "false", "no", "off"}) _ENV_TRUTHY: Final[frozenset[str]] = frozenset({"1", "true", "yes", "on"}) @@ -247,6 +262,128 @@ def _read_toml_flag_for( return None +def _read_toml_float_for( + key: str, + start: Path | None = None, +) -> float | None: + """Walk up from `start` looking for a `.aelfrice.toml` with + `[retrieval] ` typed as int or float. Returns the float + value when found, or None when no file / no key. + + Tolerant: a malformed TOML or wrong-typed value returns None + and traces to stderr without raising. Mirrors + `_read_toml_flag_for` semantics but accepts numeric types. + """ + serr: IO[str] = sys.stderr + current = (start if start is not None else Path.cwd()).resolve() + seen: set[Path] = set() + while current not in seen: + seen.add(current) + candidate = current / CONFIG_FILENAME + if candidate.is_file(): + try: + raw = candidate.read_bytes() + except OSError as exc: + print( + f"aelfrice retrieval: cannot read {candidate}: {exc}", + file=serr, + ) + return None + try: + parsed: dict[str, Any] = tomllib.loads( + raw.decode("utf-8", errors="replace"), + ) + except tomllib.TOMLDecodeError as exc: + print( + f"aelfrice retrieval: malformed TOML in {candidate}: {exc}", + file=serr, + ) + return None + section_obj: Any = parsed.get(RETRIEVAL_SECTION, {}) + if not isinstance(section_obj, dict): + return None + if key not in section_obj: # type: ignore[operator] + return None + value: Any = section_obj[key] # type: ignore[index] + # bool is a subclass of int -- reject it explicitly so + # `posterior_weight = true` reads as malformed rather + # than silently coercing to 1.0. + if isinstance(value, bool): + print( + f"aelfrice retrieval: ignoring [{RETRIEVAL_SECTION}] " + f"{key} in {candidate} (expected number, got bool)", + file=serr, + ) + return None + if isinstance(value, (int, float)): + return float(value) + print( + f"aelfrice retrieval: ignoring [{RETRIEVAL_SECTION}] " + f"{key} in {candidate} (expected number)", + file=serr, + ) + return None + if current.parent == current: + break + current = current.parent + return None + + +def _env_posterior_weight() -> float | None: + """Return the AELFRICE_POSTERIOR_WEIGHT env value as a float, + or None when unset / non-numeric. + + Non-numeric values trace to stderr and fall through (same + fail-soft contract as the TOML readers). + """ + raw = os.environ.get(ENV_POSTERIOR_WEIGHT) + if raw is None: + return None + stripped = raw.strip() + if not stripped: + return None + try: + return float(stripped) + except ValueError: + print( + f"aelfrice retrieval: ignoring {ENV_POSTERIOR_WEIGHT}={raw!r} " + f"(expected float)", + file=sys.stderr, + ) + return None + + +def resolve_posterior_weight( + explicit: float | None = None, + *, + start: Path | None = None, +) -> float: + """Resolve the posterior weight per v1.3 precedence: + + 1. AELFRICE_POSTERIOR_WEIGHT env var (float, including 0.0). + 2. Explicit `explicit` kwarg from the caller. + 3. `[retrieval] posterior_weight` in `.aelfrice.toml`. + 4. Default: DEFAULT_POSTERIOR_WEIGHT (0.5 at v1.3.0). + + A weight of `0.0` is treated as "BM25-only" (the byte-identical- + with-v1.0.x ordering case); negative weights are clamped to + 0.0 since the spec defines the contract for weight ≥ 0 only. + """ + env = _env_posterior_weight() + if env is not None: + weight = env + elif explicit is not None: + weight = float(explicit) + else: + toml_value = _read_toml_float_for(POSTERIOR_WEIGHT_FLAG, start) + weight = float(toml_value) if toml_value is not None else ( + DEFAULT_POSTERIOR_WEIGHT + ) + if weight < 0.0: + return 0.0 + return weight + + def is_entity_index_enabled( explicit: bool | None = None, *, @@ -348,6 +485,45 @@ def _l25_hits( return out +def _l1_hits( + store: MemoryStore, + query: str, + *, + l1_limit: int, + posterior_weight: float, +) -> list[Belief]: + """Run L1: FTS5 BM25 search, optionally reranked by partial- + Bayesian score. + + `posterior_weight = 0.0` short-circuits to the v1.0.x path — + `store.search_beliefs(query, limit)` returns rows already + ordered by `bm25(beliefs_fts)` ascending, and we discard the + score. This guarantees byte-identical ordering with the v1.0 + ranker. + + `posterior_weight > 0` swaps in the scored variant and re- + sorts by `partial_bayesian_score(...)` descending. The + underlying SQL ORDER BY keeps the BM25 prefilter deterministic + in the truncation case (rare-but-possible at small `l1_limit`). + Tie-break on belief id ASC so result lists are reproducible. + """ + if posterior_weight == 0.0: + return store.search_beliefs(query, limit=l1_limit) + scored = store.search_beliefs_scored(query, limit=l1_limit) + if not scored: + return [] + keyed: list[tuple[float, str, Belief]] = [] + for b, bm25_raw in scored: + s = partial_bayesian_score( + bm25_raw, b.alpha, b.beta, posterior_weight, + ) + keyed.append((s, b.id, b)) + # Higher score = more relevant. Tie-break on id ASC for + # determinism (matches the convention in bfs_multihop and L2.5). + keyed.sort(key=lambda x: (-x[0], x[1])) + return [b for _, _, b in keyed] + + def retrieve( store: MemoryStore, query: str, @@ -363,6 +539,7 @@ def retrieve( bfs_nodes_per_hop: int = BFS_DEFAULT_NODES_PER_HOP, bfs_total_budget_nodes: int = BFS_DEFAULT_TOTAL_BUDGET_NODES, bfs_min_path_score: float = BFS_DEFAULT_MIN_PATH_SCORE, + posterior_weight: float | None = None, ) -> list[Belief]: """Return L0 locked + L2.5 entity + L1 BM25 + L3 BFS expansions. @@ -383,6 +560,15 @@ def retrieve( order until the shared token budget is exhausted. When disabled, output is byte-identical to the L0+L2.5+L1 path. + `posterior_weight` (v1.3.0): float ≥ 0. Combines the L1 BM25 + score with the Beta-Bernoulli posterior_mean log-additively: + `score = log(-bm25) + posterior_weight * log(posterior_mean)`. + `0.0` collapses to v1.0.x BM25-only ordering (byte-identical + regression-tested). Default `0.5` per docs/bayesian_ranking.md + § Defaults; resolved via `resolve_posterior_weight()` (env → + kwarg → TOML → 0.5). L0 locks bypass the score entirely; L2.5 + and L3 are unaffected. + Empty / whitespace-only query: returns L0 only (no L2.5, L1, or L3). @@ -395,6 +581,7 @@ def retrieve( """ enabled = is_entity_index_enabled(entity_index_enabled) bfs_on = is_bfs_enabled(bfs_enabled) + weight = resolve_posterior_weight(posterior_weight) locked: list[Belief] = store.list_locked_beliefs() locked_ids: set[str] = {b.id for b in locked} @@ -433,7 +620,10 @@ def retrieve( l1: list[Belief] = [] if query.strip(): - raw_l1: list[Belief] = store.search_beliefs(query, limit=l1_limit) + raw_l1: list[Belief] = _l1_hits( + store, query, + l1_limit=l1_limit, posterior_weight=weight, + ) l1 = [ b for b in raw_l1 if b.id not in locked_ids and b.id not in l25_ids @@ -495,6 +685,7 @@ def retrieve_with_tiers( bfs_nodes_per_hop: int = BFS_DEFAULT_NODES_PER_HOP, bfs_total_budget_nodes: int = BFS_DEFAULT_TOTAL_BUDGET_NODES, bfs_min_path_score: float = BFS_DEFAULT_MIN_PATH_SCORE, + posterior_weight: float | None = None, ) -> tuple[ list[Belief], list[str], list[str], list[str], list[list[str]], ]: @@ -512,6 +703,7 @@ def retrieve_with_tiers( """ enabled = is_entity_index_enabled(entity_index_enabled) bfs_on = is_bfs_enabled(bfs_enabled) + weight = resolve_posterior_weight(posterior_weight) locked: list[Belief] = store.list_locked_beliefs() locked_ids_list: list[str] = [b.id for b in locked] @@ -542,7 +734,10 @@ def retrieve_with_tiers( l1: list[Belief] = [] if query.strip(): - raw_l1: list[Belief] = store.search_beliefs(query, limit=l1_limit) + raw_l1: list[Belief] = _l1_hits( + store, query, + l1_limit=l1_limit, posterior_weight=weight, + ) l1 = [ b for b in raw_l1 if b.id not in locked_ids and b.id not in l25_ids @@ -602,6 +797,7 @@ def retrieve_v2( bfs_nodes_per_hop: int = BFS_DEFAULT_NODES_PER_HOP, bfs_total_budget_nodes: int = BFS_DEFAULT_TOTAL_BUDGET_NODES, bfs_min_path_score: float = BFS_DEFAULT_MIN_PATH_SCORE, + posterior_weight: float | None = None, ) -> RetrievalResult: """Lab-compatible retrieval wrapper for academic-suite adapters. @@ -645,6 +841,7 @@ def retrieve_v2( bfs_nodes_per_hop=bfs_nodes_per_hop, bfs_total_budget_nodes=bfs_total_budget_nodes, bfs_min_path_score=bfs_min_path_score, + posterior_weight=posterior_weight, ) if include_locked: beliefs = out @@ -667,14 +864,20 @@ class RetrievalCache: the cache. Per-instance: two `RetrievalCache` objects pointing at different stores never share state. - Cache key includes both the entity-index flag (v1.3.0 default-on) - and the BFS flag (v1.3.0 default-off). Two queries that differ - only in either flag are distinct entries. The BFS knobs - (`bfs_max_depth` etc.) are NOT in the key — per + Cache key includes the entity-index flag (v1.3.0 default-on), + the BFS flag (v1.3.0 default-off), and `posterior_weight` + (v1.3.0 default 0.5, rounded to `POSTERIOR_WEIGHT_KEY_PRECISION` + decimals so floating-point jitter does not fragment the cache). + Two queries that differ in any of these are distinct entries. + BFS knobs (`bfs_max_depth` etc.) are NOT in the key — per docs/bfs_multihop.md § Cache invalidation, callers that toggle - them per call would defeat the cache anyway, and the default-off - flag means a single process either uses BFS for every retrieval - or none. + them per call would defeat the cache anyway. + + The `posterior_weight` cache-key extension is a structural fix + against cross-caller collisions per docs/bayesian_ranking.md § + "Cache invalidation". Posterior-write staleness is handled by + the existing store-mutation callback (apply_feedback -> + update_belief -> _fire_invalidation -> cache wipe). """ def __init__( @@ -687,7 +890,10 @@ def __init__( self._store = store self._capacity = capacity self._entries: OrderedDict[ - tuple[str, int, int, bool | None, bool | None], list[Belief] + tuple[ + str, int, int, bool | None, bool | None, float | None, + ], + list[Belief], ] = OrderedDict() store.add_invalidation_callback(self.invalidate) @@ -699,14 +905,30 @@ def retrieve( *, entity_index_enabled: bool | None = None, bfs_enabled: bool | None = None, + posterior_weight: float | None = None, ) -> list[Belief]: - """Cached `retrieve()`. Identical contract to the free function.""" + """Cached `retrieve()`. Identical contract to the free function. + + Cache key keeps `posterior_weight` in its caller-supplied + form (None or a float) — `None` is its own bucket and + deferred env / TOML resolution happens once on the miss + path. Resolving on every hit would walk Path.cwd().resolve() + each time and blow the AC2 cache-hit latency budget. + """ + if posterior_weight is None: + key_weight: float | None = None + else: + key_weight = round( + float(posterior_weight), + POSTERIOR_WEIGHT_KEY_PRECISION, + ) key = ( canonicalize_query(query), token_budget, l1_limit, entity_index_enabled, bfs_enabled, + key_weight, ) cached = self._entries.get(key) if cached is not None: @@ -717,6 +939,7 @@ def retrieve( token_budget=token_budget, l1_limit=l1_limit, entity_index_enabled=entity_index_enabled, bfs_enabled=bfs_enabled, + posterior_weight=posterior_weight, ) self._entries[key] = list(result) if len(self._entries) > self._capacity: diff --git a/src/aelfrice/scoring.py b/src/aelfrice/scoring.py index 12719736..64c16e83 100644 --- a/src/aelfrice/scoring.py +++ b/src/aelfrice/scoring.py @@ -9,13 +9,46 @@ Lock-floor: when a belief's lock_level is "user", decay() is a no-op regardless of age (zero work, sharp step). Above the floor decay is exponential toward the Jeffreys prior (0.5, 0.5). + +v1.3.0 partial Bayesian-weighted ranking +----------------------------------------- + +`partial_bayesian_score(bm25_raw, alpha, beta, posterior_weight)` +combines an FTS5 BM25 score (SQLite signs it non-positive: smaller += better) with the existing Beta-Bernoulli posterior mean log- +additively, per `docs/bayesian_ranking.md` § Algorithm: + + score = log(max(-bm25_raw, EPS)) + + posterior_weight * log(posterior_mean(alpha, beta)) + +The first term flips SQLite's BM25 sign so `log()` is defined +(SQLite returns `0` for non-matches; an `EPS` floor keeps that +case finite without crashing). At `posterior_weight = 0.0` the +second term is zero and ranking collapses to `log(-bm25_raw)`, +which is monotone with `-bm25_raw` ascending — i.e., byte- +identical to the v1.0.x `ORDER BY bm25(beliefs_fts)` ordering. The +Jeffreys prior (0.5, 0.5) is preserved at the ranking layer; do +not introduce a Laplace `(α+1) / (α+β+2)` form here. See the spec +for the rejected-alternative analysis. """ from __future__ import annotations +import math from typing import Final from aelfrice.models import LOCK_USER, Belief +# Numerical floor for the BM25-side log term. SQLite FTS5 returns +# `0.0` for non-matches and very small magnitudes (~1e-6) for +# weak matches; the floor protects against `log(0)` while sitting +# well below any matched-document score on practical corpora. +PARTIAL_BAYESIAN_BM25_FLOOR: Final[float] = 1e-12 + +# v1.3.0 default weight on the posterior_mean log term. Picked +# from #151's synthetic-graph calibration (NDCG@10 ≈ 0.95 at +# λ=0.5; collapses to 0.91 at λ=1.0; minimal effect at λ=0.0). +DEFAULT_POSTERIOR_WEIGHT: Final[float] = 0.5 + # --- Half-lives in seconds --- _HOUR: Final[float] = 3600.0 TYPE_HALF_LIFE_SECONDS: Final[dict[str, float]] = { @@ -88,3 +121,43 @@ def relevance(belief: Belief, query_overlap_score: float) -> float: and other layered weights are deferred to a later release. """ return posterior_mean(belief.alpha, belief.beta) * query_overlap_score + + +def partial_bayesian_score( + bm25_raw: float, + alpha: float, + beta: float, + posterior_weight: float = DEFAULT_POSTERIOR_WEIGHT, +) -> float: + """v1.3 partial Bayesian-weighted retrieval score. + + `score = log(max(-bm25_raw, EPS)) + posterior_weight * log(posterior_mean)` + + `bm25_raw` is FTS5's signed score (non-positive: SQLite returns + smaller-magnitude-negative for stronger matches). We negate to + get a positive relevance magnitude before taking `log`. `EPS` + (`PARTIAL_BAYESIAN_BM25_FLOOR`) prevents `log(0)` for non- + matches without contaminating any real-match ordering. + + `posterior_weight = 0.0` collapses the second term to zero and + makes the score a monotone function of `-bm25_raw` — byte- + identical to v1.0.x `ORDER BY bm25(beliefs_fts)`. + + `posterior_mean` reuses the existing module-level helper, which + returns `α / (α + β)` (Jeffreys prior, reads 0.5 for unobserved + beliefs). Do not switch to Laplace at this layer — the prior + must agree with `aelf stats`, the MCP, and `decay()`. + + Higher score = more relevant (matches the convention used by + sort-descending callers). + """ + relevance_pos = max(-bm25_raw, PARTIAL_BAYESIAN_BM25_FLOOR) + log_bm25 = math.log(relevance_pos) + if posterior_weight == 0.0: + return log_bm25 + p = posterior_mean(alpha, beta) + # `posterior_mean` returns 0.5 in the degenerate (alpha+beta<=0) + # case, so `p > 0` is guaranteed; floor defensively for the + # pathological `alpha = 0` operator-fed case to avoid `log(0)`. + p_safe = p if p > 0.0 else PARTIAL_BAYESIAN_BM25_FLOOR + return log_bm25 + posterior_weight * math.log(p_safe) diff --git a/src/aelfrice/store.py b/src/aelfrice/store.py index c34aa359..c26b0b03 100644 --- a/src/aelfrice/store.py +++ b/src/aelfrice/store.py @@ -604,6 +604,44 @@ def search_beliefs(self, query: str, limit: int = 20) -> list[Belief]: ) return [_row_to_belief(r) for r in cur.fetchall()] + def search_beliefs_scored( + self, query: str, limit: int = 20, + ) -> list[tuple[Belief, float]]: + """FTS5 keyword search returning `(belief, bm25_score)` pairs. + + Sibling of `search_beliefs`. Same MATCH escaping, same ordering + (ascending by `bm25(beliefs_fts)`, which SQLite returns as a + non-positive number — smaller = more relevant). The raw FTS5 + BM25 score is exposed for callers that need to compose it with + other signals (e.g. v1.3 partial Bayesian-weighted ranking, + which combines `log(-bm25)` with `log(posterior_mean)` log- + additively). + + Empty / whitespace-only queries return [] without hitting + FTS5. + """ + escaped = _escape_fts5_query(query) + if not escaped: + return [] + cur = self._conn.execute( + """ + SELECT b.*, bm25(beliefs_fts) AS bm25_score + FROM beliefs b + JOIN beliefs_fts f ON f.id = b.id + WHERE beliefs_fts MATCH ? + ORDER BY bm25(beliefs_fts) + LIMIT ? + """, + (escaped, limit), + ) + rows = cur.fetchall() + out: list[tuple[Belief, float]] = [] + for r in rows: + score_obj = r["bm25_score"] + score = float(score_obj) if score_obj is not None else 0.0 + out.append((_row_to_belief(r), score)) + return out + # --- Feedback history ------------------------------------------------ def insert_feedback_event( diff --git a/tests/test_bayesian_ranking.py b/tests/test_bayesian_ranking.py new file mode 100644 index 00000000..d0ed7100 --- /dev/null +++ b/tests/test_bayesian_ranking.py @@ -0,0 +1,493 @@ +"""Acceptance tests for v1.3.0 partial Bayesian-weighted ranking +(`docs/bayesian_ranking.md`, issue #146). + +One test per acceptance criterion. All deterministic, in-memory +SQLite, ≤2s per test, no probabilistic assertions. +""" +from __future__ import annotations + +import math +import tempfile +import time +from pathlib import Path + +import pytest + +from aelfrice.feedback import apply_feedback +from aelfrice.models import BELIEF_FACTUAL, LOCK_NONE, LOCK_USER, Belief +from aelfrice.retrieval import ( + POSTERIOR_WEIGHT_KEY_PRECISION, + RetrievalCache, + resolve_posterior_weight, + retrieve, + retrieve_v2, +) +from aelfrice.scoring import ( + DEFAULT_POSTERIOR_WEIGHT, + PARTIAL_BAYESIAN_BM25_FLOOR, + partial_bayesian_score, + posterior_mean, +) +from aelfrice.store import MemoryStore + + +# --- Fixtures ------------------------------------------------------------- + + +def _mk( + bid: str, + content: str, + *, + alpha: float = 1.0, + beta: float = 1.0, + lock_level: str = LOCK_NONE, + locked_at: str | None = None, +) -> Belief: + return Belief( + id=bid, + content=content, + content_hash=f"h_{bid}", + alpha=alpha, + beta=beta, + type=BELIEF_FACTUAL, + lock_level=lock_level, + locked_at=locked_at, + demotion_pressure=0, + created_at="2026-04-26T00:00:00Z", + last_retrieved_at=None, + ) + + +def _equal_bm25_store() -> MemoryStore: + """Five beliefs with the same surface form but distinct + posteriors. Identical token bag (one occurrence of "widget" + each, with a unique id-padding word) so SQLite FTS5 BM25 ties + them at the same score against `widget`. + """ + s = MemoryStore(":memory:") + # alpha grows -> posterior_mean rises. beta=1.0 fixed. + # Insertion order is reversed-alphabetical; this guarantees + # the v1.0.x BM25-only path returns them in store-driven + # order (NOT in posterior order), so the posterior-driven + # rerank is observable. + s.insert_belief(_mk("e_one", "widget echo unit", alpha=1.0)) + s.insert_belief(_mk("d_two", "widget delta gear", alpha=2.0)) + s.insert_belief(_mk("c_thr", "widget gamma cog", alpha=3.0)) + s.insert_belief(_mk("b_fou", "widget beta cam", alpha=4.0)) + s.insert_belief(_mk("a_fiv", "widget alpha rod", alpha=5.0)) + return s + + +# --- AC1: posterior_weight kwarg accepted by both retrieve surfaces ------ + + +def test_ac1_retrieve_and_retrieve_v2_accept_posterior_weight() -> None: + s = _equal_bm25_store() + out1 = retrieve(s, "widget", posterior_weight=0.5) + out2 = retrieve_v2(s, "widget", posterior_weight=0.5) + assert isinstance(out1, list) + assert all(isinstance(b, Belief) for b in out1) + assert isinstance(out2.beliefs, list) + # Both surfaces accept the new kwarg without raising. + assert len(out1) >= 1 + assert len(out2.beliefs) >= 1 + + +# --- AC2: posterior_weight=0.0 is byte-identical to v1.0.x ordering ------ + + +def test_ac2_weight_zero_byte_identical_to_v10x() -> None: + """The most important regression test: at weight 0 the result + list is identical to what `store.search_beliefs(...)` returns + for the L1 portion. (L0 prefix is unaffected by weight.) + """ + s = _equal_bm25_store() + direct = s.search_beliefs("widget", limit=50) + weighted = retrieve(s, "widget", token_budget=10_000, posterior_weight=0.0) + # The retrieve() output may include an L0 prefix; here the + # store has no locked beliefs, so the lists must match + # byte-for-byte. + assert [b.id for b in weighted] == [b.id for b in direct] + + +# --- AC3: equal-BM25 beliefs are reranked by posterior_mean DESC --------- + + +def test_ac3_equal_bm25_orders_by_posterior_descending() -> None: + s = _equal_bm25_store() + out = retrieve(s, "widget", token_budget=10_000, posterior_weight=0.5) + ids = [b.id for b in out] + # alpha=5,4,3,2,1 -> posterior_mean 5/6, 4/5, 3/4, 2/3, 1/2. + # Tied (or near-tied) BM25 + descending posterior -> a_fiv first. + assert ids[0] == "a_fiv" + # And b_fou (alpha=4) ranks ahead of e_one (alpha=1). + assert ids.index("b_fou") < ids.index("e_one") + + +# --- AC4: high-BM25-low-posterior can drop below low-BM25-high-posterior - + + +def test_ac4_posterior_can_overcome_bm25_gap() -> None: + """A high-BM25-low-posterior belief drops below a low-BM25- + high-posterior belief once the posterior gap is large enough. + + Constructed at the L1 layer only — entity-index (L2.5) is + disabled so the BM25 ranker is the sole ordering signal at + weight=0.0. At weight=2.0 the strong-posterior belief wins. + """ + s = MemoryStore(":memory:") + s.insert_belief(_mk( + "F_high", "spruce", # short doc, strong BM25 + alpha=1.0, beta=1.0, # prior, posterior_mean = 0.5 + )) + s.insert_belief(_mk( + "F_low", + # long doc with one 'spruce' mention -> length normalization + # pushes its BM25 score below F_high's. + "spruce surrounded by oaks elms maples birches pines firs cedars junipers " + "willows aspens beeches alders hawthorns dogwoods blackthorns hazels rowans", + alpha=200.0, beta=1.0, # posterior_mean ≈ 0.995 + )) + base = retrieve( + s, "spruce", token_budget=10_000, posterior_weight=0.0, + entity_index_enabled=False, + ) + base_ids = [b.id for b in base] + # Sanity: BM25-only ordering puts F_high first. + assert base_ids.index("F_high") < base_ids.index("F_low") + + # With a strong posterior weight, F_low jumps above F_high. + boosted = retrieve( + s, "spruce", token_budget=10_000, posterior_weight=2.0, + entity_index_enabled=False, + ) + boosted_ids = [b.id for b in boosted] + assert boosted_ids.index("F_low") < boosted_ids.index("F_high") + + +# --- AC5: apply_feedback promotes a previously-mid-rank belief ---------- + + +def test_ac5_apply_feedback_promotes_mid_rank_belief() -> None: + """Calibration regression: a belief at rank R≥2 in baseline + promotes to rank ≤R-1 after one positive feedback event. + """ + s = _equal_bm25_store() + base = retrieve( + s, "widget", token_budget=10_000, posterior_weight=DEFAULT_POSTERIOR_WEIGHT, + ) + base_ids = [b.id for b in base] + # Pick a belief at rank ≥ 2. + target = base_ids[2] # 0-index 2 -> rank 3 + # Apply one positive feedback event. + apply_feedback(s, target, valence=+5.0, source="test_ac5") + after = retrieve( + s, "widget", token_budget=10_000, posterior_weight=DEFAULT_POSTERIOR_WEIGHT, + ) + after_ids = [b.id for b in after] + base_rank = base_ids.index(target) + 1 + after_rank = after_ids.index(target) + 1 + assert base_rank >= 2, f"baseline rank too low to test: {base_rank}" + assert after_rank <= base_rank - 1, ( + f"feedback failed to promote: was {base_rank}, now {after_rank}" + ) + + +# --- AC6: cache key includes posterior_weight (hit / miss matrix) -------- + + +def test_ac6_cache_key_includes_posterior_weight() -> None: + s = _equal_bm25_store() + cache = RetrievalCache(s) + cache.retrieve("widget", posterior_weight=0.5) + assert len(cache) == 1 + # Same query, different weight -> miss + new entry. + cache.retrieve("widget", posterior_weight=1.0) + assert len(cache) == 2 + # Same weight again -> hit, no new entry. + cache.retrieve("widget", posterior_weight=0.5) + assert len(cache) == 2 + # Weight 0.0 is its own bucket (must not collide with default). + cache.retrieve("widget", posterior_weight=0.0) + assert len(cache) == 3 + + +# --- AC7: apply_feedback wipes the cache via the existing callback ------- + + +def test_ac7_apply_feedback_wipes_cache_via_store_callback() -> None: + """apply_feedback must NOT reach into the cache directly. The + wipe comes through store.update_belief -> _fire_invalidation + -> cache.invalidate. + """ + s = _equal_bm25_store() + cache = RetrievalCache(s) + cache.retrieve("widget", posterior_weight=0.5) + assert len(cache) == 1 + target = cache.retrieve("widget", posterior_weight=0.5)[0].id + # Feedback application happens entirely without referencing + # the cache. The wipe must come through the store hook. + apply_feedback(s, target, valence=+1.0, source="test_ac7") + assert len(cache) == 0, "cache should have been invalidated" + + +# --- AC8: locked beliefs unaffected by posterior_weight ------------------ + + +def test_ac8_locked_bypass_invariant_across_weights() -> None: + s = MemoryStore(":memory:") + s.insert_belief(_mk( + "L_a", "user pinned the widget rule first", + lock_level=LOCK_USER, locked_at="2026-04-26T03:00:00Z", + )) + s.insert_belief(_mk( + "L_b", "another locked widget mention", + lock_level=LOCK_USER, locked_at="2026-04-26T01:00:00Z", + )) + s.insert_belief(_mk("F_1", "widget alpha", alpha=10.0)) + s.insert_belief(_mk("F_2", "widget beta", alpha=2.0)) + + locked_position_at = {} + for w in (0.0, 0.5, 1.0): + out = retrieve(s, "widget", token_budget=10_000, posterior_weight=w) + ids = [b.id for b in out] + locked_position_at[w] = (ids.index("L_a"), ids.index("L_b")) + # Both locks come before any non-locked. + non_locked = [i for i, b in enumerate(out) if b.lock_level == LOCK_NONE] + if non_locked: + assert max(ids.index("L_a"), ids.index("L_b")) < min(non_locked) + # Lock positions identical at every weight. + assert ( + locked_position_at[0.0] + == locked_position_at[0.5] + == locked_position_at[1.0] + ), f"lock positions drifted: {locked_position_at}" + + +# --- AC9: cold-belief neutrality at all-prior corpus -------------------- + + +def test_ac9_cold_belief_neutrality_collapses_to_bm25() -> None: + """When every belief has (alpha, beta) = (0.5, 0.5), the + posterior term is a constant log(0.5) added uniformly. Every + score shifts by the same amount; ordering is identical to + weight=0.0. + """ + s = MemoryStore(":memory:") + # Jeffreys prior on every row. + for i, content in enumerate([ + "widget alpha rod brief", + "widget beta cam medium length doc text words", + "widget gamma cog longer document text padded", + "widget delta gear", + ]): + s.insert_belief(_mk( + f"P_{i}", content, alpha=0.5, beta=0.5, + )) + cold = retrieve(s, "widget", token_budget=10_000, posterior_weight=0.5) + bm25_only = retrieve(s, "widget", token_budget=10_000, posterior_weight=0.0) + assert [b.id for b in cold] == [b.id for b in bm25_only] + + +# --- AC10: bm25 == 0 edge case does not crash --------------------------- + + +def test_ac10_bm25_zero_does_not_crash() -> None: + """`partial_bayesian_score` must handle bm25=0 (the FTS5 + non-match return) without raising log(0). The clamp to + PARTIAL_BAYESIAN_BM25_FLOOR keeps the score finite. + """ + s = _equal_bm25_store() + # Trigger a query that returns an empty L1; assert no crash. + out = retrieve(s, "zzznosuchterm", token_budget=10_000, posterior_weight=0.5) + assert out == [] + # Direct call to scoring helper at bm25=0. + score = partial_bayesian_score(0.0, alpha=1.0, beta=1.0, posterior_weight=0.5) + # Score should be finite (not -inf, not nan). + assert score == score # not NaN + assert score < 0.0 # log of small numbers is negative + # And at posterior_weight=0.0 too. + score_z = partial_bayesian_score(0.0, alpha=1.0, beta=1.0, posterior_weight=0.0) + assert score_z == score_z + + +# --- AC11: latency overhead is negligible ------------------------------- + + +def test_ac11_per_query_overhead_within_budget() -> None: + """Posterior reranking must add <1ms per query at the v1 + benchmark size. The synthetic corpus here is small (5 + beliefs); the AC simply asserts the rerank doesn't blow up + against a reasonable wall-clock ceiling. Per-query budget + here is conservative — the spec's 10^5 N latency claim is + measured separately on the benchmark harness. + """ + s = _equal_bm25_store() + # Warm up. + retrieve(s, "widget", posterior_weight=0.5) + # Time best-of-100 to dampen scheduler jitter. + t0 = time.perf_counter() + for _ in range(100): + retrieve(s, "widget", posterior_weight=0.5) + elapsed = time.perf_counter() - t0 + # 100 calls in well under a second on any machine. + assert elapsed < 1.0, f"100 calls took {elapsed:.3f}s -- too slow" + + +# --- AC12 / AC13 / AC14: docs + CI --- + +# AC12 (LIMITATIONS rewrite) and AC13 (ROADMAP link) are checked by +# the docs commit; AC14 (full pytest green) is checked by CI. We +# pin them as content-hash tests below to catch silent reverts. + + +def test_ac12_limitations_md_documents_partial_ranking() -> None: + repo = Path(__file__).resolve().parents[1] + text = (repo / "docs" / "LIMITATIONS.md").read_text(encoding="utf-8") + # The v1.3.0 paragraph must mention the formula and the cache + # invalidation contract. + assert "v1.3.0" in text + assert "posterior" in text + assert any( + marker in text + for marker in ("log(bm25)", "log(BM25)", "log-additive", "log(-bm25)") + ) + + +def test_ac13_roadmap_links_bayesian_ranking_spec() -> None: + repo = Path(__file__).resolve().parents[1] + text = (repo / "docs" / "ROADMAP.md").read_text(encoding="utf-8") + assert "bayesian_ranking.md" in text + + +# --- Default-weight at v1.3.0 --- + + +def test_default_posterior_weight_is_half() -> None: + """Spec: 'v1.3.0 ships posterior_weight = 0.5 as default.' Pin + it so a future PR cannot silently flip the default.""" + assert DEFAULT_POSTERIOR_WEIGHT == 0.5 + + +def test_resolve_posterior_weight_default_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("AELFRICE_POSTERIOR_WEIGHT", raising=False) + # Make TOML resolution stable by pointing to a directory with + # no .aelfrice.toml. + with tempfile.TemporaryDirectory() as td: + weight = resolve_posterior_weight(start=Path(td)) + assert weight == DEFAULT_POSTERIOR_WEIGHT + + +def test_resolve_posterior_weight_env_overrides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AELFRICE_POSTERIOR_WEIGHT", "0.0") + assert resolve_posterior_weight() == 0.0 + monkeypatch.setenv("AELFRICE_POSTERIOR_WEIGHT", "0.7") + assert resolve_posterior_weight() == 0.7 + + +def test_resolve_posterior_weight_explicit_overrides_toml( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("AELFRICE_POSTERIOR_WEIGHT", raising=False) + cfg = tmp_path / ".aelfrice.toml" + cfg.write_text("[retrieval]\nposterior_weight = 0.25\n") + # explicit kwarg wins over TOML (env is unset). + assert resolve_posterior_weight(0.9, start=tmp_path) == 0.9 + # ...and TOML wins when no kwarg. + assert resolve_posterior_weight(start=tmp_path) == 0.25 + + +def test_resolve_posterior_weight_negative_clamps_to_zero() -> None: + assert resolve_posterior_weight(-1.5) == 0.0 + + +# --- Calibration regression: 5-belief synthetic, ≥1 strict promotion --- + + +def _uniform_prior_store() -> MemoryStore: + """Five widget-content beliefs with identical Jeffreys-equivalent + priors (alpha=1, beta=1). Insertion order driven by id ASC so + the BM25-tied ordering is deterministic. + """ + s = MemoryStore(":memory:") + s.insert_belief(_mk("a_fiv", "widget alpha rod", alpha=1.0)) + s.insert_belief(_mk("b_fou", "widget beta cam", alpha=1.0)) + s.insert_belief(_mk("c_thr", "widget gamma cog", alpha=1.0)) + s.insert_belief(_mk("d_two", "widget delta gear", alpha=1.0)) + s.insert_belief(_mk("e_one", "widget echo unit", alpha=1.0)) + return s + + +def test_calibration_one_round_feedback_promotes_at_least_one_belief() -> None: + """The spec's 'aelf bench --partial-uplift' minimum: ≥ 1 + strict rank promotion after one round of synthetic feedback. + + Synthetic shape: 5 beliefs with uniform Jeffreys-equivalent + priors. At baseline (weight=0.0) the BM25-tied ordering is + store-determined. After apply_feedback(used) on the rank-3 + belief and re-running at the v1.3 default weight (0.5), that + belief promotes to rank ≤ 2. + """ + s = _uniform_prior_store() + base = retrieve(s, "widget", token_budget=10_000, posterior_weight=0.0) + base_ids = [b.id for b in base] + assert len(base_ids) == 5 + # Pick the rank-3 belief (0-index 2). + target = base_ids[2] + # Single round of synthetic feedback per spec § Calibration. + apply_feedback(s, target, valence=+1.0, source="bench-synthetic") + after = retrieve( + s, "widget", token_budget=10_000, + posterior_weight=DEFAULT_POSTERIOR_WEIGHT, + ) + after_ids = [b.id for b in after] + after_rank = after_ids.index(target) + 1 + assert after_rank <= 2, ( + f"calibration failed: rank-3 belief did not promote to <=2 " + f"(got rank {after_rank}). after_ids={after_ids}" + ) + + +# --- Cache-key precision sanity --- + + +def test_cache_key_precision_constant_is_sane() -> None: + """Round-to-N decimals is enough granularity that two callers + passing 0.5 and 0.5000001 collapse, but 0.5 and 0.6 don't.""" + assert POSTERIOR_WEIGHT_KEY_PRECISION >= 2 + assert POSTERIOR_WEIGHT_KEY_PRECISION <= 10 + + +# --- Posterior-mean reuse pin --- + + +def test_partial_bayesian_score_uses_jeffreys_posterior_mean() -> None: + """Spec rejects Laplace (alpha+1)/(alpha+beta+2) at this layer. + Pin the formula to scoring.posterior_mean = alpha/(alpha+beta). + """ + # alpha=2, beta=1 -> posterior_mean = 2/3 (NOT 3/5 = Laplace). + pm = posterior_mean(2.0, 1.0) + assert abs(pm - (2.0 / 3.0)) < 1e-12 + # And the score uses it. + score = partial_bayesian_score( + bm25_raw=-1.0, # log(1) = 0 on the bm25 side + alpha=2.0, beta=1.0, + posterior_weight=1.0, + ) + expected = math.log(1.0) + 1.0 * math.log(2.0 / 3.0) + assert abs(score - expected) < 1e-12 + + +# --- Floor constant pin (spec § "Numerical safety") --- + + +def test_bm25_floor_is_strictly_positive_and_small() -> None: + """Floor must be > 0 (so log() is finite) and small enough + not to contaminate any real BM25 score (~1e-6 typical).""" + assert PARTIAL_BAYESIAN_BM25_FLOOR > 0.0 + assert PARTIAL_BAYESIAN_BM25_FLOOR < 1e-6