Skip to content

feat: heat-kernel composition in retrieval (#151 slice 2) - #309

Closed
robotrocketscience wants to merge 2 commits into
mainfrom
feat/issue-151-slice2-heat-kernel
Closed

feat: heat-kernel composition in retrieval (#151 slice 2)#309
robotrocketscience wants to merge 2 commits into
mainfrom
feat/issue-151-slice2-heat-kernel

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Apr 29, 2026

Copy link
Copy Markdown
Owner

Closes part of #151 (slice 2 — heat-kernel composition).

Summary

Adds the optional heat-kernel authority term to the v1.3 ranking
score and wires it through retrieval. Default behaviour is
unchanged — every existing caller produces byte-identical output
because the heat-kernel lane requires both an eigenbasis_cache
kwarg AND is_heat_kernel_enabled() to resolve True.

Composition implemented (per #151):

score = log(-bm25_raw)
      + heat_kernel_weight * log(heat_kernel_safe)
      + posterior_weight   * log(posterior_mean)

Changes

  • scoring.partial_bayesian_score() gains heat_kernel: float = 1.0
    and heat_kernel_weight: float = 1.0. Default 1.0 makes
    log(1) == 0, so callers unchanged. Defence-in-depth floor at
    PARTIAL_BAYESIAN_BM25_FLOOR.
  • graph_spectral.heat_kernel_for_candidates(cache, candidates):
    new helper that builds a sparse seed vector from the L1 hit set,
    runs heat_kernel_score against the cached eigenbasis, applies
    heat_kernel_safe, and returns dict[belief_id, score] for
    candidates present in the eigenbasis row order. Returns {} when
    cache is stale, eigenbasis unbuilt, or no positive seed exists.
  • retrieval._l1_hits accepts an eigenbasis_cache kwarg; both
    the BM25F and FTS5 paths thread per-belief heat scores into
    partial_bayesian_score. Backward-compat preserved by the
    default 1.0 neutral.
  • retrieve() and retrieve_with_tiers() accept and forward
    eigenbasis_cache into _l1_hits. The RetrievalCache and
    retrieve_v2 wrappers stay untouched in this slice; benchmark
    callers using the lower-level entry points get the new lane
    immediately.

Tests

tests/test_bayesian_ranking.py (+8):

  • neutral default at heat_kernel=1.0
  • log-additivity at unit weight
  • weight-zero collapse
  • floor behaviour for non-positive heat-kernel input
  • monotonicity in authority
  • byte-identity vs no-cache baseline (flag on, no cache)
  • retrieve() smoke with cache built and flag on
  • byte-identity when flag off (cache supplied but ignored)

tests/test_graph_spectral.py (+4):

  • per-belief score output for the toy graph
  • unknown-id passthrough
  • stale-cache returns {}
  • empty inputs / non-positive seeds return {}

Full suite: 1897 passed, 8 skipped.

Out of scope (future slice)

Discretion check

Discretion grep against the canonical pattern set — CLEAN.

Summary by Sourcery

Add an optional heat-kernel authority term to the v1.3 retrieval ranking score and wire it through L1 retrieval while preserving default, byte-identical behaviour when the feature is disabled or no cache is available.

New Features:

  • Introduce heat-kernel authority composition into partial_bayesian_score via configurable heat_kernel and heat_kernel_weight parameters.
  • Add heat_kernel_for_candidates to compute per-belief heat-kernel authority scores for L1 candidates using a precomputed graph eigenbasis.
  • Extend retrieve and retrieve_with_tiers to accept an eigenbasis_cache for enabling heat-kernel-based reranking of L1 results.

Enhancements:

  • Update L1 retrieval (_l1_hits) to incorporate heat-kernel scores alongside BM25 and posterior terms, with strict preservation of legacy ordering when the new lane yields no contribution.

Tests:

  • Add unit tests covering heat-kernel composition behaviour in partial_bayesian_score, including neutrality, log-additivity, weight-zero collapse, flooring, and monotonicity.
  • Add retrieval tests to ensure byte-identical behaviour without a cache or with the feature flag off, and smoke test retrieval with an eigenbasis cache and flag enabled.
  • Add graph_spectral tests validating heat_kernel_for_candidates output, handling of unknown IDs, stale caches, and empty or non-positive seed inputs.

Extend the v1.3 ranking score with an optional log-additive
heat-kernel authority term. Default heat_kernel=1.0 contributes
log(1)=0, preserving byte-identical output for callers that have
no eigenbasis available (the common path until graph-spectral
caches land in retrieval).

Defence-in-depth floor matches PARTIAL_BAYESIAN_BM25_FLOOR; the
primary guard remains graph_spectral.heat_kernel_safe at the
eigenbasis layer. Five new tests pin the neutral default,
log-additivity at unit weight, weight-zero collapse, floor
behaviour, and monotonicity in authority.
…part 2)

Adds heat_kernel_for_candidates() helper to graph_spectral: given
the eigenbasis cache and an L1 candidate set with positive BM25
relevance magnitudes, returns per-belief heat-kernel authority
scores (floored).

Threads an optional eigenbasis_cache kwarg through retrieve() and
retrieve_with_tiers() into _l1_hits. When the kwarg is supplied
AND is_heat_kernel_enabled() resolves True, _l1_hits computes
per-candidate heat-kernel scores and passes them as heat_kernel=
into partial_bayesian_score. Default heat_kernel=1.0 keeps
non-supplied / disabled callers byte-identical.

Cache absence, staleness, empty candidates, and unknown belief
ids all fail soft to a neutral no-contribution. Tests cover the
helper's per-belief output, unknown-id passthrough, stale-cache
behaviour, empty inputs, retrieve()-level wiring, and the
flag-off / no-cache byte-identity guarantee.
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@robotrocketscience has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 22 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0953bdd8-e2a4-4168-a0fc-40a56875c832

📥 Commits

Reviewing files that changed from the base of the PR and between 5817dd7 and b1064b4.

📒 Files selected for processing (5)
  • src/aelfrice/graph_spectral.py
  • src/aelfrice/retrieval.py
  • src/aelfrice/scoring.py
  • tests/test_bayesian_ranking.py
  • tests/test_graph_spectral.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-151-slice2-heat-kernel

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 36 minutes and 22 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai

sourcery-ai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements optional heat-kernel authority composition in the v1.3 retrieval score, threading an eigenbasis-backed heat-kernel lane through L1 retrieval while preserving byte-identical behavior for existing callers when the feature is disabled or unavailable.

Sequence diagram for L1 retrieval with optional heat-kernel authority

sequenceDiagram
    actor Client
    participant Retrieval as retrieve
    participant RetrievalTiers as retrieve_with_tiers
    participant L1 as _l1_hits
    participant Store as MemoryStore
    participant HKLookup as _heat_kernel_lookup
    participant Spectral as heat_kernel_for_candidates
    participant Cache as GraphEigenbasisCache

    Client->>Retrieval: retrieve(query, eigenbasis_cache)
    Retrieval->>L1: _l1_hits(..., eigenbasis_cache)
    alt use_bm25f_anchors is True
        L1->>Store: bm25f_search_beliefs_scored
        Store-->>L1: (Belief, bm25_pos) list
    else use_bm25f_anchors is False
        L1->>Store: search_beliefs_scored
        Store-->>L1: (Belief, bm25_raw) list
    end

    L1->>HKLookup: _heat_kernel_lookup(eigenbasis_cache, candidates)
    alt cache is None or candidates empty
        HKLookup-->>L1: {}
    else is_heat_kernel_enabled() is False
        HKLookup-->>L1: {}
    else heat-kernel lane enabled
        HKLookup->>Spectral: heat_kernel_for_candidates(Cache, candidates)
        Spectral->>Cache: check is_stale(), eigvals, eigvecs, belief_ids
        alt cache stale or eigenbasis missing
            Spectral-->>HKLookup: {}
        else seeds available
            Spectral-->>HKLookup: {belief_id: heat_score}
        end
        HKLookup-->>L1: {belief_id: heat_score}
    end

    alt posterior_weight == 0.0 and no heat_scores
        L1->>Store: search_beliefs(query)
        Store-->>L1: Belief list
        L1-->>Retrieval: Belief list
    else scoring path
        L1->>L1: partial_bayesian_score(bm25_raw, alpha, beta, posterior_weight, heat_kernel)
        L1-->>Retrieval: ranked Belief list
    end

    Retrieval-->>Client: Belief list

    Client->>RetrievalTiers: retrieve_with_tiers(..., eigenbasis_cache)
    RetrievalTiers->>L1: _l1_hits(..., eigenbasis_cache)
    L1-->>RetrievalTiers: ranked L1 Belief list
    RetrievalTiers-->>Client: tiered results
Loading

Flow diagram for partial_bayesian_score with heat-kernel composition

flowchart TD
    A["Start partial_bayesian_score"] --> B["relevance_pos = max(-bm25_raw, PARTIAL_BAYESIAN_BM25_FLOOR)"]
    B --> C["score = log(relevance_pos)"]
    C --> D{"heat_kernel != 1.0 and<br/>heat_kernel_weight != 0.0?"}
    D -->|No| F
    D -->|Yes| E["heat_safe = max(heat_kernel, PARTIAL_BAYESIAN_BM25_FLOOR)<br/>score += heat_kernel_weight * log(heat_safe)"]
    E --> F{posterior_weight == 0.0?}
    F -->|Yes| G[Return score]
    F -->|No| H["p = posterior_mean(alpha, beta)"]
    H --> I["p_safe = p if p > 0.0<br/>else PARTIAL_BAYESIAN_BM25_FLOOR"]
    I --> J["score += posterior_weight * log(p_safe)"]
    J --> K[Return score]
    G --> L[End]
    K --> L
Loading

File-Level Changes

Change Details Files
Extend the partial Bayesian ranking score to include an optional heat-kernel authority term while preserving previous behavior by default.
  • Add heat_kernel and heat_kernel_weight parameters to partial_bayesian_score with neutral defaults
  • Incorporate heat-kernel contribution as heat_kernel_weight * log(max(heat_kernel, PARTIAL_BAYESIAN_BM25_FLOOR)) into the score accumulation
  • Keep posterior_weight==0.0 and heat_kernel==1.0 behavior identical to legacy log(-bm25_raw) scoring
src/aelfrice/scoring.py
tests/test_bayesian_ranking.py
Introduce a helper to compute per-candidate heat-kernel authority scores from the eigenbasis cache for L1 candidates.
  • Add heat_kernel_for_candidates that maps L1 (belief_id, positive_bm25) candidates to floored heat-kernel scores using seeds_from_bm25 and heat_kernel_score
  • Guard against stale/unbuilt eigenbasis, missing belief_ids, or non-positive seeds by returning an empty dict
  • Ensure only candidates present in the eigenbasis row order receive scores and unknown IDs are skipped
src/aelfrice/graph_spectral.py
tests/test_graph_spectral.py
Wire the heat-kernel lane into L1 retrieval and retrieval entry points using an optional eigenbasis_cache while maintaining byte-identical results when the lane is inactive or produces no scores.
  • Extend _l1_hits to accept eigenbasis_cache and call a new _heat_kernel_lookup helper to fetch per-belief authority scores
  • Combine BM25, Bayesian posterior, and heat-kernel scores in partial_bayesian_score for both BM25F and FTS5 paths, defaulting to heat_kernel=1.0 when no per-belief score exists
  • Preserve BM25-only ordering when posterior_weight==0.0 and the heat-kernel lane yields no scores, including a re-fetch path for FTS5 to match baseline tie-breaking
  • Add _heat_kernel_lookup that checks the feature flag, cache readiness, and candidate list, returning {} when the heat-kernel lane should no-op
  • Plumb optional eigenbasis_cache through retrieve and retrieve_with_tiers into _l1_hits
src/aelfrice/retrieval.py
tests/test_bayesian_ranking.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@robotrocketscience robotrocketscience added the author-Kulili PR coordination mutex label Apr 29, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In heat_kernel_for_candidates, rebuilding id_to_row on every call is O(N) and may dominate cost for frequent L1 calls; consider storing a belief_id → row index on GraphEigenbasisCache (built alongside belief_ids) and reusing it here.
  • In _l1_hits FTS5 path, when posterior_weight == 0.0 and heat_scores is empty you re-run search_beliefs after already calling search_beliefs_scored; if identical ordering is guaranteed between the two, you could avoid the second query by reusing the scored result (e.g., by dropping scores) to save one DB hit.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `heat_kernel_for_candidates`, rebuilding `id_to_row` on every call is O(N) and may dominate cost for frequent L1 calls; consider storing a `belief_id → row` index on `GraphEigenbasisCache` (built alongside `belief_ids`) and reusing it here.
- In `_l1_hits` FTS5 path, when `posterior_weight == 0.0` and `heat_scores` is empty you re-run `search_beliefs` after already calling `search_beliefs_scored`; if identical ordering is guaranteed between the two, you could avoid the second query by reusing the `scored` result (e.g., by dropping scores) to save one DB hit.

## Individual Comments

### Comment 1
<location path="src/aelfrice/graph_spectral.py" line_range="389-398" />
<code_context>
+    id_to_row: dict[str, int] = {bid: i for i, bid in enumerate(cache.belief_ids)}
+    n = len(cache.belief_ids)
+    bm25_full = np.zeros(n, dtype=np.float64)
+    present: list[str] = []
+    for bid, bm25_pos in candidates:
+        row = id_to_row.get(bid)
+        if row is None:
+            continue
+        if bm25_pos > 0.0:
+            bm25_full[row] = bm25_pos
+            present.append(bid)
+    if not present:
+        return {}
+    seeds = seeds_from_bm25(bm25_full, top_k=seed_top_k)
+    if not np.any(seeds):
+        return {}
+    raw = heat_kernel_score(cache.eigvals, cache.eigvecs, seeds, t=t)
+    safe = heat_kernel_safe(raw, floor=floor)
+    return {bid: float(safe[id_to_row[bid]]) for bid in present}
+
+
</code_context>
<issue_to_address>
**suggestion:** Function only returns scores for positively seeded candidates, which slightly contradicts the docstring.

The docstring promises `dict[belief_id, heat_kernel_score]` for every candidate with a row in the eigenbasis, but the code only records candidates when `bm25_pos > 0.0`. So any candidate with an eigenbasis row but non‑positive `bm25_pos` is omitted, even though it would get a finite score from diffusion. Please decide whether these should be included with their propagated scores or intentionally excluded, and update the docstring and/or logic to match that decision.

Suggested implementation:

```python
    id_to_row: dict[str, int] = {bid: i for i, bid in enumerate(cache.belief_ids)}
    n = len(cache.belief_ids)
    bm25_full = np.zeros(n, dtype=np.float64)

```

```python
def heat_kernel_for_candidates(
    cache: "GraphEigenbasisCache",
    candidates: list[tuple[str, float]],
    *,
    t: float = DEFAULT_HEAT_BANDWIDTH,
    seed_top_k: int = DEFAULT_BM25_SEED_TOP_K,
    floor: float = HEAT_SCORE_FLOOR,
) -> dict[str, float]:
    """Compute per-belief heat-kernel authority scores for the
    given `candidates`, returning `dict[belief_id, heat_kernel_score]`
    for every candidate that has a corresponding row in the eigenbasis.

    The heat kernel is seeded from candidates with positive BM25 scores;
    candidates with non-positive BM25 scores can still receive non-zero
    authority via diffusion, and their scores are included in the result.
    """
    if cache.is_stale() or cache.eigvals is None or cache.eigvecs is None:
        return {}
    if cache.belief_ids is None or not candidates:
        return {}

    # Map belief IDs to their row indices in the eigenbasis.
    id_to_row: dict[str, int] = {bid: i for i, bid in enumerate(cache.belief_ids)}
    n = len(cache.belief_ids)

    # Dense BM25 vector over the full eigenbasis rows.
    bm25_full = np.zeros(n, dtype=np.float64)

    # Track which candidates actually have a row in the eigenbasis.
    rows_for_bids: dict[str, int] = {}

    for bid, bm25_pos in candidates:
        row = id_to_row.get(bid)
        if row is None:
            # Candidate not present in eigenbasis; cannot score it.
            continue

        rows_for_bids[bid] = row

        # Only positive BM25 scores are used as seeds.
        if bm25_pos > 0.0:
            bm25_full[row] = bm25_pos

    # If there are no positive seeds, there is nothing to diffuse.
    if not np.any(bm25_full):
        return {}

    seeds = seeds_from_bm25(bm25_full, top_k=seed_top_k)
    if not np.any(seeds):
        return {}

    raw = heat_kernel_score(cache.eigvals, cache.eigvecs, seeds, t=t)
    safe = heat_kernel_safe(raw, floor=floor)

    # Return scores for all candidates that had a row in the eigenbasis,
    # regardless of whether they were used as positive seeds.
    return {bid: float(safe[row]) for bid, row in rows_for_bids.items()}

```

If the existing docstring for `heat_kernel_for_candidates` spans more lines than shown in the snippet, you should ensure the updated description above is consistent with the rest of the documentation. Specifically:
1. Confirm that any references to "positively seeded candidates" are updated to explain that *seeding* uses positive BM25 scores but *outputs* are provided for all candidates with eigenbasis rows.
2. If there are unit tests asserting that only positively seeded candidates are returned, update them to expect scores for all candidates with rows in the eigenbasis, while still expecting an empty dict when there are no positive BM25 seeds.
</issue_to_address>

### Comment 2
<location path="src/aelfrice/scoring.py" line_range="171-180" />
<code_context>
     alpha: float,
     beta: float,
     posterior_weight: float = DEFAULT_POSTERIOR_WEIGHT,
+    heat_kernel: float = 1.0,
+    heat_kernel_weight: float = 1.0,
 ) -> float:
</code_context>
<issue_to_address>
**issue:** The docstring claims the heat-kernel floor is identical to `heat_kernel_safe`, but the code uses `PARTIAL_BAYESIAN_BM25_FLOOR`.

The docstring states the heat-kernel input must be passed through `heat_kernel_safe` and that the floor is identical, but the code uses `PARTIAL_BAYESIAN_BM25_FLOOR`. If `HEAT_SCORE_FLOOR` and `PARTIAL_BAYESIAN_BM25_FLOOR` diverge, this will no longer hold and double-flooring could bias scores away from the 1.0 baseline. Consider either using the same constant as `heat_kernel_safe` or updating the docstring to say the floor is only similar, not identical.
</issue_to_address>

### Comment 3
<location path="tests/test_bayesian_ranking.py" line_range="511-520" />
<code_context>
+            assert with_default == base
+
+
+def test_heat_kernel_log_additive_at_unit_weight() -> None:
+    """At heat_kernel_weight=1.0 the heat term adds log(heat_kernel)
+    on top of the bm25+posterior baseline."""
+    bm25_raw, alpha, beta, pw = -1.0, 2.0, 1.0, 0.5
+    heat = 0.4
+    base = partial_bayesian_score(bm25_raw, alpha, beta, pw)
+    composed = partial_bayesian_score(
+        bm25_raw, alpha, beta, pw, heat_kernel=heat,
+    )
+    assert abs(composed - (base + math.log(heat))) < 1e-12
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for non-unit `heat_kernel_weight` values.

Current tests cover only `heat_kernel_weight` = 1.0 and 0.0. Please add a parametrized test over a few `(heat_kernel, heat_kernel_weight)` pairs (e.g., `w in {0.5, 2.0}`) that checks `partial_bayesian_score(..., heat_kernel=h, heat_kernel_weight=w) == base + w * log(h)`, and also verifies that `heat_kernel=1.0` is neutral even when `heat_kernel_weight != 1.0` to lock in the weighting semantics.

```suggestion
def test_heat_kernel_log_additive_at_unit_weight() -> None:
    """At heat_kernel_weight=1.0 the heat term adds log(heat_kernel)
    on top of the bm25+posterior baseline."""
    bm25_raw, alpha, beta, pw = -1.0, 2.0, 1.0, 0.5
    heat = 0.4
    base = partial_bayesian_score(bm25_raw, alpha, beta, pw)
    composed = partial_bayesian_score(
        bm25_raw, alpha, beta, pw, heat_kernel=heat,
    )
    assert abs(composed - (base + math.log(heat))) < 1e-12


@pytest.mark.parametrize(
    "heat_kernel, heat_kernel_weight",
    [
        (0.4, 0.5),
        (0.4, 2.0),
        (0.1, 0.5),
        (0.9, 2.0),
    ],
)
def test_heat_kernel_log_additive_with_weight(
    heat_kernel: float,
    heat_kernel_weight: float,
) -> None:
    """For non-unit heat_kernel_weight the heat term scales log(heat_kernel),
    and heat_kernel=1.0 remains neutral."""
    bm25_raw, alpha, beta, pw = -1.0, 2.0, 1.0, 0.5
    base = partial_bayesian_score(bm25_raw, alpha, beta, pw)

    composed = partial_bayesian_score(
        bm25_raw,
        alpha,
        beta,
        pw,
        heat_kernel=heat_kernel,
        heat_kernel_weight=heat_kernel_weight,
    )
    assert abs(
        composed - (base + heat_kernel_weight * math.log(heat_kernel))
    ) < 1e-12

    neutral = partial_bayesian_score(
        bm25_raw,
        alpha,
        beta,
        pw,
        heat_kernel=1.0,
        heat_kernel_weight=heat_kernel_weight,
    )
    assert abs(neutral - base) < 1e-12
```
</issue_to_address>

### Comment 4
<location path="tests/test_bayesian_ranking.py" line_range="596-605" />
<code_context>
+def test_retrieve_eigenbasis_cache_threaded_through(
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a BM25F-path smoke test with `eigenbasis_cache` to cover the `_l1_hits` BM25F branch.

The current tests only cover the FTS5 path with/without `eigenbasis_cache` and the flag toggled. Since `_l1_hits` now also passes `eigenbasis_cache` through the BM25F path (`use_bm25f_anchors=True`), that branch is untested. Please add a small smoke test that:

- builds a `GraphEigenbasisCache`,
- calls `retrieve(..., use_bm25f_anchors=True, bm25f_cache=..., eigenbasis_cache=..., posterior_weight=0.0 or >0, AELFRICE_HEAT_KERNEL=1)`,
- asserts the call succeeds and returns non-empty results (optionally checking stable ordering when `posterior_weight=0` with the flag off vs on).

This will mirror the FTS5 coverage and verify the heat-kernel wiring for BM25F as well.

Suggested implementation:

```python
    from aelfrice.graph_spectral import GraphEigenbasisCache

    s = _equal_bm25_store()
    cache = GraphEigenbasisCache(store=s, path=tmp_path / "eb.npz", k=3)

    # Smoke-test the BM25F path with eigenbasis_cache threaded through.
    # This mirrors the FTS5 coverage and ensures the heat-kernel wiring works
    # when `_l1_hits` routes through BM25F with use_bm25f_anchors=True.
    bm25f_cache = _equal_bm25f_cache(store=s)

    prior = os.environ.get("AELFRICE_HEAT_KERNEL")
    os.environ["AELFRICE_HEAT_KERNEL"] = "1"
    try:
        l1 = retrieve(
            s,
            "widget",
            token_budget=10_000,
            posterior_weight=0.0,
            use_bm25f_anchors=True,
            bm25f_cache=bm25f_cache,
            eigenbasis_cache=cache,
        )
    finally:
        if prior is None:
            os.environ.pop("AELFRICE_HEAT_KERNEL", None)
        else:
            os.environ["AELFRICE_HEAT_KERNEL"] = prior

    assert l1, "BM25F + eigenbasis_cache should return a non-empty L1"

```

1. Ensure there is a helper `_equal_bm25f_cache(store: Store)` (or equivalent) that builds a BM25F cache compatible with `retrieve(..., bm25f_cache=...)`. If your test module already has a BM25F cache helper, reuse that name instead of `_equal_bm25f_cache`.
2. If `retrieve` is not already imported into this module, add the appropriate import at the top of `tests/test_bayesian_ranking.py` (e.g., `from aelfrice.ranking import retrieve`) consistent with the existing imports and conventions in the file.
3. If your existing FTS5 eigenbasis tests use a different query string, token budget, or fixture setup for `_equal_bm25_store()`, align the parameters here to match those tests for more stable behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +389 to +398
present: list[str] = []
for bid, bm25_pos in candidates:
row = id_to_row.get(bid)
if row is None:
continue
if bm25_pos > 0.0:
bm25_full[row] = bm25_pos
present.append(bid)
if not present:
return {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Function only returns scores for positively seeded candidates, which slightly contradicts the docstring.

The docstring promises dict[belief_id, heat_kernel_score] for every candidate with a row in the eigenbasis, but the code only records candidates when bm25_pos > 0.0. So any candidate with an eigenbasis row but non‑positive bm25_pos is omitted, even though it would get a finite score from diffusion. Please decide whether these should be included with their propagated scores or intentionally excluded, and update the docstring and/or logic to match that decision.

Suggested implementation:

    id_to_row: dict[str, int] = {bid: i for i, bid in enumerate(cache.belief_ids)}
    n = len(cache.belief_ids)
    bm25_full = np.zeros(n, dtype=np.float64)
def heat_kernel_for_candidates(
    cache: "GraphEigenbasisCache",
    candidates: list[tuple[str, float]],
    *,
    t: float = DEFAULT_HEAT_BANDWIDTH,
    seed_top_k: int = DEFAULT_BM25_SEED_TOP_K,
    floor: float = HEAT_SCORE_FLOOR,
) -> dict[str, float]:
    """Compute per-belief heat-kernel authority scores for the
    given `candidates`, returning `dict[belief_id, heat_kernel_score]`
    for every candidate that has a corresponding row in the eigenbasis.

    The heat kernel is seeded from candidates with positive BM25 scores;
    candidates with non-positive BM25 scores can still receive non-zero
    authority via diffusion, and their scores are included in the result.
    """
    if cache.is_stale() or cache.eigvals is None or cache.eigvecs is None:
        return {}
    if cache.belief_ids is None or not candidates:
        return {}

    # Map belief IDs to their row indices in the eigenbasis.
    id_to_row: dict[str, int] = {bid: i for i, bid in enumerate(cache.belief_ids)}
    n = len(cache.belief_ids)

    # Dense BM25 vector over the full eigenbasis rows.
    bm25_full = np.zeros(n, dtype=np.float64)

    # Track which candidates actually have a row in the eigenbasis.
    rows_for_bids: dict[str, int] = {}

    for bid, bm25_pos in candidates:
        row = id_to_row.get(bid)
        if row is None:
            # Candidate not present in eigenbasis; cannot score it.
            continue

        rows_for_bids[bid] = row

        # Only positive BM25 scores are used as seeds.
        if bm25_pos > 0.0:
            bm25_full[row] = bm25_pos

    # If there are no positive seeds, there is nothing to diffuse.
    if not np.any(bm25_full):
        return {}

    seeds = seeds_from_bm25(bm25_full, top_k=seed_top_k)
    if not np.any(seeds):
        return {}

    raw = heat_kernel_score(cache.eigvals, cache.eigvecs, seeds, t=t)
    safe = heat_kernel_safe(raw, floor=floor)

    # Return scores for all candidates that had a row in the eigenbasis,
    # regardless of whether they were used as positive seeds.
    return {bid: float(safe[row]) for bid, row in rows_for_bids.items()}

If the existing docstring for heat_kernel_for_candidates spans more lines than shown in the snippet, you should ensure the updated description above is consistent with the rest of the documentation. Specifically:

  1. Confirm that any references to "positively seeded candidates" are updated to explain that seeding uses positive BM25 scores but outputs are provided for all candidates with eigenbasis rows.
  2. If there are unit tests asserting that only positively seeded candidates are returned, update them to expect scores for all candidates with rows in the eigenbasis, while still expecting an empty dict when there are no positive BM25 seeds.

Comment thread src/aelfrice/scoring.py
Comment on lines +171 to +180
heat_kernel: float = 1.0,
heat_kernel_weight: float = 1.0,
) -> float:
"""v1.3 partial Bayesian-weighted retrieval score.
"""v1.3 partial Bayesian-weighted retrieval score, optionally
composed with a heat-kernel authority term (#151 slice 2).

`score = log(max(-bm25_raw, EPS)) + posterior_weight * log(posterior_mean)`
Full formula::

score = log(max(-bm25_raw, EPS))
+ heat_kernel_weight * log(max(heat_kernel, EPS))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: The docstring claims the heat-kernel floor is identical to heat_kernel_safe, but the code uses PARTIAL_BAYESIAN_BM25_FLOOR.

The docstring states the heat-kernel input must be passed through heat_kernel_safe and that the floor is identical, but the code uses PARTIAL_BAYESIAN_BM25_FLOOR. If HEAT_SCORE_FLOOR and PARTIAL_BAYESIAN_BM25_FLOOR diverge, this will no longer hold and double-flooring could bias scores away from the 1.0 baseline. Consider either using the same constant as heat_kernel_safe or updating the docstring to say the floor is only similar, not identical.

Comment on lines +511 to +520
def test_heat_kernel_log_additive_at_unit_weight() -> None:
"""At heat_kernel_weight=1.0 the heat term adds log(heat_kernel)
on top of the bm25+posterior baseline."""
bm25_raw, alpha, beta, pw = -1.0, 2.0, 1.0, 0.5
heat = 0.4
base = partial_bayesian_score(bm25_raw, alpha, beta, pw)
composed = partial_bayesian_score(
bm25_raw, alpha, beta, pw, heat_kernel=heat,
)
assert abs(composed - (base + math.log(heat))) < 1e-12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for non-unit heat_kernel_weight values.

Current tests cover only heat_kernel_weight = 1.0 and 0.0. Please add a parametrized test over a few (heat_kernel, heat_kernel_weight) pairs (e.g., w in {0.5, 2.0}) that checks partial_bayesian_score(..., heat_kernel=h, heat_kernel_weight=w) == base + w * log(h), and also verifies that heat_kernel=1.0 is neutral even when heat_kernel_weight != 1.0 to lock in the weighting semantics.

Suggested change
def test_heat_kernel_log_additive_at_unit_weight() -> None:
"""At heat_kernel_weight=1.0 the heat term adds log(heat_kernel)
on top of the bm25+posterior baseline."""
bm25_raw, alpha, beta, pw = -1.0, 2.0, 1.0, 0.5
heat = 0.4
base = partial_bayesian_score(bm25_raw, alpha, beta, pw)
composed = partial_bayesian_score(
bm25_raw, alpha, beta, pw, heat_kernel=heat,
)
assert abs(composed - (base + math.log(heat))) < 1e-12
def test_heat_kernel_log_additive_at_unit_weight() -> None:
"""At heat_kernel_weight=1.0 the heat term adds log(heat_kernel)
on top of the bm25+posterior baseline."""
bm25_raw, alpha, beta, pw = -1.0, 2.0, 1.0, 0.5
heat = 0.4
base = partial_bayesian_score(bm25_raw, alpha, beta, pw)
composed = partial_bayesian_score(
bm25_raw, alpha, beta, pw, heat_kernel=heat,
)
assert abs(composed - (base + math.log(heat))) < 1e-12
@pytest.mark.parametrize(
"heat_kernel, heat_kernel_weight",
[
(0.4, 0.5),
(0.4, 2.0),
(0.1, 0.5),
(0.9, 2.0),
],
)
def test_heat_kernel_log_additive_with_weight(
heat_kernel: float,
heat_kernel_weight: float,
) -> None:
"""For non-unit heat_kernel_weight the heat term scales log(heat_kernel),
and heat_kernel=1.0 remains neutral."""
bm25_raw, alpha, beta, pw = -1.0, 2.0, 1.0, 0.5
base = partial_bayesian_score(bm25_raw, alpha, beta, pw)
composed = partial_bayesian_score(
bm25_raw,
alpha,
beta,
pw,
heat_kernel=heat_kernel,
heat_kernel_weight=heat_kernel_weight,
)
assert abs(
composed - (base + heat_kernel_weight * math.log(heat_kernel))
) < 1e-12
neutral = partial_bayesian_score(
bm25_raw,
alpha,
beta,
pw,
heat_kernel=1.0,
heat_kernel_weight=heat_kernel_weight,
)
assert abs(neutral - base) < 1e-12

Comment on lines +596 to +605
def test_retrieve_eigenbasis_cache_threaded_through(
tmp_path: Path,
) -> None:
"""Smoke test: retrieve() accepts eigenbasis_cache, builds it,
enables the heat-kernel flag, and returns a non-empty L1 with
no exceptions. The actual reranking is exercised by the
scoring-level tests above; this confirms wiring."""
import os
from aelfrice.graph_spectral import GraphEigenbasisCache
s = _equal_bm25_store()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a BM25F-path smoke test with eigenbasis_cache to cover the _l1_hits BM25F branch.

The current tests only cover the FTS5 path with/without eigenbasis_cache and the flag toggled. Since _l1_hits now also passes eigenbasis_cache through the BM25F path (use_bm25f_anchors=True), that branch is untested. Please add a small smoke test that:

  • builds a GraphEigenbasisCache,
  • calls retrieve(..., use_bm25f_anchors=True, bm25f_cache=..., eigenbasis_cache=..., posterior_weight=0.0 or >0, AELFRICE_HEAT_KERNEL=1),
  • asserts the call succeeds and returns non-empty results (optionally checking stable ordering when posterior_weight=0 with the flag off vs on).

This will mirror the FTS5 coverage and verify the heat-kernel wiring for BM25F as well.

Suggested implementation:

    from aelfrice.graph_spectral import GraphEigenbasisCache

    s = _equal_bm25_store()
    cache = GraphEigenbasisCache(store=s, path=tmp_path / "eb.npz", k=3)

    # Smoke-test the BM25F path with eigenbasis_cache threaded through.
    # This mirrors the FTS5 coverage and ensures the heat-kernel wiring works
    # when `_l1_hits` routes through BM25F with use_bm25f_anchors=True.
    bm25f_cache = _equal_bm25f_cache(store=s)

    prior = os.environ.get("AELFRICE_HEAT_KERNEL")
    os.environ["AELFRICE_HEAT_KERNEL"] = "1"
    try:
        l1 = retrieve(
            s,
            "widget",
            token_budget=10_000,
            posterior_weight=0.0,
            use_bm25f_anchors=True,
            bm25f_cache=bm25f_cache,
            eigenbasis_cache=cache,
        )
    finally:
        if prior is None:
            os.environ.pop("AELFRICE_HEAT_KERNEL", None)
        else:
            os.environ["AELFRICE_HEAT_KERNEL"] = prior

    assert l1, "BM25F + eigenbasis_cache should return a non-empty L1"
  1. Ensure there is a helper _equal_bm25f_cache(store: Store) (or equivalent) that builds a BM25F cache compatible with retrieve(..., bm25f_cache=...). If your test module already has a BM25F cache helper, reuse that name instead of _equal_bm25f_cache.
  2. If retrieve is not already imported into this module, add the appropriate import at the top of tests/test_bayesian_ranking.py (e.g., from aelfrice.ranking import retrieve) consistent with the existing imports and conventions in the file.
  3. If your existing FTS5 eigenbasis tests use a different query string, token budget, or fixture setup for _equal_bm25_store(), align the parameters here to match those tests for more stable behavior.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-04-29T05:54:22Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-04-29T05:55:26Z]

@robotrocketscience
robotrocketscience enabled auto-merge (squash) April 29, 2026 05:55
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Gylf:2026-04-29T15:48:25Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Gylf:2026-04-29T15:49:21Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Superseded by #310 (merged). Closing.

auto-merge was automatically disabled April 29, 2026 15:55

Pull request was closed

@robotrocketscience
robotrocketscience deleted the feat/issue-151-slice2-heat-kernel branch April 29, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant