From 93d7ef7c515fe84e064e530c7c22575b7c8fa1ee Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:55:32 -0700 Subject: [PATCH 01/11] feat(retrieval): add MemoryStore.search_beliefs_scored returning (belief, bm25) Sibling of search_beliefs that exposes the raw FTS5 BM25 score per hit (SQLite returns it as a non-positive float, smaller = better). Used by v1.3 partial Bayesian-weighted ranking to combine BM25 with posterior_mean log-additively. The existing search_beliefs surface is unchanged. --- src/aelfrice/store.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/aelfrice/store.py b/src/aelfrice/store.py index c34aa359c..c26b0b03b 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( From 370df45edaa40dfedaae782a006cadb214ae43d8 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:56:21 -0700 Subject: [PATCH 02/11] feat(scoring): add partial_bayesian_score combining BM25 + posterior log-additively Implements the v1.3.0 score function from docs/bayesian_ranking.md: score = log(max(-bm25_raw, EPS)) + posterior_weight * log(posterior_mean(alpha, beta)) - Reuses scoring.posterior_mean (Jeffreys prior). Spec rejects the Laplace (alpha+1)/(alpha+beta+2) sketch from #151. - DEFAULT_POSTERIOR_WEIGHT = 0.5 (the synthetic-graph optimum). - PARTIAL_BAYESIAN_BM25_FLOOR = 1e-12 floors the BM25 log term so bm25 = 0 (non-match) does not raise log(0). - posterior_weight = 0.0 short-circuits to log(-bm25_raw) which is monotone with the SQLite ORDER BY bm25() ascending convention, preserving v1.0.x byte-identical ordering. No retrieval wiring yet -- next commit. --- src/aelfrice/scoring.py | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/aelfrice/scoring.py b/src/aelfrice/scoring.py index 12719736f..64c16e833 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) From 4d498a82f0b60a169870a4ed14248c1e06b20937 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:01:39 -0700 Subject: [PATCH 03/11] feat(retrieval): wire posterior_weight into retrieve / cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/bayesian_ranking.md acceptance criteria 1, 2, 6. retrieve(), retrieve_with_tiers(), retrieve_v2() gain a posterior_weight: float | None kwarg. None triggers precedence resolution via resolve_posterior_weight(): 1. AELFRICE_POSTERIOR_WEIGHT env var (float). 2. Explicit kwarg from caller. 3. [retrieval] posterior_weight in .aelfrice.toml. 4. DEFAULT_POSTERIOR_WEIGHT = 0.5. L1 hits flow through new private _l1_hits() helper: - weight == 0.0: short-circuit to store.search_beliefs() (BM25 ascending), preserving v1.0.x byte-identical ordering. - weight > 0: store.search_beliefs_scored(), score with scoring.partial_bayesian_score(), sort descending. Tie-break on belief id ASC for determinism. Locks (L0), L2.5 entity-index, and L3 BFS are unaffected — the score only reranks the L1 BM25 candidate set. RetrievalCache key gains posterior_weight (rounded to POSTERIOR_WEIGHT_KEY_PRECISION = 4 decimals). The cache key is the caller-supplied weight (None vs float) — env / TOML resolution stays out of the hot hit path so AC2 (50us hit budget) is preserved. Same-weight queries hit; different-weight queries miss. Cache invalidation is unchanged: store mutations (including apply_feedback's update_belief) wipe the cache via the existing _fire_invalidation callback. New TOML reader _read_toml_float_for() parallels the bool reader, with explicit rejection of bool subclass values. --- src/aelfrice/retrieval.py | 245 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 234 insertions(+), 11 deletions(-) diff --git a/src/aelfrice/retrieval.py b/src/aelfrice/retrieval.py index b8835b721..8f9aef78f 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: From 6e69a969c006177ae77d289b8c075abf82b4d61d Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:07:08 -0700 Subject: [PATCH 04/11] test: 22 acceptance tests for partial Bayesian-weighted ranking (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One test per spec acceptance criterion (docs/bayesian_ranking.md § 'Acceptance criteria for the implementation PR'): - AC1 retrieve / retrieve_v2 accept posterior_weight kwarg. - AC2 posterior_weight=0.0 byte-identical to v1.0.x ordering. - AC3 equal-BM25 hits ordered by posterior_mean DESC. - AC4 high-BM25/low-posterior drops below low-BM25/high-posterior. - AC5 apply_feedback promotes a mid-rank belief. - AC6 RetrievalCache key gains posterior_weight (hit/miss matrix). - AC7 apply_feedback wipes cache via store callback (no direct cache.invalidate() call). - AC8 Locked beliefs unaffected at weights {0.0, 0.5, 1.0}. - AC9 Cold-belief neutrality: all-prior corpus collapses to BM25. - AC10 bm25 == 0 edge case does not crash (log(0) floor). - AC11 Per-query overhead within latency budget. - AC12 docs/LIMITATIONS.md documents the partial ranker. - AC13 docs/ROADMAP.md links the spec. Plus pin tests for: - DEFAULT_POSTERIOR_WEIGHT == 0.5. - resolve_posterior_weight precedence (env > kwarg > TOML > default). - Negative weights clamp to 0.0. - Calibration regression: ≥ 1 strict rank promotion after one apply_feedback round on rank-3 belief at default weight. - partial_bayesian_score uses Jeffreys posterior_mean (not Laplace). - BM25 floor constant is positive and small. All deterministic, ≤2s, pass under pyright strict. --- tests/test_bayesian_ranking.py | 493 +++++++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 tests/test_bayesian_ranking.py diff --git a/tests/test_bayesian_ranking.py b/tests/test_bayesian_ranking.py new file mode 100644 index 000000000..d0ed71001 --- /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 From c05d47deaaec6920f6241ffa094997898aa6494f Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:08:27 -0700 Subject: [PATCH 05/11] docs: document posterior_weight in CONFIG.md and CHANGELOG.md (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/CONFIG.md gains [retrieval] posterior_weight section: TOML example, behaviour at the 0.0 / 0.5 / >1.0 boundaries, env-var override, precedence, lock-bypass note, link to the spec. - CHANGELOG.md [Unreleased] entry covers the scoring formula, Path B rationale, fixture / regression coverage, the 22-test acceptance suite, and what v2.0.0 still owes per the spec. docs/LIMITATIONS.md already carried the v1.3.0 paragraph; no change needed there. docs/ROADMAP.md § v1.3.0 already linked docs/bayesian_ranking.md; AC13 satisfied without edit. --- CHANGELOG.md | 2 ++ docs/CONFIG.md | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1add3982..aca88a5f6 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 d45795966..216bb06b7 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. From 53520432f96fc0fcc3edd5ee2f61464ca87b4d91 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:49:16 -0700 Subject: [PATCH 06/11] docs(release): update test count from ~1,150 to ~1,414 (v1.3/v1.4) --- docs/RELEASING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index edcec4c85..7ea8d0f32 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -16,7 +16,7 @@ How to cut a new version. Maintainer reference. 5. Update README roadmap status. 6. Run locally: ```bash - uv run pytest tests/ -x -q # ~1,150 passing at v1.2 (track the actual count in CI) + uv run pytest tests/ -x -q # ~1,414 passing at v1.3/v1.4 (track the actual count in CI) uv run pyright src/ # strict uv run aelf --help # spot-check CLI uv build # wheels build clean From bdac7a55788a40ea41f1886b820a0c2c5b1e3600 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:49:44 -0700 Subject: [PATCH 07/11] =?UTF-8?q?docs(commands):=20surface=20count=2023?= =?UTF-8?q?=E2=86=9224,=20onboard=20LLM=20flags,=20--advanced=20help=20fla?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/COMMANDS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 43491b134..c63963985 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,6 +1,6 @@ # Commands -Twenty-three CLI subcommands. The retrieval/feedback ones are also exposed as MCP tools (see [MCP](MCP.md)) and slash commands (see [SLASH_COMMANDS](SLASH_COMMANDS.md)). Lifecycle commands (`setup`, `doctor`, `migrate`, `upgrade`, `uninstall`, etc.) are CLI-only. +Twenty-four CLI subcommands. The retrieval/feedback ones are also exposed as MCP tools (see [MCP](MCP.md)) and slash commands (see [SLASH_COMMANDS](SLASH_COMMANDS.md)). Lifecycle commands (`setup`, `doctor`, `migrate`, `upgrade`, `uninstall`, etc.) are CLI-only. ``` aelf [args] [options] @@ -14,7 +14,7 @@ DB resolves from `$AELFRICE_DB`, then `/aelfrice/memory.db` when | Command | What it does | |---|---| -| `onboard ` | Walk filesystem, git log, Python AST. Classify candidates, insert non-duplicates. Tunable via `.aelfrice.toml` — see [CONFIG](CONFIG.md). | +| `onboard ` | Walk filesystem, git log, Python AST. Classify candidates, insert non-duplicates. Tunable via `.aelfrice.toml` — see [CONFIG](CONFIG.md). Optional flags (v1.3+): `--llm-classify` (route through Haiku classifier; default-off, requires `ANTHROPIC_API_KEY`), `--dry-run` (preview candidates without inserting; requires `--llm-classify`), `--revoke-consent` (remove the stored consent sentinel and exit). | | `search [--budget N]` | L0 locked + L2.5 entity-index (v1.3+) + L1 FTS5 BM25, token-budgeted (default 2,400 at v1.3+, 2,000 prior). L2.5 default-on; disable via `[retrieval] entity_index_enabled = false` in `.aelfrice.toml` or `AELFRICE_ENTITY_INDEX=0` in the env. Distinguishes "store empty" from "no match". | | `lock ` | Insert at `(α, β) = (9.0, 0.5)` with `lock_level=user`. Idempotent — re-lock upgrades existing. | | `locked [--pressured]` | List locks. With `--pressured`, only those with `demotion_pressure > 0`. | @@ -49,6 +49,10 @@ DB resolves from `$AELFRICE_DB`, then `/aelfrice/memory.db` when | `project-warm [--debounce N]` | CwdChanged hook entry point. Resolves `` to a project root (git work-tree or `~/.aelfrice/projects//`-provisioned ancestor), pre-loads the SQLite + OS page cache, and writes a sentinel under `~/.aelfrice/projects//.last_warm`. Silent no-op for unknown paths, denied paths (default deny: `/tmp/**`, `/var/folders/**`, `~/Downloads/**`, `~/Desktop/**` — override via `~/.aelfrice/config.json` `project_warm.deny_globs`), and any call inside the 60-second debounce window. Always exits 0; never writes to stdout. | | `session-delta [--id ID] [--telemetry-path PATH]` | **Advanced/hidden.** SessionEnd hook entry point. Computes per-session deltas (beliefs created, corrections detected, feedback given, velocity) from beliefs tagged with `--id` in the active store, combines with a current store snapshot (beliefs/graph blocks) and rolling-window rollups from the existing `telemetry.jsonl`, and appends one v=1 JSON row to `PATH` (default `~/.aelfrice/telemetry.jsonl`). Missing or empty `--id` is a silent no-op (stderr warning, exit 0). Idle sessions with zero beliefs still emit a row so `len(telemetry.jsonl)` equals session count. Not shown in `aelf --help`. | +## Help flags + +`aelf --help` shows the everyday surface (visible subcommands). `aelf --help --advanced` (or `aelf --advanced`) shows the full surface including hidden subcommands (`bench`, `feedback`, `health`, `migrate`, `project-warm`, `rebuild`, `regime`, `session-delta`, `stats`, `statusline`, `unsetup`). The `--advanced` flag was wired in v1.4 (PR #174). + ## Output and exit codes - Human-readable on stdout. Errors on stderr. From 0d0e1151038e45467f36760cbc26e84c2379ecd0 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:51:18 -0700 Subject: [PATCH 08/11] docs(architecture): retrieval tiers L2.5/L3/Bayesian, rebuilder section, LLM classifier, spec links, fix counts --- docs/ARCHITECTURE.md | 60 +++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index becb79743..a0e2d3d1b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ How aelfrice fits together. Maps directly to source under `src/aelfrice/`. 1. **Determinism end to end.** Every retrieval result is bit-identical given the same write log and the same code. Every result traces to named beliefs and named rules. See [PHILOSOPHY § Determinism is the property](PHILOSOPHY.md#determinism-is-the-property). 2. **Stdlib + SQLite only.** No vector DB, no embeddings, no LLM in the hot path. The `[mcp]` extra (`fastmcp`) is the only optional runtime dep. -3. **Bayesian, not vibes.** Confidence is `α / (α + β)`. Every update has a closed-form rule. (At v1.0–v1.2 the score does not yet drive ranking — see [LIMITATIONS](LIMITATIONS.md).) +3. **Bayesian, not vibes.** Confidence is `α / (α + β)`. Every update has a closed-form rule. At v1.3.0+ the posterior is combined log-additively with BM25 on the L1 tier — see [LIMITATIONS](LIMITATIONS.md) for what the partial ranking does and doesn't cover. 4. **`apply_feedback` is the central endpoint.** One writer of `(α, β)`. One audit row per successful update. 5. **Locks are user-asserted ground truth.** A user-locked belief short-circuits decay. Contradicting positive feedback accumulates `demotion_pressure`; ≥5 ⇒ auto-demote. @@ -29,7 +29,7 @@ Imports are one-directional — modules lower in the table import from higher. | `models.py` | `Belief`, `Edge`, `FeedbackEvent`, `OnboardSession` dataclasses; type / lock / origin constants. No I/O. | | `scoring.py` | `posterior_mean`, `decay`, `relevance_combiner`. Type half-lives. Lock-floor short-circuit. Decay target: Jeffreys `(0.5, 0.5)`. | | `store.py` | SQLite WAL + FTS5 + CRUD. `propagate_valence` BFS with broker-confidence attenuation. | -| `retrieval.py` | `retrieve(store, query, token_budget=2000)` — L0 locked + L1 FTS5 BM25. L0 never trimmed. | +| `retrieval.py` | `retrieve(store, query, token_budget=2000)` — L0 locked + L2.5 entity-index (v1.3+) + L3 BFS multi-hop (v1.3+, default-off) + L1 FTS5 BM25 with Bayesian log-additive reranking (v1.3+). L0 never trimmed. | | `feedback.py` | `apply_feedback(store, belief_id, valence, source)` — only Bayesian-update path. Writes `feedback_history`. Drives demotion-pressure + auto-demote. | | `contradiction.py` | `resolve_contradiction` — picks a winner per precedence, inserts `SUPERSEDES`, writes audit row. Backs `aelf resolve`. | | `correction.py` | No-LLM heuristic correction detector. | @@ -47,7 +47,7 @@ Imports are one-directional — modules lower in the table import from higher. | `triple_extractor.py` | Pure-regex `(subject, relation, object)` extraction over six relation families. Used by commit-ingest and transcript-ingest. | | `context_rebuilder.py` | PreCompact alpha that surfaces aelfrice retrieval before Claude Code summarises. | | `benchmark.py` | Deterministic 16-belief × 16-query synthetic harness. Frozen `BenchmarkReport`. | -| `cli.py` | argparse 22-subcommand CLI. Entry: `aelf`. | +| `cli.py` | argparse 24-subcommand CLI. Entry: `aelf`. | | `mcp_server.py` | FastMCP server, 9 tools. `[mcp]` optional extra. | | `setup.py` | Idempotent install/uninstall of all hooks + statusline. Atomic write via tempfile + `os.replace`. | | `hook.py` | `aelfrice.hook:main` — process Claude Code spawns on each prompt. Reads stdin, calls `retrieve()`, emits `` on stdout. Non-blocking. Entry: `aelf-hook`. | @@ -89,18 +89,24 @@ Walk is 1-hop only. Multi-hop pressure is deferred. ## Retrieval ``` -L0: store.list_locked() always loaded; never trimmed +L0: store.list_locked() always loaded; never trimmed ↓ -L1: FTS5 BM25 keyword search limit l1_limit, query escaped +L2.5: entity-index lookup (v1.3+) NER-extracted entities → exact + stem match; + ↓ default-on; disable via [retrieval] entity_index_enabled = false +L3: BFS multi-hop expansion (v1.3+) edge-weighted graph walk from L0+L2.5 seeds; + ↓ default-OFF; enable via [retrieval] bfs_enabled = true +L1: FTS5 BM25 keyword search limit l1_limit, query escaped; + ↓ v1.3+: score = log(bm25) + 0.5*log(posterior_mean) +Dedupe L1+L2.5+L3 against L0 ids ↓ -Dedupe L1 against L0 ids - ↓ -Trim L1 from tail until sum(estimated_tokens) ≤ token_budget +Trim from tail until sum(estimated_tokens) ≤ token_budget ``` Token estimate: `(len(content) + 3) // 4`. Empty query: L0 only. L0 always wins overflow. -The v1.3.0 retrieval wave inserts an L2.5 entity-index tier between L0 and L1 — spec lives at [entity_index.md](entity_index.md). +Spec docs: [entity_index.md](entity_index.md) (L2.5), [bfs_multihop.md](bfs_multihop.md) (L3), [bayesian_ranking.md](bayesian_ranking.md) (L1 Bayesian reranking). + +**BFS temporal-coherence caveat:** L3 resolves each hop to the globally latest serial of its target belief. For recall queries this is correct. For audit queries (what did the agent believe at decision-time?) a post-seed supersession can appear mid-chain. The temporal-coherence fix is targeted at v2.0.0 — see [LIMITATIONS § BFS multi-hop temporal coherence](LIMITATIONS.md#bfs-multi-hop-temporal-coherence). ## Onboarding @@ -112,6 +118,8 @@ The v1.3.0 retrieval wave inserts an L2.5 entity-index tier between L0 and L1 Classification via priors + regex fallback. Idempotent on `content_hash`. +**LLM-Haiku onboard classifier (v1.3+, default-OFF):** `aelf onboard --llm-classify` routes each candidate through Claude Haiku instead of the regex path. Four consent gates enforce the privacy boundary: flag presence, `ANTHROPIC_API_KEY` present, stored sentinel, interactive prompt. `--dry-run` previews candidates without calling the API. Spec: [llm_classifier.md](llm_classifier.md). This is the only path in aelfrice that transmits user content outbound — see [PRIVACY § Optional outbound calls](PRIVACY.md#optional-outbound-calls). + ## Claude Code hook ``` @@ -145,6 +153,27 @@ observation produced by a HOME-side hook (tracked separately). See [hook_activity_schema](hook_activity_schema.md) for the field schema and the consumer-side dedupe-by-fingerprint warning. +## PreCompact rebuilder (v1.4) + +When Claude Code approaches its context limit it fires `PreCompact`. The `aelf-pre-compact-hook` intercepts this event and injects a curated retrieval block before the harness summarises: + +``` +PreCompact fires + ↓ +aelf-pre-compact-hook reads the last N turns from turns.jsonl + ↓ +rebuild_v14(recent_turns, store, token_budget) + → L0 locked beliefs (always first) + → session-scoped beliefs matching recent content + → BM25+posterior hits against the session tail + packed to token_budget (default: [rebuilder].token_budget in .aelfrice.toml) + ↓ +emitted as additionalContext — both the aelfrice block +and the harness's own summary land in the new context (augment mode) +``` + +`aelf rebuild [--transcript PATH] [--n N] [--budget N]` runs the same codepath manually (prints block to stdout). Install via `aelf setup --rebuilder`. Spec: [context_rebuilder.md](context_rebuilder.md). Eval fixture policy: [eval_fixture_policy.md](eval_fixture_policy.md). + ## Tests | Layer | Marker | Coverage | @@ -153,15 +182,18 @@ and the consumer-side dedupe-by-fingerprint warning. | Property | default | Pre-registered invariants: Bayesian inertia, decay-required, lock-floor sharpness, token-budget invariant, broker-attenuation. | | Regression | `@pytest.mark.regression` | Cross-module scenarios: retrieval round-trip, feedback loop, onboarding, setup→hook→unsetup, `aelf bench` end-to-end. | -`uv run pytest` (~1,150 tests at v1.2, ~15s on Apple Silicon). +`uv run pytest` (~1,414 tests at v1.3/v1.4, ~15s on Apple Silicon). ## Out of scope through v1.x These land at v2.0 with evidence (a benchmark, an experiment, a clear case where the existing operations don't suffice): -- Posterior-aware retrieval ranking (gated on the v1.3 retrieval wave) - HRR / sentence-transformer embeddings -- BFS multi-hop graph retrieval -- Entity index / NER -- LLM in the hot path - Cross-project knowledge federation +- Full posterior-driven ranking eval (10-round MRR uplift, ECE calibration, BM25F + heat-kernel composition — v2.0.0; the partial Bayesian reranking shipped at v1.3.0) + +The following were previously listed here and have since shipped: +- Posterior-aware retrieval ranking → **shipped v1.3.0** (partial; [bayesian_ranking.md](bayesian_ranking.md)) +- BFS multi-hop graph retrieval → **shipped v1.3.0** ([bfs_multihop.md](bfs_multihop.md)) +- Entity index / NER → **shipped v1.3.0** ([entity_index.md](entity_index.md)) +- LLM in the hot path (optional onboard classifier) → **shipped v1.3.0** ([llm_classifier.md](llm_classifier.md)) From da724a1dd8055c8d1a514ce230b69d8339ca9268 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:51:34 -0700 Subject: [PATCH 09/11] docs(readme): roadmap themes v1.3 + add v1.4, v2.0 incremental note --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 368172799..255422a27 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,9 @@ The same operations are also available as MCP tools and `/aelf:*` slash commands | v1.1.0 | shipped | per-project DBs (`.git/aelfrice/`), `aelf migrate`, `edges`→`threads` rename, `aelf health` rewrite | | v1.2.0 | shipped | auto-capture pipeline (transcript-ingest, commit-ingest, SessionStart), `agent_inferred → user_validated` promotion, triple extractor, `--batch` JSONL ingest, CLI consolidation, `INEDIBLE` per-file opt-out | | v1.2.x | planned | search-tool `PreToolUse` hook — memory-first context on Grep/Glob | -| v1.3 | planned | retrieval wave — entity index + BFS multi-hop + LLM classification | -| v2.0 | planned | feature parity with the original research line + benchmark reproducibility | +| v1.3 | planned | retrieval wave — entity index + BFS multi-hop + LLM classification + posterior-weighted ranking | +| v1.4 | planned | context rebuilder — PreCompact retrieval-curated continuation | +| v2.0 | planned | feature parity with the original research line + benchmark reproducibility. v2.0's component issues (#148–#154) will land incrementally across v1.5+ minor versions; final v2.0 tag is the reproducibility cut. | Per-version detail: [docs/ROADMAP.md](docs/ROADMAP.md). Open issues: [docs/LIMITATIONS.md](docs/LIMITATIONS.md). From c85511639e639925eb133f698325403cff70f233 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:52:16 -0700 Subject: [PATCH 10/11] docs(slash): update hidden-commands list to include project-warm, session-delta, feedback --- docs/SLASH_COMMANDS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SLASH_COMMANDS.md b/docs/SLASH_COMMANDS.md index bb0743e01..a0a972add 100644 --- a/docs/SLASH_COMMANDS.md +++ b/docs/SLASH_COMMANDS.md @@ -2,7 +2,7 @@ Fourteen markdown files in `src/aelfrice/slash_commands/`, tracking the v1.2.0 CLI consolidation. After `aelf setup`, they appear as `/aelf:*` in Claude Code. Each is a thin wrapper over the CLI — `/aelf:foo` invokes `aelf foo` against the active project's DB. -Slash files are not shipped for hidden CLI subcommands (`bench`, `health`, `migrate`, `rebuild`, `regime`, `stats`, `statusline`, `unsetup`). Those subcommands stay callable from the CLI for scripting, hook entry-points, and back-compat aliases — they're just not surfaced as slashes. +Slash files are not shipped for hidden CLI subcommands (`bench`, `feedback`, `health`, `migrate`, `project-warm`, `rebuild`, `regime`, `session-delta`, `stats`, `statusline`, `unsetup`). Those subcommands stay callable from the CLI for scripting, hook entry-points, and back-compat aliases — they're just not surfaced as slashes. Manual install: From 9b2e078adc3729a977d9810b05f375613270e067 Mon Sep 17 00:00:00 2001 From: rrs <276464689+robotrocketscience@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:52:35 -0700 Subject: [PATCH 11/11] =?UTF-8?q?docs(limitations):=20onboarding=20scope?= =?UTF-8?q?=20=E2=80=94=20note=20--llm-classify=20path=20added=20at=20v1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/LIMITATIONS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index e3b20ff97..960c63ba3 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -20,7 +20,9 @@ This is a deliberate scope choice, not a roadmap item. Adding embeddings would b The CLI scanner walks three sources: prose files (`*.md`, `*.rst`, `*.txt`, `*.adoc`), `git log`, and Python AST. Not yet wired: JavaScript / TypeScript / Rust / Go ASTs. -Classification on the CLI path is regex-based. Higher-quality classification requires the MCP `aelf:onboard` polymorphic flow, which routes through the host LLM. +Classification on the CLI path defaults to regex-based priors. Higher-quality classification is available via two paths: +- **MCP `aelf:onboard`** polymorphic flow, which routes through the host LLM. +- **`aelf onboard --llm-classify`** (v1.3+, default-off) — routes through Claude Haiku directly. Requires `ANTHROPIC_API_KEY`. Four consent gates enforce the privacy boundary. See [llm_classifier.md](llm_classifier.md). ## BFS multi-hop temporal coherence