diff --git a/CHANGELOG/v4.md b/CHANGELOG/v4.md index 6b1bcbe78..db3b17596 100644 --- a/CHANGELOG/v4.md +++ b/CHANGELOG/v4.md @@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **PRs touching only `benchmarks/` or `scripts/` reported a passing pytest matrix without running any tests ([#1160](https://github.com/robotrocketscience/aelfrice/issues/1160)).** `ci.yml` deliberately carries no `paths-ignore`, so the workflow always runs and the required `pytest (3.12)` / `pytest (3.13)` checks report on every PR including docs-only ones (#413/#427); which PRs actually execute the suite is decided inside the job by a `dorny/paths-filter` glob list. That list covered `src/**` and `tests/**` but not `benchmarks/**` or `scripts/**`, while `pythonpath = ["."]` makes both importable from the suite — 27 test modules import `benchmarks` and 6 load a `scripts/` module through `importlib`. A PR editing only those directories therefore took the `echo "No code paths changed"` branch and turned both required checks green having run nothing. The coverage it skipped is real, not nominal: widening `compute_band`'s absolute floor 2× in `benchmarks/tolerance.py` fails two tests in `test_bench_tolerance.py`, and breaking `scripts/check_migration_policy.py` errors 14 in `test_migration_policy.py` — the gate that guards destructive schema migrations. Both would have merged green. `docs/` and `CHANGELOG/` stay out of the filter deliberately, and that exclusion was checked rather than assumed: truncating `README.md` to a quarter of its length leaves the full suite passing, so no test asserts on prose and the docs-only exemption costs no coverage. The new guard **derives** its expectation instead of hand-listing names — it scans `tests/` for top-level in-repo packages actually imported or path-resolved and asserts each is matched by a glob, so a package the suite starts using fails the test until the filter covers it. Its own vacuity is guarded too (an empty scan would satisfy the coverage assertion for free, and the filter parser refuses to run unless it finds exactly one `code:` key), because a hand-maintained list compared against the constants it was copied from is the tautology #1161 hit on the doctor side. One acceptance criterion of the #1160 umbrella; the others remain open. - **The `eval-calibration` gate never ran on the code it calibrates ([#1160](https://github.com/robotrocketscience/aelfrice/issues/1160)).** The job pins `aelf eval --json` byte-for-byte against `benchmarks/posterior_ranking/baseline.json`, but its PR `paths:` filter named only `eval_harness.py`, `calibration_metrics.py` and `cli.py` — while the metric is produced by `retrieve()` (`eval_harness.py:167` imports it, `:182` calls it), so the entire retrieval and scoring stack sat outside the trigger. Because the `push:` trigger carries no `paths:` key, that asymmetry converted a skipped check into a broken main: a PR editing only `scoring.py` never ran the job, merged, and the post-merge run then failed the baseline assertion with no owning PR to revert. The hidden coverage is real — negating the bm25 term in `scoring.py` moves `roc_auc` from 0.8444 to 0.7347 and `spearman_rho` from 0.5241 to 0.3572, which the byte-exact assertion catches the moment it is allowed to run. Now filters on `src/aelfrice/**`, matching what `ci.yml` already does, rather than a longer enumeration: the enumeration is what drifted, and the coupling is not stable enough to enumerate safely — the harness pins `l1_limit` and passes `entity_index_enabled=False, bfs_enabled=False`, so `DEFAULT_L1_LIMIT` and `DEFAULT_K1` are both unreachable today and which modules are live moves with the call. Over-triggering costs a 0.45–0.68 s measurement; under-triggering costs a red main. The accompanying guard walks imports from the harness (via `ast.walk`, since the `retrieve` import sits inside a function body — a top-level-only scan misses exactly the dependency at issue) and asserts all 25 reachable modules match the filter, with its own vacuity pinned so an empty walk cannot satisfy the assertion for free. **Not fixed here:** the same gate is still blind to the posterior rerank it is named for — `AELFRICE_POSTERIOR_WEIGHT=0.0`, `1.0` and `5.0` all emit byte-identical metrics, because the calibration corpus builds every belief at `alpha=0.5, beta=0.5` and a constant posterior is a constant offset that cannot reorder anything. That is a separate acceptance criterion on the umbrella and needs a corpus change plus a deliberate baseline recut. - **Federated peers opened with `immutable=1` ignored a live WAL and read nothing ([#1198](https://github.com/robotrocketscience/aelfrice/issues/1198)).** `open_peer_connection` opened every peer with `mode=ro&immutable=1`. `immutable=1` promises SQLite the file cannot change, and SQLite acts on that promise by skipping locking and ignoring the write-ahead log entirely. Every aelfrice store runs in WAL mode, and a store held open by a running hook — the normal deployment shape — keeps its recent commits in `memory.db-wal` until something checkpoints, so the peer handle read a database that was missing them. Measured on a live 30-belief peer at the same instant: the `immutable=1` connection raised `no such table: beliefs` (the WAL held the schema too), while a plain `mode=ro` connection saw all 30. Where the schema *had* been checkpointed but recent rows had not, the failure was quieter and worse — a smaller row count with no error, so federated retrieval silently omitted beliefs that were committed and durable. Same class as the [#1173](https://github.com/robotrocketscience/aelfrice/issues/1173) `--archive` bug: a live WAL means the main database file is not the data. Peers now open with plain `mode=ro`, which honours the WAL; the read-only guarantee is unchanged (a write still raises `attempt to write a readonly database`). `immutable=1` is retained as a **fallback**, because a WAL-mode database must create a `-shm` file even when fully checkpointed and so plain `mode=ro` fails outright on read-only media — dropping it unconditionally would have turned those peers from working into silently unreachable. The fallback is reached only after an honest read has already failed **and only on `SQLITE_READONLY`** — any other `OperationalError` (a lock, a transient filesystem error) propagates, because routing those to `immutable=1` would hand back a WAL-blind handle for the connection's whole lifetime and silently restore the defect, and `_peer_conn` caches the handle and swallows failures so it would stay restored until the process restarts. Because `sqlite3.connect` on a URI defers the `-shm` attempt — a file on read-only media errors only at the first statement, though a *missing* file still raises from `connect` itself — the open now issues a `schema_version` pragma to force the pager to resolve where the fallback can act on it. This was the last untouched acceptance criterion of [#1161](https://github.com/robotrocketscience/aelfrice/issues/1161). +- **Superseded beliefs were never demoted or excluded at retrieval ([#1187](https://github.com/robotrocketscience/aelfrice/issues/1187)).** `grep SUPERSEDES src/aelfrice/retrieval.py` returned zero hits: the retrieval pipeline had no notion of supersession, and `uri_baki.apply_supersession_demote` — the only implementation — had no importer anywhere in `src/`. Reproduced: two contradictory beliefs, `resolve_contradiction` picks the newer and writes SUPERSEDES, then `retrieve(s, "deploy target")` returns `['old', 'new']` — the **retired** belief ranked first, both injected. The user corrects "deploy target is heroku" to "fly.io" and the next prompt still leads with heroku. Live for the paths that actually write SUPERSEDES (explicit `aelf resolve`, the triple extractor's "X supersedes Y"); the auto-relationship detector has been CONTRADICTS-only since the #1005 revert. A new `use_supersession_demote` lane ships **both** treatments behind one default-OFF flag — `demote` (adds `log(supersession_demote_factor)`, default 0.5) and `exclude` (drops the belief from the candidate set before the heat-kernel seeds are computed) — selected by `supersession_treatment`, resolved env > kwarg > TOML > default like every other lane, and fed by one batched `SELECT DISTINCT dst … WHERE type='SUPERSEDES'` over the candidate set. Per the ratified shape, nothing presumes a winner: the three-arm bench (demote vs exclusion vs control) picks the default, and unlike the #1170 BFS-direction fix this lane changes `retrieve()` output on the **default** path, which is why it stays off until that bench is read. **The demote is additive, not multiplicative,** and that is a correction to the issue's suggested wiring: the composite rerank score is a log-domain quantity from `combine_log_scores` / `partial_bayesian_score` and is routinely negative (measured -13.08 on the two-belief reproduction), so `score * 0.5` *raises* it — importing `apply_supersession_demote` as written would have promoted the superseded belief to the top of the pack, the exact inversion the lane exists to fix. Adding `log(factor)` is the log-domain equivalent of scaling a probability, so the issue's factor semantics survive and the demote is unconditional; the primitive is left unimported and a test pins the negative-score premise so nobody folds the addition back into a multiplication. Two calibration facts for the bench, both measured: at factor 0.5 the penalty is -0.69, the same order as the default-ON entity-persistence penalty and far weaker than its -6.91 floor, and on the reproduction the two compose to *cancel* (the current belief grounds to entities, S1=0.5 → -0.691; the retired one grounds to none → 0), so the bench should sweep the factor rather than test 0.5 alone. +- **Superseded beliefs were never demoted or excluded at retrieval ([#1187](https://github.com/robotrocketscience/aelfrice/issues/1187)).** `grep SUPERSEDES src/aelfrice/retrieval.py` returned zero hits: the retrieval pipeline had no notion of supersession, and `uri_baki.apply_supersession_demote` — the only implementation — had no importer anywhere in `src/`. Reproduced: two contradictory beliefs, `resolve_contradiction` picks the newer and writes SUPERSEDES, then `retrieve(s, "deploy target")` returns `['old', 'new']` — the **retired** belief ranked first, both injected. The user corrects "deploy target is heroku" to "fly.io" and the next prompt still leads with heroku. Live for the paths that actually write SUPERSEDES (explicit `aelf resolve`, the triple extractor's "X supersedes Y"); the auto-relationship detector has been CONTRADICTS-only since the #1005 revert. A new `use_supersession_demote` lane ships **both** treatments behind one default-OFF flag — `demote` (adds `log(supersession_demote_factor)`, default 0.5) and `exclude` (drops the belief from the candidate set before the heat-kernel seeds are computed) — selected by `supersession_treatment`, resolved env > kwarg > TOML > default like every other lane, and fed by one batched `SELECT DISTINCT dst … WHERE type='SUPERSEDES'` over the candidate set. Per the ratified shape, nothing presumes a winner: the three-arm bench (demote vs exclusion vs control) picks the default, and unlike the #1170 BFS-direction fix this lane changes `retrieve()` output on the **default** path, which is why it stays off until that bench is read. **The demote is additive, not multiplicative,** and that is a correction to the issue's suggested wiring: the composite rerank score is a log-domain quantity from `combine_log_scores` / `partial_bayesian_score` and is routinely negative (measured -13.08 on the two-belief reproduction), so `score * 0.5` *raises* it — importing `apply_supersession_demote` as written would have promoted the superseded belief to the top of the pack, the exact inversion the lane exists to fix. Adding `log(factor)` is the log-domain equivalent of scaling a probability, so the issue's factor semantics survive and the demote is unconditional; the primitive is left unimported and a test pins the negative-score premise so nobody folds the addition back into a multiplication. Two calibration facts for the bench, both measured: at factor 0.5 the penalty is -0.69, the same order as the default-ON entity-persistence penalty and far weaker than its -6.91 floor, and on the reproduction the two compose to *cancel* (the current belief grounds to entities, S1=0.5 → -0.691; the retired one grounds to none → 0), so the bench should sweep the factor rather than test 0.5 alone. The exclusion arm widens the candidate fetch and retries rather than filtering the fixed-size slice in place: the limit is applied by the search, so filtering afterwards shrank the pack instead of backfilling — at `l1_limit=4` with three of the top four retired it returned one belief while three current ones sat at ranks 5-7, the same shape as the lock-budget starvation fixed in [#1014](https://github.com/robotrocketscience/aelfrice/issues/1014)/[#1015](https://github.com/robotrocketscience/aelfrice/issues/1015). Bounded at three rounds so a store where nearly everything is superseded costs a fixed number of queries. This also matters for the ratified three-arm bench: with the arms differing in pack size as well as in treatment, a loss for exclusion could not have been attributed to either. ## [4.2.0] - 2026-07-21 diff --git a/docs/user/CONFIG.md b/docs/user/CONFIG.md index 813a84f61..53d120be2 100644 --- a/docs/user/CONFIG.md +++ b/docs/user/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 the following power-user surfaces: - `[noise]` — onboard-time belief filter. Changes how `aelf onboard` ingests beliefs; nothing else. -- `[retrieval]` (v1.3+) — retrieval-time tier toggles + ranking. Knobs: `entity_index_enabled` (L2.5), `bfs_enabled` (L3), `posterior_weight` (partial Bayesian-weighted L1 ranking), `l1_limit` + `token_budget` (the #1045 wide-retrieval knobs — BM25 candidate cap + token budget, default 50/2400; raise both together for multi-hop recall), `use_bm25f_anchors` (BM25F-with-anchor-text since v1.7), `use_heat_kernel` (authority scoring lane, default-on since v2.1), `use_hrr_structural` (HRR structural-query lane, default-on since v2.1 — live on the production `retrieve()` path via the #1107 Phase-5 cutover; marker-routed, a no-op fall-through on non-marker queries), `hrr_persist` (HRR structural-index on-disk persistence, default-on since v3.0), `use_type_aware_compression` (per-belief retention-class compression, default-on since #769), `use_intentional_clustering` (co-locating related beliefs, default-on since v3.0 — live on the production `retrieve()` path via the #1107 Phase-4 cutover), `expansion_gate_enabled`, `use_gamma_posterior_temperature` (default off), and `use_zeta_posterior_rerank` (default off; mutually exclusive with the γ flag — `retrieve()` raises `ValueError` when both are on), `use_temporal_spine` + `temporal_spine_budget` (the #1064 chronological-adjacency lane, default **on**/32 since v4.0 — live on the production `retrieve()` path via the #1107 cutover; pairs with `[ingest] write_temporal_spine`), `use_entity_persist_demote` (the #1096 entity-persistence demotion / organic-sink rerank modifier, default **on** since v4.0 — live on the production `retrieve()` path via the #1107 cutover), `use_origin_tiebreak` (the #1089 origin-priority within-tier tie-break, default off). Two placeholder flags (`use_signed_laplacian`, `use_posterior_ranking`) are recognised but emit a deprecation warning if set — their lanes have not yet shipped. +- `[retrieval]` (v1.3+) — retrieval-time tier toggles + ranking. Knobs: `entity_index_enabled` (L2.5), `bfs_enabled` (L3), `posterior_weight` (partial Bayesian-weighted L1 ranking), `l1_limit` + `token_budget` (the #1045 wide-retrieval knobs — BM25 candidate cap + token budget, default 50/2400; raise both together for multi-hop recall), `use_bm25f_anchors` (BM25F-with-anchor-text since v1.7), `use_heat_kernel` (authority scoring lane, default-on since v2.1), `use_hrr_structural` (HRR structural-query lane, default-on since v2.1 — live on the production `retrieve()` path via the #1107 Phase-5 cutover; marker-routed, a no-op fall-through on non-marker queries), `hrr_persist` (HRR structural-index on-disk persistence, default-on since v3.0), `use_type_aware_compression` (per-belief retention-class compression, default-on since #769), `use_intentional_clustering` (co-locating related beliefs, default-on since v3.0 — live on the production `retrieve()` path via the #1107 Phase-4 cutover), `expansion_gate_enabled`, `use_gamma_posterior_temperature` (default off), and `use_zeta_posterior_rerank` (default off; mutually exclusive with the γ flag — `retrieve()` raises `ValueError` when both are on), `use_temporal_spine` + `temporal_spine_budget` (the #1064 chronological-adjacency lane, default **on**/32 since v4.0 — live on the production `retrieve()` path via the #1107 cutover; pairs with `[ingest] write_temporal_spine`), `use_entity_persist_demote` (the #1096 entity-persistence demotion / organic-sink rerank modifier, default **on** since v4.0 — live on the production `retrieve()` path via the #1107 cutover), `use_origin_tiebreak` (the #1089 origin-priority within-tier tie-break, default off), `use_supersession_demote` + `supersession_treatment` + `supersession_demote_factor` (the #1187 supersession lane — demote or exclude beliefs a `SUPERSEDES` edge retires, default off/`demote`/0.5, pending the three-arm bench). Two placeholder flags (`use_signed_laplacian`, `use_posterior_ranking`) are recognised but emit a deprecation warning if set — their lanes have not yet shipped. - `[rebuilder]` (v1.4+) — context-rebuilder knobs: `turn_window_n` (default 50), `token_budget` (default 4000), `trigger_mode` (`manual`|`threshold`|`dynamic`, default `threshold`), `threshold_fraction` (default 0.6), and `query_strategy` (v1.7+, default `stack-r1-r3` since v3.0). `[rebuild_floor]` (v1.7+) sets the token-budget floors for the session-scoped and L1 belief lanes (`[rebuild_floor] session` and `[rebuild_floor] l1`). - `[onboard.llm]` (v1.3.0+) — direct-API onboard classifier gate; documented under [Keys § `[onboard.llm]`](#onboardllm-v130) below. - `[cadence]`, `[implicit_feedback]`, and `[hook_audit]` — feedback-cadence scoring, deferred retrieval-exposure feedback, and the per-turn hook audit log. Recognised here but documented in their module docstrings (`src/aelfrice/cadence.py`, `src/aelfrice/deferred_feedback.py`, `src/aelfrice/hook.py`). @@ -418,6 +418,29 @@ The sink is **content-referential, not temporal**: a time/recency decay sink was Precedence (first decisive wins): env var `AELFRICE_ENTITY_PERSIST_DEMOTE=1`/`0` > explicit Python kwarg `use_entity_persist_demote=` on `retrieve_v2()` > TOML `[retrieval] use_entity_persist_demote` > default `true`. The lean production `retrieve()` shim takes no per-call kwarg — opt out there via the env var or TOML key for parity with the pre-flip ranking. +### `use_supersession_demote` + +Boolean, default `false` ([#1187](https://github.com/robotrocketscience/aelfrice/issues/1187)). Enables the **supersession lane**: a belief that a `SUPERSEDES` edge points *at* — the claim the user retired — is pushed down the ranking or dropped from the candidate set. + +Without it, retrieval has no notion of supersession at all. Correct "deploy target is heroku" to "fly.io", let contradiction resolution record the supersession, and the next prompt still injects the heroku belief **ahead of** the fly.io one. The edge is written by `aelf resolve` and by the triple extractor's "X supersedes Y"; the auto-relationship detector has been CONTRADICTS-only since the #1005 revert, so this affects the explicit paths. + +Two arms, selected by `supersession_treatment`: + +| Value | Behaviour | +|---|---| +| `demote` (default) | Adds `log(supersession_demote_factor)` to the candidate's rerank score. | +| `exclude` | Drops the superseded belief from the candidate set entirely, before the heat-kernel seeds are computed. | + +`demote` is the default because it is the recoverable arm: a `SUPERSEDES` edge can be written by the triple extractor from prose that merely *looks* like a supersession, and a wrong exclusion hides a belief with no ranking signal left to notice it. Exclusion is the stronger reading of "the user retired this claim". **Which becomes the default is gated on a three-arm bench** (demote vs exclusion vs control) — unlike the #1170 BFS-direction fix, this lane changes `retrieve()` output on the default path, so both arms ship behind the flag and neither is presumed. + +`supersession_demote_factor` (float, default `0.5`) keeps its multiplicative meaning in the log domain — the penalty is `log(factor)`, clamped to `(0, 1]` so a value above 1 cannot promote a retired belief and `0` yields a finite penalty rather than `-inf`. Note the penalty is **additive**, not `score * factor`: the composite rerank score is a log-domain quantity and is routinely negative, so multiplying it by `0.5` would *raise* it and promote the very belief being demoted. + +Calibration note for anyone running the bench: at `factor = 0.5` the penalty is `-0.69`, the same order of magnitude as the default-ON entity-persistence penalty and far weaker than its `log(ε)` floor of `-6.9`. The two compose additively and can cancel, so sweep the factor rather than testing `0.5` alone. + +One batched query per retrieval (`SELECT DISTINCT dst … WHERE type = 'SUPERSEDES'` over the candidate set), skipped entirely when the flag is off. Deterministic per #605 — an edge join, no clock and no embeddings. + +Precedence (first decisive wins), for all three knobs: env var `AELFRICE_SUPERSESSION_DEMOTE=1`/`0`, `AELFRICE_SUPERSESSION_TREATMENT=demote|exclude`, `AELFRICE_SUPERSESSION_FACTOR=` > explicit Python kwarg on `retrieve_v2()` > TOML `[retrieval] use_supersession_demote` / `supersession_treatment` / `supersession_demote_factor` > defaults `false` / `demote` / `0.5`. Unrecognised treatments and non-numeric factors trace to stderr and fall through to the default rather than raising. + ### `use_origin_tiebreak` Boolean, default `false` (v4.0+, [#1089](https://github.com/robotrocketscience/aelfrice/issues/1089)). Enables the **origin-priority tie-break**: when two ranked candidates tie on relevance, the higher-trust *origin* wins (e.g. a belief curated from a `user`/`feedback` fact file outranks one auto-captured from a chat transcript). diff --git a/src/aelfrice/retrieval.py b/src/aelfrice/retrieval.py index 20cc0fd44..757241744 100644 --- a/src/aelfrice/retrieval.py +++ b/src/aelfrice/retrieval.py @@ -216,6 +216,31 @@ # Floor so a durable-free belief (S1 = 0) gets a bounded, not infinite, # log penalty. ENTITY_PERSIST_DEMOTE_EPS: Final[float] = 1e-3 +# #1187 supersession lane. Default-OFF (resolver default False) and it +# stays that way: unlike the #1170 BFS fix this changes `retrieve()` output +# on the **default** path, so the flip waits on the three-arm bench +# (demote vs exclusion vs control) the operator ratified on 2026-07-29. +# Both arms ship; the bench picks the default, nothing here presumes it. +SUPERSESSION_DEMOTE_FLAG: Final[str] = "use_supersession_demote" +# Treatment selector. `demote` reduces the superseded belief's rerank +# score; `exclude` drops it from the candidate set outright. The +# trade-off the bench has to settle: exclusion is the stronger reading of +# "the user retired this claim", but a SUPERSEDES edge can be written by +# the triple extractor from prose that merely looks like a supersession, +# and a wrong exclusion hides a belief with no ranking signal to notice. +SUPERSESSION_TREATMENT_FLAG: Final[str] = "supersession_treatment" +SUPERSESSION_TREATMENT_DEMOTE: Final[str] = "demote" +SUPERSESSION_TREATMENT_EXCLUDE: Final[str] = "exclude" +SUPERSESSION_TREATMENTS: Final[tuple[str, ...]] = ( + SUPERSESSION_TREATMENT_DEMOTE, + SUPERSESSION_TREATMENT_EXCLUDE, +) +SUPERSESSION_FACTOR_FLAG: Final[str] = "supersession_demote_factor" +# 0.5 per the issue spec, the same default the uri_baki primitive carries. +SUPERSESSION_DEMOTE_FACTOR: Final[float] = 0.5 +# Floor so `factor = 0` is a bounded penalty rather than `log(0) = -inf`, +# which would make the score non-comparable (and NaN once summed). +SUPERSESSION_FACTOR_EPS: Final[float] = 1e-6 TEMPORAL_SPINE_FLAG: Final[str] = "use_temporal_spine" TEMPORAL_SPINE_BUDGET_FLAG: Final[str] = "temporal_spine_budget" # v2.1 #434 type-aware compression flag. Default-ON since the #769 flip @@ -286,6 +311,10 @@ ENV_ENTITY_PERSIST_DEMOTE: Final[str] = "AELFRICE_ENTITY_PERSIST_DEMOTE" # #1089 axis-2 origin-priority tie-break env override. Tri-state; default-OFF. ENV_ORIGIN_TIEBREAK: Final[str] = "AELFRICE_ORIGIN_TIEBREAK" +# #1187 supersession lane env overrides. Tri-state; default-OFF. +ENV_SUPERSESSION_DEMOTE: Final[str] = "AELFRICE_SUPERSESSION_DEMOTE" +ENV_SUPERSESSION_TREATMENT: Final[str] = "AELFRICE_SUPERSESSION_TREATMENT" +ENV_SUPERSESSION_FACTOR: Final[str] = "AELFRICE_SUPERSESSION_FACTOR" # #1064 temporal-spine lane flag + node-budget knob. ENV_TEMPORAL_SPINE: Final[str] = "AELFRICE_TEMPORAL_SPINE" ENV_TEMPORAL_SPINE_BUDGET: Final[str] = "AELFRICE_TEMPORAL_SPINE_BUDGET" @@ -784,6 +813,135 @@ def is_entity_persist_demote_enabled( return True +def _env_supersession_demote_override() -> bool | None: + """Return True/False if AELFRICE_SUPERSESSION_DEMOTE is set to a + recognised truthy/falsy value, else None (#1187). Symmetric to + `_env_entity_persist_demote_override`.""" + raw = os.environ.get(ENV_SUPERSESSION_DEMOTE) + if raw is None: + return None + norm = raw.strip().lower() + if norm in _ENV_FALSY: + return False + if norm in _ENV_TRUTHY: + return True + return None + + +def is_supersession_demote_enabled( + kwarg: bool | None = None, *, start: Path | None = None +) -> bool: + """Resolve the supersession lane flag (#1187). + + Precedence (first decisive wins): + 1. AELFRICE_SUPERSESSION_DEMOTE env var (truthy / falsy normalised). + 2. Explicit `kwarg` from the caller. + 3. `[retrieval] use_supersession_demote` in `.aelfrice.toml`. + 4. Default: **False**. + + The default stays False until the ratified three-arm bench (demote vs + exclusion vs control) exists and the operator reads it. Unlike the + #1170 BFS direction fix, this lane changes `retrieve()` output on the + default path, which is precisely why the arms ship behind a flag + instead of one of them shipping as the new behaviour. + """ + env = _env_supersession_demote_override() + if env is not None: + return env + if kwarg is not None: + return kwarg + toml_value = _read_toml_flag_for(SUPERSESSION_DEMOTE_FLAG, start) + if toml_value is not None: + return toml_value + return False + + +def resolve_supersession_treatment( + kwarg: str | None = None, *, start: Path | None = None +) -> str: + """Resolve which arm of the supersession lane runs (#1187). + + Same precedence as `is_supersession_demote_enabled`; default + `"demote"`, the safer arm — a wrong demote leaves a ranking signal + to notice, a wrong exclusion does not. Unrecognised values fall back + to the default rather than raising, matching the tolerance the TOML + readers already apply, and trace to stderr so a typo is visible. + """ + candidates = ( + os.environ.get(ENV_SUPERSESSION_TREATMENT), + kwarg, + _read_toml_str_for(SUPERSESSION_TREATMENT_FLAG, start), + ) + for raw in candidates: + if raw is None: + continue + norm = raw.strip().lower() + if norm in SUPERSESSION_TREATMENTS: + return norm + print( + f"aelfrice retrieval: ignoring supersession treatment {raw!r} " + f"(expected one of {', '.join(SUPERSESSION_TREATMENTS)})", + file=sys.stderr, + ) + return SUPERSESSION_TREATMENT_DEMOTE + + +def resolve_supersession_factor( + kwarg: float | None = None, *, start: Path | None = None +) -> float: + """Resolve the demote arm's multiplicative factor (#1187). + + Same precedence; default `SUPERSESSION_DEMOTE_FACTOR` (0.5). Clamped + to `(0, 1]`: above 1 would *promote* a retired belief, which is never + the intent, and 0 is floored by `SUPERSESSION_FACTOR_EPS` so the log + penalty stays finite. Out-of-range values clamp rather than raise. + """ + raw: float | None = None + env_raw = os.environ.get(ENV_SUPERSESSION_FACTOR) + if env_raw is not None: + try: + raw = float(env_raw) + except ValueError: + print( + f"aelfrice retrieval: ignoring {ENV_SUPERSESSION_FACTOR}" + f"={env_raw!r} (expected a number)", + file=sys.stderr, + ) + if raw is None: + raw = kwarg + if raw is None: + raw = _read_toml_float_for(SUPERSESSION_FACTOR_FLAG, start) + if raw is None: + raw = SUPERSESSION_DEMOTE_FACTOR + return min(1.0, max(SUPERSESSION_FACTOR_EPS, float(raw))) + + +def _supersession_penalty( + superseded: frozenset[str] | None, belief_id: str, factor: float +) -> float: + """Log-additive demote for a superseded belief (#1187). + + Returns 0.0 when the lane is off (`superseded is None`) or this + belief has not been retired by anything. + + **Additive `log(factor)`, not multiplicative `score * factor`.** The + composite rerank score here is a log-domain quantity from + `combine_log_scores` / `partial_bayesian_score` and is routinely + negative — measured at ~-13 on a two-belief store. Multiplying a + negative score by 0.5 *raises* it, so the multiplicative primitive in + `uri_baki.apply_supersession_demote` (written against a non-negative + score scale) would promote the superseded belief to the top of the + pack: the exact inversion this lane exists to fix. Adding + `log(factor)` is the log-domain equivalent of scaling a probability + by `factor`, so the issue's "factor 0.5" semantics are preserved and + the demote is unconditional. Same shape as + `_entity_persist_penalty`, which is log-additive for the same reason. + """ + if superseded is None or belief_id not in superseded: + return 0.0 + return min(0.0, math.log(max(factor, SUPERSESSION_FACTOR_EPS))) + + def _env_origin_tiebreak_override() -> bool | None: """Return True/False if AELFRICE_ORIGIN_TIEBREAK is set to a recognised truthy/falsy value, else None (#1089). Symmetric to @@ -999,6 +1157,42 @@ def _read_toml_flag_for( return None +def _read_toml_str_for( + key: str, + start: Path | None = None, +) -> str | None: + """Walk up from `start` looking for a `.aelfrice.toml` with + `[retrieval] ` typed as a string (#1187). Returns the value when + found, or None when no file / no key. + + Same tolerance as `_read_toml_flag_for`: a wrong-typed value traces to + stderr and returns None so the caller's default wins. + """ + 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(): + section = _parsed_retrieval_section(candidate) + if section is None or key not in section: + return None + value: Any = section[key] + if isinstance(value, str): + return value + print( + f"aelfrice retrieval: ignoring [{RETRIEVAL_SECTION}] " + f"{key} in {candidate} (expected str)", + file=serr, + ) + return None + if current.parent == current: + break + current = current.parent + return None + + def _read_toml_float_for( key: str, start: Path | None = None, @@ -2769,6 +2963,54 @@ def _store_scoped_bm25f_cache( return cache +# #1187 exclusion-arm refetch. The candidate limit is applied by the +# search (SQL `LIMIT` on the FTS5 path, `top_k` on BM25F), so filtering +# superseded beliefs afterwards SHRINKS the pack instead of backfilling +# from below the cutoff: at `l1_limit=4` with three of the top four +# retired, the arm returned one belief while three current ones sat at +# ranks 5-7. That is the same shape as the lock-budget starvation fixed +# in #1014/#1015 — a filter applied after the budget starves the pack — +# and it would also confound the demote-vs-exclude bench, since the two +# arms would differ in pack size as well as in treatment. +# +# So the exclusion arm widens the fetch and retries, stopping as soon as +# it has `l1_limit` survivors or the search runs out of matches. Bounded +# at three rounds (limit, 2x, 4x) so a store where nearly everything is +# superseded costs a fixed number of queries rather than scanning; when +# the rounds are exhausted the arm returns what it has, which is still +# strictly more than the pre-#1187 behaviour. +SUPERSESSION_REFETCH_ROUNDS: Final[int] = 3 + + +def _fetch_excluding_superseded( + store: MemoryStore, + fetch: Any, + l1_limit: int, +) -> tuple[list[Any], frozenset[str]]: + """Fetch `l1_limit` candidates that survive supersession exclusion. + + `fetch(limit)` returns a list of `(belief, raw)` pairs. Returns the + kept pairs (at most `l1_limit`) and the superseded id set observed on + the widest fetch, so the caller can reuse it without re-querying. + """ + limit = l1_limit + kept: list[Any] = [] + sup: frozenset[str] = frozenset() + for _ in range(SUPERSESSION_REFETCH_ROUNDS): + rows = fetch(limit) + if not rows: + return [], frozenset() + sup = frozenset(store.superseded_belief_ids([b.id for b, _ in rows])) + kept = [(b, raw) for b, raw in rows if b.id not in sup] + if len(kept) >= l1_limit or len(rows) < limit: + # Either the pack is full, or the search has no more matches + # to widen into — a further round would return the same rows. + break + limit *= 2 + return kept[:l1_limit], sup + + + def _l1_hits( store: MemoryStore, query: str, @@ -2783,6 +3025,9 @@ def _l1_hits( zeta_params: tuple[float, float, float] | None = None, use_entity_persist_demote: bool = False, use_origin_tiebreak: bool = False, + use_supersession_demote: bool = False, + supersession_treatment: str = SUPERSESSION_TREATMENT_DEMOTE, + supersession_factor: float = SUPERSESSION_DEMOTE_FACTOR, now_ts: int | None = None, ) -> list[Belief]: """Run L1: FTS5 BM25 search (default) or BM25F sparse-matvec @@ -2911,12 +3156,27 @@ def _l1_hits( f"update failed: {exc}", file=sys.stderr, ) - beliefs: list[tuple[Belief, float]] = [] - for bid, raw in scored_pairs: - b = store.get_belief(bid) - if b is None: - continue - beliefs.append((b, raw)) + def _bm25f_candidates(top_k: int) -> list[tuple[Belief, float]]: + """Materialise the top-`top_k` BM25F hits as (belief, raw). + + Factored out so the #1187 exclusion arm can widen `top_k` + and refetch instead of filtering a fixed-size slice. At + `top_k == l1_limit` it reuses the scores already computed + above rather than re-scoring the index. + """ + pairs = ( + scored_pairs if top_k == l1_limit + else index.score(query, top_k=top_k) + ) + out: list[tuple[Belief, float]] = [] + for bid, raw in pairs: + b = store.get_belief(bid) + if b is None: + continue + out.append((b, raw)) + return out + + beliefs: list[tuple[Belief, float]] = _bm25f_candidates(l1_limit) # γ / ζ are opt-in; when either is set it forces the rerank # loop so the byte-identical short-circuit can't bypass the # posterior reweighting. @@ -2927,12 +3187,31 @@ def _l1_hits( and gamma_temperature is None and zeta_params is None and not use_entity_persist_demote + and not use_supersession_demote ): return [b for b, _ in beliefs] # BM25F scores are non-negative; the rerank uses `raw` as the # positive-magnitude relevance signal directly (the FTS5 path # has to negate first because SQLite returns smaller-negative # for stronger matches; BM25F doesn't). + # #1187: resolve supersession before anything downstream reads the + # candidate set, so the exclusion arm also keeps superseded beliefs + # out of the heat-kernel seeds rather than only out of the ranking. + sup: frozenset[str] | None = None + if use_supersession_demote: + if supersession_treatment == SUPERSESSION_TREATMENT_EXCLUDE: + # Widen and retry rather than filtering in place, so the + # pack backfills from below the cutoff instead of + # shrinking. See `_fetch_excluding_superseded`. + beliefs, sup = _fetch_excluding_superseded( + store, _bm25f_candidates, l1_limit, + ) + if not beliefs: + return [] + else: + sup = frozenset( + store.superseded_belief_ids([b.id for b, _ in beliefs]) + ) bm25_pos_by_id: dict[str, float] = {b.id: float(raw) for b, raw in beliefs} heat_map = ( _heat_by_id(eigenbasis_cache, bm25_pos_by_id) # type: ignore[arg-type] @@ -2971,6 +3250,7 @@ def _l1_hits( ) s = _hash_n_boosted(s, b.content, hash_n_literals) s += _entity_persist_penalty(ep, b.id) + s += _supersession_penalty(sup, b.id, supersession_factor) keyed.append((s, b.id, b)) if use_origin_tiebreak: keyed.sort( @@ -2987,6 +3267,7 @@ def _l1_hits( and gamma_temperature is None and zeta_params is None and not use_entity_persist_demote + and not use_supersession_demote ): return store.search_beliefs( query, limit=l1_limit, origin_tiebreak=use_origin_tiebreak, @@ -2996,6 +3277,27 @@ def _l1_hits( ) if not scored: return [] + # #1187: as on the BM25F path, resolve and apply exclusion before the + # candidate set is read downstream. + sup: frozenset[str] | None = None + if use_supersession_demote: + if supersession_treatment == SUPERSESSION_TREATMENT_EXCLUDE: + # Widen and retry rather than filtering in place, so the pack + # backfills from below the cutoff instead of shrinking. See + # `_fetch_excluding_superseded`. + scored, sup = _fetch_excluding_superseded( + store, + lambda lim: store.search_beliefs_scored( + query, limit=lim, origin_tiebreak=use_origin_tiebreak, + ), + l1_limit, + ) + if not scored: + return [] + else: + sup = frozenset( + store.superseded_belief_ids([b.id for b, _ in scored]) + ) # FTS5 path: bm25_raw is non-positive (SQLite convention). Negate # to get a positive relevance magnitude, same convention used by # `partial_bayesian_score` internally. @@ -3037,6 +3339,7 @@ def _l1_hits( ) s = _hash_n_boosted(s, b.content, hash_n_literals) s += _entity_persist_penalty(ep, b.id) + s += _supersession_penalty(sup, b.id, supersession_factor) 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). @@ -3174,6 +3477,11 @@ def retrieve( # HRR structural-query lane #152 (Phase 5) — all resolver default-ON. use_temporal_spine=None, use_entity_persist_demote=None, + # #1187: resolver-driven so the lane is reachable from the + # production path, but the resolver default is OFF pending the bench. + use_supersession_demote=None, + supersession_treatment=None, + supersession_factor=None, use_intentional_clustering=None, use_hrr_structural=None, use_origin_tiebreak=False, @@ -3230,6 +3538,9 @@ def retrieve_with_tiers( temporal_spine_node_budget: int | None = None, use_entity_persist_demote: bool = False, use_origin_tiebreak: bool = False, + use_supersession_demote: bool = False, + supersession_treatment: str = SUPERSESSION_TREATMENT_DEMOTE, + supersession_factor: float = SUPERSESSION_DEMOTE_FACTOR, manifest_reference_locks: bool = False, now_ts: int | None = None, ) -> tuple[ @@ -3381,6 +3692,9 @@ def _cost(b: Belief) -> int: zeta_params=zeta_params, use_entity_persist_demote=use_entity_persist_demote, use_origin_tiebreak=use_origin_tiebreak, + use_supersession_demote=use_supersession_demote, + supersession_treatment=supersession_treatment, + supersession_factor=supersession_factor, now_ts=effective_now_ts, ) l1 = [ @@ -3582,6 +3896,9 @@ def retrieve_v2( use_hrr_expand: bool | None = None, use_entity_persist_demote: bool | None = None, use_origin_tiebreak: bool | None = None, + use_supersession_demote: bool | None = None, + supersession_treatment: str | None = None, + supersession_factor: float | None = None, use_temporal_spine: bool | None = None, temporal_spine_depth: int | None = None, temporal_spine_node_budget: int | None = None, @@ -3766,6 +4083,13 @@ def retrieve_v2( use_entity_persist_demote ), use_origin_tiebreak=is_origin_tiebreak_enabled(use_origin_tiebreak), + use_supersession_demote=is_supersession_demote_enabled( + use_supersession_demote + ), + supersession_treatment=resolve_supersession_treatment( + supersession_treatment + ), + supersession_factor=resolve_supersession_factor(supersession_factor), manifest_reference_locks=manifest_reference_locks, hrr_struct_index_cache=expand_cache, temporal_spine_enabled=use_temporal_spine, diff --git a/src/aelfrice/store.py b/src/aelfrice/store.py index 34d055cc6..7800a3814 100644 --- a/src/aelfrice/store.py +++ b/src/aelfrice/store.py @@ -37,6 +37,7 @@ CORROBORATION_SOURCE_TYPES, CORROBORATION_SOURCE_WONDER_INGEST, EDGE_RELATES_TO, + EDGE_SUPERSEDES, EDGE_VALENCE, EXPOSURE_ONLY_FEEDBACK_SOURCES, ORIGIN_SPECULATIVE, @@ -5425,6 +5426,39 @@ def edges_for_beliefs(self, belief_ids: list[str]) -> list[Edge]: ) return [_row_to_edge(r) for r in cur.fetchall()] + def superseded_belief_ids(self, belief_ids: list[str]) -> set[str]: + """Which of `belief_ids` a SUPERSEDES edge points *at* (#1187). + + The canonical direction is the producers': `src` is the newer + belief, `dst` the one it retires (#1170). So a belief is + superseded when it appears as a `dst`, and this returns the + subset of the candidate set that has been retired by something. + + One query over the candidate set rather than one per belief — + this runs inside the L1 rerank, on the default retrieval path + once the lane is enabled. + + The candidate set is bound as a single JSON array read through + `json_each` rather than an interpolated `IN (?, ?, …)` list, so + the SQL text stays static (the ca97776 convention). The empty + case needs no branch beyond the early return: `json_each('[]')` + yields no rows, so the join produces nothing. + + Empty input → empty set (no SQL). + """ + if not belief_ids: + return set() + cur = self._conn.execute( + """ + SELECT DISTINCT e.dst + FROM edges e + JOIN json_each(?) AS cand ON cand.value = e.dst + WHERE e.type = ? + """, + (json.dumps(sorted(set(belief_ids))), EDGE_SUPERSEDES), + ) + return {row["dst"] for row in cur.fetchall()} + def entity_persistence_scores( self, belief_ids: list[str] ) -> dict[str, float]: diff --git a/tests/test_supersession_lane.py b/tests/test_supersession_lane.py new file mode 100644 index 000000000..362baad4e --- /dev/null +++ b/tests/test_supersession_lane.py @@ -0,0 +1,563 @@ +"""The supersession retrieval lane, both arms (#1187). + +Superseded beliefs were never demoted or excluded at retrieval: the user +corrects "deploy target is heroku" to "fly.io", contradiction resolution +records the supersession, and the next prompt still injects heroku *first*. +`uri_baki.apply_supersession_demote` was the only implementation and had no +importer. + +Both arms ship behind a default-OFF flag; the ratified three-arm bench +(demote vs exclusion vs control) picks the default. These tests pin the +mechanism, the resolvers, and — load-bearing — that the demote is additive +in the log domain rather than multiplicative on the score. +""" +from __future__ import annotations + +import math +from pathlib import Path + +import pytest + +from aelfrice import retrieval +from aelfrice.models import ( + BELIEF_FACTUAL, + EDGE_CONTRADICTS, + EDGE_SUPERSEDES, + LOCK_NONE, + Belief, + Edge, +) +from aelfrice.retrieval import ( + SUPERSESSION_DEMOTE_FACTOR, + SUPERSESSION_TREATMENT_DEMOTE, + SUPERSESSION_TREATMENT_EXCLUDE, + _l1_hits, + _supersession_penalty, + is_supersession_demote_enabled, + resolve_supersession_factor, + resolve_supersession_treatment, + retrieve_v2, +) +from aelfrice.store import MemoryStore + +_OLD_ID = "B" + "1" * 15 +_NEW_ID = "B" + "2" * 15 + + +def _belief(bid: str, content: str, created: str) -> Belief: + return Belief( + id=bid, content=content, content_hash="h" + bid[1:], + alpha=1.0, beta=1.0, type=BELIEF_FACTUAL, lock_level=LOCK_NONE, + locked_at=None, created_at=created, last_retrieved_at=None, + ) + + +@pytest.fixture() +def store(tmp_path: Path) -> MemoryStore: + """The issue's reproduction: heroku superseded by fly.io.""" + s = MemoryStore(str(tmp_path / "memory.db")) + s.insert_belief(_belief(_OLD_ID, "deploy target is heroku", "2026-01-01T00:00:00Z")) + s.insert_belief(_belief(_NEW_ID, "deploy target is fly.io", "2026-06-01T00:00:00Z")) + # Producers' canonical direction (#1170): src = newer, dst = retired. + s.insert_edge( + Edge(src=_NEW_ID, dst=_OLD_ID, type=EDGE_SUPERSEDES, weight=1.0) + ) + yield s + s.close() + + +# --- The batched store query --------------------------------------------- + + +def test_returns_the_edge_target_not_its_source(store: MemoryStore) -> None: + """A belief is superseded when it is the `dst`, never the `src`. + + Getting this backwards would demote the *replacement* — the #1170 + inversion, reintroduced one layer down. + """ + assert store.superseded_belief_ids([_OLD_ID, _NEW_ID]) == {_OLD_ID} + + +def test_restricted_to_the_candidate_set(store: MemoryStore) -> None: + """Only ids asked about come back, so the L1 rerank stays scoped.""" + assert store.superseded_belief_ids([_NEW_ID]) == set() + assert store.superseded_belief_ids([_OLD_ID]) == {_OLD_ID} + + +def test_ignores_other_edge_types(store: MemoryStore) -> None: + """CONTRADICTS is not supersession — it is the unresolved state.""" + other = "B" + "3" * 15 + store.insert_belief(_belief(other, "deploy target is render", "2026-02-01T00:00:00Z")) + store.insert_edge( + Edge(src=_NEW_ID, dst=other, type=EDGE_CONTRADICTS, weight=1.0) + ) + + assert store.superseded_belief_ids([_OLD_ID, _NEW_ID, other]) == {_OLD_ID} + + +def test_empty_input_does_no_sql(store: MemoryStore) -> None: + assert store.superseded_belief_ids([]) == set() + + +def test_duplicate_ids_collapse(store: MemoryStore) -> None: + """Two SUPERSEDES edges onto one belief still yield one entry.""" + third = "B" + "4" * 15 + store.insert_belief(_belief(third, "deploy target is fly", "2026-07-01T00:00:00Z")) + store.insert_edge( + Edge(src=third, dst=_OLD_ID, type=EDGE_SUPERSEDES, weight=1.0) + ) + + assert store.superseded_belief_ids([_OLD_ID, _OLD_ID]) == {_OLD_ID} + + +# --- The penalty: additive, not multiplicative --------------------------- + + +def test_penalty_is_log_of_the_factor() -> None: + """`factor` keeps its multiplicative meaning, in the log domain.""" + sup = frozenset({_OLD_ID}) + + assert _supersession_penalty(sup, _OLD_ID, 0.5) == pytest.approx(math.log(0.5)) + assert _supersession_penalty(sup, _NEW_ID, 0.5) == 0.0 + assert _supersession_penalty(None, _OLD_ID, 0.5) == 0.0 + + +def test_penalty_never_promotes() -> None: + """A factor of 1 is a no-op and above 1 cannot become a boost. + + `resolve_supersession_factor` clamps, but the penalty clamps too, so a + direct caller cannot turn the demote into a promotion either. + """ + sup = frozenset({_OLD_ID}) + + assert _supersession_penalty(sup, _OLD_ID, 1.0) == 0.0 + assert _supersession_penalty(sup, _OLD_ID, 4.0) == 0.0 + + +def test_penalty_is_finite_at_factor_zero() -> None: + """`log(0)` is -inf, which would make the score non-comparable.""" + got = _supersession_penalty(frozenset({_OLD_ID}), _OLD_ID, 0.0) + + assert got < 0.0 + assert math.isfinite(got) + + +def test_a_multiplicative_demote_would_have_inverted(store: MemoryStore) -> None: + """Why this lane is additive — the regression the design avoids. + + The composite rerank score is a log-domain quantity and is routinely + negative (measured ~-13 on this two-belief store). `score * 0.5` on a + negative score *raises* it, so wiring `uri_baki.apply_supersession_demote` + as the issue suggested would have promoted the superseded belief to the + top of the pack. This asserts the premise directly, so nobody + "simplifies" the additive penalty back into a multiplication. + """ + from aelfrice.scoring import partial_bayesian_score + + scored = store.search_beliefs_scored("deploy target", limit=10) + assert scored, "the reproduction needs both beliefs to match" + + for belief, bm25_raw in scored: + composite = partial_bayesian_score(bm25_raw, belief.alpha, belief.beta, 0.0) + assert composite < 0.0, "premise: the composite score is negative" + assert composite * SUPERSESSION_DEMOTE_FACTOR > composite, ( + "premise: multiplying a negative score by 0.5 raises it" + ) + additive = composite + _supersession_penalty( + frozenset({belief.id}), belief.id, SUPERSESSION_DEMOTE_FACTOR, + ) + assert additive < composite, "the additive penalty always demotes" + + +# --- Both arms through the L1 rerank ------------------------------------- + + +def _order(beliefs: list[Belief]) -> list[str]: + return ["superseded" if b.id == _OLD_ID else "current" for b in beliefs] + + +def test_control_still_ranks_the_superseded_belief_first( + store: MemoryStore, +) -> None: + """The defect, unchanged with the lane off — this is the baseline arm.""" + got = _l1_hits(store, "deploy target", l1_limit=10, posterior_weight=0.0) + + assert _order(got) == ["superseded", "current"] + + +def test_demote_arm_reorders(store: MemoryStore) -> None: + """The demote arm moves the retired belief below its replacement. + + `use_entity_persist_demote=False` isolates this lane; the two compose + additively and the default-ON entity lane has its own opinion about + these two beliefs (see the composition test below). + """ + got = _l1_hits( + store, "deploy target", l1_limit=10, posterior_weight=0.0, + use_supersession_demote=True, + supersession_treatment=SUPERSESSION_TREATMENT_DEMOTE, + use_entity_persist_demote=False, + ) + + assert _order(got) == ["current", "superseded"] + + +def test_exclude_arm_drops_it_entirely(store: MemoryStore) -> None: + got = _l1_hits( + store, "deploy target", l1_limit=10, posterior_weight=0.0, + use_supersession_demote=True, + supersession_treatment=SUPERSESSION_TREATMENT_EXCLUDE, + ) + + assert _order(got) == ["current"] + + +def test_exclude_arm_can_empty_the_result(tmp_path: Path) -> None: + """Every candidate superseded is a legitimate empty pack, not a crash.""" + s = MemoryStore(str(tmp_path / "m.db")) + s.insert_belief(_belief(_OLD_ID, "deploy target is heroku", "2026-01-01T00:00:00Z")) + s.insert_belief(_belief(_NEW_ID, "unrelated content", "2026-06-01T00:00:00Z")) + s.insert_edge(Edge(src=_NEW_ID, dst=_OLD_ID, type=EDGE_SUPERSEDES, weight=1.0)) + + got = _l1_hits( + s, "heroku", l1_limit=10, posterior_weight=0.0, + use_supersession_demote=True, + supersession_treatment=SUPERSESSION_TREATMENT_EXCLUDE, + ) + + assert got == [] + s.close() + + +def test_lane_off_touches_no_edges( + store: MemoryStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Default-off must be byte-identical, including no extra query. + + The short-circuit that returns the un-reranked FTS5 order has to stay + reachable, or the default path pays for a lane nobody enabled. + """ + def _boom(_ids: list[str]) -> set[str]: + raise AssertionError("superseded_belief_ids called with the lane off") + + monkeypatch.setattr(store, "superseded_belief_ids", _boom) + + got = _l1_hits(store, "deploy target", l1_limit=10, posterior_weight=0.0) + + assert _order(got) == ["superseded", "current"] + + +def test_the_two_demote_lanes_compose_additively(store: MemoryStore) -> None: + """Measured interaction, recorded because the bench has to account for it. + + At factor 0.5 the supersession penalty is log(0.5) = -0.693 — the same + order of magnitude as the entity-persistence penalty, whose floor is + log(1e-3) = -6.9. Here they very nearly cancel: the *current* belief + extracts entities (S1 = 0.5, penalty -0.691) while the superseded one + extracts none (penalty 0), so with both lanes on the pre-existing bm25 + gap survives and the order does not change. The demote is applied + correctly either way; it is simply a weak term in composition, which is + why the bench should sweep the factor rather than test 0.5 alone. + """ + ep = store.entity_persistence_scores([_OLD_ID, _NEW_ID]) + assert _OLD_ID not in ep, "superseded belief extracts no entities here" + assert ep[_NEW_ID] == pytest.approx(0.5) + + both_on = _l1_hits( + store, "deploy target", l1_limit=10, posterior_weight=0.0, + use_supersession_demote=True, use_entity_persist_demote=True, + ) + sup_only = _l1_hits( + store, "deploy target", l1_limit=10, posterior_weight=0.0, + use_supersession_demote=True, use_entity_persist_demote=False, + ) + + assert _order(sup_only) == ["current", "superseded"] + assert _order(both_on) == ["superseded", "current"] + + +# --- Resolvers ----------------------------------------------------------- + + +def test_lane_defaults_off(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The flip is gated on the bench, so the default must stay False.""" + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_DEMOTE, raising=False) + + assert is_supersession_demote_enabled(start=tmp_path) is False + + +def test_env_beats_kwarg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_DEMOTE, "0") + + assert is_supersession_demote_enabled(True, start=tmp_path) is False + + +def test_kwarg_beats_toml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_DEMOTE, raising=False) + (tmp_path / ".aelfrice.toml").write_text( + "[retrieval]\nuse_supersession_demote = true\n", encoding="utf-8", + ) + + assert is_supersession_demote_enabled(False, start=tmp_path) is False + assert is_supersession_demote_enabled(start=tmp_path) is True + + +def test_treatment_defaults_to_the_safer_arm( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A wrong demote leaves a ranking signal; a wrong exclusion does not.""" + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_TREATMENT, raising=False) + + assert resolve_supersession_treatment(start=tmp_path) == ( + SUPERSESSION_TREATMENT_DEMOTE + ) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("exclude", SUPERSESSION_TREATMENT_EXCLUDE), + ("EXCLUDE", SUPERSESSION_TREATMENT_EXCLUDE), + (" demote ", SUPERSESSION_TREATMENT_DEMOTE), + ], +) +def test_treatment_is_normalised( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, raw: str, expected: str, +) -> None: + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_TREATMENT, raw) + + assert resolve_supersession_treatment(start=tmp_path) == expected + + +def test_unknown_treatment_falls_back_rather_than_raising( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A typo in config must not break retrieval on the default path.""" + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_TREATMENT, "delete") + + got = resolve_supersession_treatment(start=tmp_path) + + assert got == SUPERSESSION_TREATMENT_DEMOTE + assert "ignoring supersession treatment" in capsys.readouterr().err + + +def test_factor_defaults_and_clamps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_FACTOR, raising=False) + + assert resolve_supersession_factor(start=tmp_path) == SUPERSESSION_DEMOTE_FACTOR + # Above 1 would promote; below 0 has no meaning. + assert resolve_supersession_factor(4.0, start=tmp_path) == 1.0 + assert resolve_supersession_factor(-1.0, start=tmp_path) > 0.0 + + +def test_non_numeric_factor_env_is_ignored( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_FACTOR, "half") + + assert resolve_supersession_factor(start=tmp_path) == SUPERSESSION_DEMOTE_FACTOR + assert "expected a number" in capsys.readouterr().err + + +# --- Reachable from the production path ---------------------------------- + + +def test_retrieve_v2_threads_the_lane( + store: MemoryStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The lane has to be reachable from `retrieve_v2`, not just `_l1_hits`. + + `uri_baki.apply_supersession_demote` had no importer for exactly this + reason — a primitive nothing calls is not a fix. Uses the exclusion arm + because it is unambiguous end-to-end (the demote arm composes with the + default-ON entity lane, per the composition test above). + """ + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_DEMOTE, raising=False) + + off = retrieve_v2(store, "deploy target") + on = retrieve_v2( + store, "deploy target", use_supersession_demote=True, + supersession_treatment=SUPERSESSION_TREATMENT_EXCLUDE, + ) + + assert _order(off.beliefs) == ["superseded", "current"] + assert _order(on.beliefs) == ["current"] + + +def test_retrieve_reaches_the_lane_via_the_resolver( + store: MemoryStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """`retrieve()` is what production calls; `retrieve_v2` is not. + + The hook path goes through `retrieve()`, which passes `None` for the + lane and lets the resolver decide — so wiring that stops at + `retrieve_v2` is reachable from tests and from the bench harness and + from nothing a user runs. Driven by the env override rather than a + kwarg precisely because `retrieve()` exposes no kwarg for it: if the + env var is the only handle, the env var is what has to work. + + What this does and does not catch, stated precisely: it fails if + `retrieve_v2` stops consulting the resolver, or if the lane is + dropped from `_l1_hits`. It does *not* fail if `retrieve()` starts + passing a hardcoded `False` instead of `None` — the resolver reads + the env var before the kwarg, so that edit is unobservable from here + by construction. The guarantee is end-to-end reachability through the + public API, not the shape of one argument. + """ + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_TREATMENT, "exclude") + + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_DEMOTE, raising=False) + off = retrieval.retrieve(store, "deploy target") + + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_DEMOTE, "1") + on = retrieval.retrieve(store, "deploy target") + + assert _order(off) == ["superseded", "current"], ( + "default-off changed retrieve()'s output" + ) + assert _order(on) == ["current"], ( + "the lane is unreachable from retrieve(); only retrieve_v2 sees it" + ) + + +def test_treatment_precedence_is_env_then_kwarg_then_toml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """env > kwarg > TOML > default, matching every other knob. + + Each layer is asserted while the layer *below* it disagrees, so a + resolver that read only one source would fail rather than coincide. + """ + (tmp_path / ".aelfrice.toml").write_text( + '[retrieval]\nsupersession_treatment = "exclude"\n', encoding="utf-8", + ) + + # TOML alone. + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_TREATMENT, raising=False) + assert resolve_supersession_treatment(start=tmp_path) == ( + SUPERSESSION_TREATMENT_EXCLUDE + ) + # kwarg beats TOML. + assert resolve_supersession_treatment( + SUPERSESSION_TREATMENT_DEMOTE, start=tmp_path, + ) == SUPERSESSION_TREATMENT_DEMOTE + # env beats both. + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_TREATMENT, "demote") + assert resolve_supersession_treatment( + SUPERSESSION_TREATMENT_EXCLUDE, start=tmp_path, + ) == SUPERSESSION_TREATMENT_DEMOTE + + +def test_factor_precedence_is_env_then_kwarg_then_toml( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Same ladder for the factor. Values chosen distinct and in range so + a clamp cannot make two layers agree by accident.""" + (tmp_path / ".aelfrice.toml").write_text( + "[retrieval]\nsupersession_demote_factor = 0.25\n", encoding="utf-8", + ) + + monkeypatch.delenv(retrieval.ENV_SUPERSESSION_FACTOR, raising=False) + assert resolve_supersession_factor(start=tmp_path) == pytest.approx(0.25) + assert resolve_supersession_factor(0.75, start=tmp_path) == pytest.approx(0.75) + + monkeypatch.setenv(retrieval.ENV_SUPERSESSION_FACTOR, "0.5") + assert resolve_supersession_factor(0.75, start=tmp_path) == pytest.approx(0.5) + + +# --- The exclusion arm must backfill, not shrink (#1187) ------------------ + + +def _packed_store(tmp_path: Path, matching: int, superseded: int) -> MemoryStore: + """`matching` beliefs that all hit the query, the strongest `superseded` + of them retired by one newer belief that does not match.""" + s = MemoryStore(str(tmp_path / "packed.db")) + ids: list[str] = [] + for i in range(matching): + bid = "B" + str(i).rjust(15, "0") + # Repeat the term so rank order is deterministic and the retired + # ones sit at the top, which is the case that starves the pack. + s.insert_belief( + _belief(bid, f"deploy target{' deploy' * (matching - i)} opt{i}", + "2026-01-01T00:00:00Z") + ) + ids.append(bid) + newer = "B" + "9" * 15 + s.insert_belief(_belief(newer, "unrelated replacement", "2026-07-01T00:00:00Z")) + for bid in ids[:superseded]: + s.insert_edge(Edge(src=newer, dst=bid, type=EDGE_SUPERSEDES, weight=1.0)) + return s + + +def test_exclusion_backfills_the_pack_instead_of_shrinking_it( + tmp_path: Path, +) -> None: + """The candidate limit is applied by the search, so filtering after it + would drop the pack size by however many were retired — leaving current, + relevant beliefs stranded just below the cutoff while the caller gets a + short pack. Same shape as the lock-budget starvation of #1014/#1015. + + It also has to be right for the demote-vs-exclude bench to mean + anything: if the arms differ in pack size as well as in treatment, + a loss for exclusion cannot be attributed to either. + """ + s = _packed_store(tmp_path, matching=20, superseded=8) + try: + for limit in (4, 10): + hits = _l1_hits( + s, "deploy", l1_limit=limit, posterior_weight=1.0, + use_supersession_demote=True, + supersession_treatment=SUPERSESSION_TREATMENT_EXCLUDE, + ) + assert len(hits) == limit, ( + f"exclusion returned {len(hits)} of {limit} — the pack " + f"shrank instead of backfilling from below the cutoff" + ) + sup = s.superseded_belief_ids([h.id for h in hits]) + assert not sup, f"a retired belief survived exclusion: {sup}" + finally: + s.close() + + +def test_exclusion_returns_what_exists_when_survivors_run_out( + tmp_path: Path, +) -> None: + """Backfilling must not invent rows. With fewer survivors than the + limit, the arm returns every survivor and stops — the widening loop + has to terminate on an exhausted search, not keep doubling.""" + s = _packed_store(tmp_path, matching=6, superseded=4) + try: + hits = _l1_hits( + s, "deploy", l1_limit=10, posterior_weight=1.0, + use_supersession_demote=True, + supersession_treatment=SUPERSESSION_TREATMENT_EXCLUDE, + ) + finally: + s.close() + assert len(hits) == 2, f"expected the 2 survivors, got {len(hits)}" + + +def test_the_demote_arm_keeps_the_full_pack(tmp_path: Path) -> None: + """Control: demote reorders a fixed candidate set, so it never needed + the refetch and must not have gained one. Retired beliefs stay in the + pack — demoted, not removed, which is the whole distinction between + the two arms.""" + s = _packed_store(tmp_path, matching=20, superseded=8) + try: + hits = _l1_hits( + s, "deploy", l1_limit=10, posterior_weight=1.0, + use_supersession_demote=True, + supersession_treatment=SUPERSESSION_TREATMENT_DEMOTE, + ) + assert len(hits) == 10 + still_there = s.superseded_belief_ids([h.id for h in hits]) + assert still_there, ( + "demote dropped every retired belief from the pack; that is " + "exclude's behaviour, not demote's" + ) + finally: + s.close()