Skip to content

feat(retrieval): ACT-R fan-effect ranking for the entity lane, default off (#1176) - #1234

Merged
github-actions[bot] merged 5 commits into
mainfrom
feat/issue-1176-fan-effect
Jul 31, 2026
Merged

feat(retrieval): ACT-R fan-effect ranking for the entity lane, default off (#1176)#1234
github-actions[bot] merged 5 commits into
mainfrom
feat/issue-1176-fan-effect

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Builds proposal 3 of #1176 — the ACT-R fan effect on the L2.5 entity lane — behind a flag, default off. The operator disposition of 2026-07-30 ratified proceed to the A/B; this is the mechanism the A/B needs, built to the three constraints that disposition carried forward.

Refs #1176.

What it does

MemoryStore.lookup_entities gains fan_effect. Off, it is the shipped COUNT(DISTINCT entity_lower) ordering, byte for byte. On, it orders by Anderson's fan-effect activation

A_i = Sum_{j in Q & entities(i)}  ln( (N + 1) / fan_j ),   fan_j = 1 + |active beliefs carrying j|

over the query entities the belief carries. Written as a log ratio this is algebraically IDF — Anderson (1993) makes that identification explicitly — so it lands in a system that already computes idf rather than introducing a second calibration to tune.

The count lane prices every matched entity the same. The corpus does not: tmp sits in 1,480 beliefs and and in 1,026, while 86% of entities sit in exactly one. So a match on a corpus-ubiquitous token buys the same rank as a match on a unique symbol — on the one lane that holds unconditional budget precedence.

Two properties make the change conservative rather than a new axis. Every term is non-negative (fan_j <= N + 1), so an additional match can never demote a belief. And with all fans equal the activation is a constant multiple of the overlap, so the ordering degenerates exactly to the lane it replaces — that is a test, not a claim.

The three carried-forward constraints

  • No entity_fan table, no migration. Fan is counted inline over the query's own keys (at most 512, in practice at most query_entity_cap). This resolves the issue's internal contradiction — summary line "~25 LOC, no migration" against a proposal body asking for a table — in favour of the summary. Given [Umbrella] Deployment and operational hardening #1161, dropping the migration is the whole risk profile.
  • No SQL LN(). It requires SQLITE_ENABLE_MATH_FUNCTIONS, which is not guaranteed across the support matrix. The logarithm is taken in Python.
  • Arm split is the bench's job, noted in the changelog so it is not lost between here and the A/B: 68% of logged prompts are harness <task-notification> blocks, 100% high-fan-bearing by construction, and pooling them fabricates the result in the proposal's favour.

Cost is below the lane it replaces — 0.04 ms p50 against 0.09 ms, tail 102 ms down to 8 ms — because the shipped path sorts every matching row while this one sorts only the grouped beliefs. Two indexed reads replace one.

Fan counts active beliefs only, matching the lane's own valid_to IS NULL filter. Otherwise a retired belief would keep damping every term it once carried.

Reachable from the path that will be measured

retrieve() — not retrieve_v2 — is what the hooks call, and #1107 is the standing example of a lane that existed only in the latter. The flag threads retrieve_v2retrieve_with_tiers_l25_hitslookup_entities, and retrieve() passes it through. Dropping the wiring at any of those joints turns test_env_var_reaches_the_production_retrieve_path red; both joints were checked by mutation, not by inspection.

Verification

Full suite: 6559 passed, 69 skipped, 71 xfailed. Eight mutations, seven caught:

mutation result
(control) 15 passed
drop the fan_effect dispatch 7 failed
activation ignores fan (constant weight) 7 failed
apply limit before the sort 7 failed
fan counted without valid_to IS NULL 1 failed — test_retired_beliefs_do_not_inflate_fan
drop the origin rank under fan 1 failed — test_origin_tiebreak_composes_with_fan
unwire _l25_hitslookup_entities 1 failed — the production-path test
unwire retrieve_with_tiers_l25_hits 1 failed — the production-path test
retrieve() passes False instead of None 0 failed

The escape is reported rather than papered over, and it corrected the code. I had written both a comment and a docstring claiming that None at that call site is load-bearing — that a hard False would leave AELFRICE_FAN_EFFECT=1 inert on the production path. It would not: the resolver is env-first, so the env var overrides either spelling. None is still the right spelling, because a .aelfrice.toml tier would have to read through it and that tier is the natural companion to a default flip — but it is a convention, not a guard. The comment and the test docstring now say that, and the test asserts only what it can: that the env var reaches retrieve() at all.

The fixtures are built so the two lanes must disagree — the rare-matching belief carries the alphabetically later id, so under the shipped overlap/id ordering it always loses, and every ordering assertion reverts to id order if the fan weighting is dropped. test_the_two_lanes_actually_disagree_on_this_fixture is the control that keeps the byte-identical assertions from passing against a fixture the reorder never touches.

One fixture detail worth flagging: the live high-fan entities are bare tokens (tmp, and, pr), but the end-to-end arm drives the real query-side extractor, which does not key on those shapes — bare tmp extracts to nothing, and that arm would have silently measured a one-entity query. It uses a path-shaped entity instead. The lane arithmetic is identical; only query-side extraction differs.

Discretion grep on added lines clean; CHANGELOG edit insert-only (verified ^- minus ^---); scripts/check_changelog_dupes.py clean.

What this does not establish

That the reorder ranks better. Everything measured so far shows only that the lane is not inert with respect to fan (337 sole-high-fan displacements on the user arm against a "near-zero" kill condition) and that a fifth of its slot spend rides on corpus-ubiquitous terms. The A/B is what decides, and flipping the default is a separate operator call.

Summary by Sourcery

Gate an ACT-R fan-effect ranker for the L2.5 entity lane behind a feature flag and thread it through the production retrieval path, with tests and documentation for the new behavior.

New Features:

  • Add an optional ACT-R fan-effect ranking mode to the entity lookup lane that reorders beliefs by fan-weighted activation while preserving the existing interface and default ordering.
  • Introduce an AELFRICE_FAN_EFFECT tri-state configuration flag and resolver to control the fan-effect mode via environment, kwargs, or default-off behavior.
  • Expose fan-effect control through retrieve_v2, retrieve_with_tiers, _l25_hits, and retrieve so it is reachable from the production retrieval entrypoint.

Enhancements:

  • Implement a deterministic, fan-weighted entity ranking helper that computes activation over active beliefs only and composes with origin-based tie-breaking.
  • Clarify retrieval flag resolution semantics and document the interaction between env vars, kwargs, and future tiered config for the fan-effect lane.

Documentation:

  • Document the new fan-effect entity lane option and its operational constraints in the v4 changelog, including performance characteristics and A/B test expectations.

Tests:

  • Add a dedicated test suite for the fan-effect lane covering ranking behavior, degeneracy to the count-based lane, handling of retired beliefs, determinism, resolver precedence, and production-path reachability.

@robotrocketscience robotrocketscience added the author-Setr PR coordination mutex label Jul 31, 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.

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4fa70f04-7be9-4045-b3e5-839bf439d0e3

📥 Commits

Reviewing files that changed from the base of the PR and between 7c3f0b6 and 30ce1e5.

📒 Files selected for processing (4)
  • CHANGELOG/v4.md
  • src/aelfrice/retrieval.py
  • src/aelfrice/store.py
  • tests/test_fan_effect_1176.py

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

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

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jul 31, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements an ACT-R fan-effect–based ranking mode for the L2.5 entity lane, wires it through both retrieval paths behind a tri-state flag resolved from env/kwargs, and adds a focused test suite and changelog entry to support an A/B experiment with default off.

Sequence diagram for ACT-R fan-effect ranking flow in retrieval

sequenceDiagram
    actor Hook
    participant retrieve_v2
    participant is_fan_effect_enabled
    participant _env_fan_effect_override
    participant retrieve_with_tiers
    participant _cost
    participant _l25_hits
    participant MemoryStore
    Hook->>retrieve_v2: retrieve_v2(use_fan_effect=None)
    retrieve_v2->>is_fan_effect_enabled: is_fan_effect_enabled(kwarg=use_fan_effect)
    is_fan_effect_enabled->>_env_fan_effect_override: _env_fan_effect_override()
    _env_fan_effect_override-->>is_fan_effect_enabled: env_override
    is_fan_effect_enabled-->>retrieve_v2: fan_effect_enabled
    retrieve_v2->>retrieve_with_tiers: retrieve_with_tiers(use_fan_effect=fan_effect_enabled)
    retrieve_with_tiers->>_cost: _cost(use_fan_effect)
    _cost->>_l25_hits: _l25_hits(use_fan_effect)
    _l25_hits->>MemoryStore: lookup_entities(keys, limit, origin_tiebreak, fan_effect=use_fan_effect)
    alt fan_effect is True
        MemoryStore->>MemoryStore: _lookup_entities_fan(keys, limit, origin_tiebreak)
    else fan_effect is False
        MemoryStore->>MemoryStore: lookup_entities overlap SQL path
    end
    MemoryStore-->>_l25_hits: [(belief_id, overlap)]
    _l25_hits-->>_cost: l25_hits
    _cost-->>retrieve_with_tiers: beliefs
    retrieve_with_tiers-->>retrieve_v2: RetrievalResult
    retrieve_v2-->>Hook: RetrievalResult
Loading

File-Level Changes

Change Details Files
Add a fan-effect ranking implementation to the entity lookup lane and keep the existing overlap-count lane as the default.
  • Extend MemoryStore.lookup_entities with a fan_effect flag that dispatches to a new fan-based ranking helper while keeping the legacy SQL path unchanged when fan_effect is False.
  • Implement _lookup_entities_fan to compute ACT-R-style activation scores by counting active-belief fans inline, using Python math.log and sorting beliefs by activation, optional origin rank, and id, while still returning (belief_id, overlap_count).
  • Ensure determinism and performance by capping keys to 512, using DISTINCT and GROUP BY in SQL, sorting entity keys before summing activations, and applying the limit after in-memory reordering.
src/aelfrice/store.py
Introduce a configuration flag and wiring so the fan-effect lane can be toggled via environment variable or kwargs and is reachable on the production retrieval path.
  • Define ENV_FAN_EFFECT and add _env_fan_effect_override plus is_fan_effect_enabled to resolve the fan-effect flag with precedence env -> kwarg -> default False.
  • Thread use_fan_effect through retrieve_v2, retrieve_with_tiers, _cost, and _l25_hits so that retrieved beliefs can use fan-effect ranking on the L2.5 entity lane when enabled.
  • Update retrieve() to accept a use_fan_effect kwarg defaulting to None, with comments documenting the env-first resolver behaviour and its role in future tiered configuration.
src/aelfrice/retrieval.py
Document the new fan-effect ranking mode and its constraints for operators and future reviewers.
  • Add a detailed CHANGELOG entry describing the ACT-R fan-effect lane, its motivation versus raw overlap, the operator disposition constraints (no migration, no SQL LN), performance characteristics, and the fact that default remains off pending A/B results.
CHANGELOG/v4.md
Add tests that validate the fan-effect ordering behaviour, its interaction with origin tie-breaks, flag resolution, and production reachability.
  • Create fixtures where fan-effect ranking must disagree with pure overlap/id order and assert that the rare-but-specific belief is lifted over the ubiquitous-term belief under fan weighting.
  • Verify properties of the implementation such as non-demotion on additional matches, degeneration to the count lane when fans are equal, exclusion of retired beliefs from fan counts, deterministic ordering, and activation formula correctness.
  • Test the resolver precedence (env vs kwarg, default off) and that AELFRICE_FAN_EFFECT reaches the production retrieve() path via retrieve_with_tiers -> _l25_hits -> lookup_entities, guarding against wiring fan-effect only into retrieve_v2.
  • Include basic invariants like limit-after-reorder behaviour, unchanged overlap counts in returned tuples, and inert behaviour for empty/unknown keys and zero limits.
tests/test_fan_effect_1176.py

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

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 562 changed lines (limit: 200)
  • 4 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-07-31T05:13:00Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Reviewed against a build of the branch head, on the live 44,584-belief store.
The mechanism is correct and the three carried-forward constraints are all
honoured. One finding: the performance claim does not reproduce, and the
number in the body is mine — I measured a different implementation shape.

Not a blocker for merging a default-off lane, but it has to be corrected before
it is used to argue a default flip.

The performance claim is wrong, and it is my fault

The body says "Cost is below the lane it replaces — 0.04 ms p50 against
0.09 ms."
That is my R5 figure from the R&D round on this issue, where I
counted fan in SQL and took the log in Python without a per-query
count_active_beliefs(). This implementation adds one, and it dominates:

p50 p95
shipped overlap lane 0.055 ms 1.663 ms
fan lane, as written 1.401 ms 4.543 ms
count_active_beliefs() alone 1.125 ms 1.263 ms

So the fan lane is ~25× slower than the lane it replaces, not faster, and
80% of its cost is that one call. count_active_beliefs() is
SELECT COUNT(*) FROM beliefs WHERE valid_to IS NULL, and the only partial
index on that predicate is idx_beliefs_speculative_gc ON beliefs(origin, created_at) WHERE valid_to IS NULL — no covering index for the bare count, so
it scans 44k rows on every retrieval.

Verified fix, one line of caching. N enters only as ln(N + 1), and it
cannot simply be dropped — it multiplies each belief's own overlap count, so it
does affect ranking between beliefs of differing overlap. But it does not need
recomputing per query:

p50 p95
fan lane, as written 1.506 ms 5.096 ms
fan lane, N memoised 0.063 ms 3.545 ms
shipped lane 0.054 ms 1.328 ms

Parity, and I confirmed memoising N leaves the ordering identical. The store
already caches a BM25 index per instance (_bm25f_shared_cache), so there is a
house pattern for this; the invalidation question is the same one that cache
already answers. BM25Index also already knows the active document count, if
reusing it is cheaper than a second cache.

I would take the corrected numbers into the changelog too — a "cost is below
the lane it replaces" line that is 25× out is exactly the sort of thing that
gets quoted later.

What I verified, and one correction to my own review

  • The lane is not inert, and I nearly reported the opposite. My first probe
    found 0/167 reorders on real prompts. That was my bug — I keyed on Entity.raw
    lowercased instead of Entity.lower, so most keys failed to resolve and both
    lanes returned the same id-ordered rows. With the correct surface, on the live
    store:

    arm prompts ordering changed by the fan lane
    user 300 70 = 23.3%
    harness 23 11 = 47.8%

    Worth handing to the bench: the two arms differ by 2×, which is direct
    empirical support for the arm-split constraint the disposition carried, and a
    concrete prior for how much of the effect is harness artefact. Fan spread
    (max/min over a query's resolved entities) has median 14 and p90 342 on the
    user arm, so the spread the mechanism needs is genuinely there.

  • Non-negativity holds. It depends on count_active_beliefs() counting
    exactly the population the join filters to, and it does — both are
    valid_to IS NULL. Checked every activation term over 80 real queries: zero
    negative, minimum exactly 0.0. So an additional match can never demote.

  • Cap and filter parity. The fan_effect dispatch sits after
    keys = [k for k in entity_lowers if k] and the limit <= 0 guard, and
    _lookup_entities_fan applies the same dict.fromkeys(...)[:512]. Both lanes
    therefore see the same key set — this is a pure reorder, not a different
    query.

  • The mechanism does what it says. With a keyset mixing three
    highest-fan entities and three rare ones, the six rare-carrying beliefs are
    absent from the shipped top-20 entirely and occupy positions 0–5 under fan.

  • Mutations reproduce. Ran three independently: constant weight → 2 failed;
    limit before the sort → 2 failed; and my own — dropping valid_to IS NULL
    from the fan count only → test_retired_beliefs_do_not_inflate_fan. 15 pass
    unmutated.

  • Determinism. sorted(ents) before the float sum is the right call and the
    comment says why. Stable across repeated calls in-process; the sort makes it
    stable across processes too, which matters because set iteration order over
    strings is hash-seed dependent.

On the reported escape

Reporting the eighth mutation rather than dropping it from the table is the
right call, and the correction it produced — that None at that call site is a
convention rather than a guard, because the resolver is env-first — is the kind
of thing that otherwise survives as a false comment for years. The test now
asserting only what it can verify is the right resolution.

Not blocking

Full suite on the branch head: 6559 passed, 69 skipped, 71 xfailed — matches the body exactly. CI green. Discretion grep clean on
added lines. Default-off with byte-identical behaviour when off, so the
performance finding cannot reach production before someone acts on it.

Approving on the mechanism. I would like the latency numbers corrected and the
N cache applied before this is cited in the A/B writeup, since "cheaper than
what it replaces" is currently one of the arguments for the flip.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-07-31T05:21:48Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Reviewer note — #1233 was filed after this PR opened and is the producer-side half of the same surface. It measures extract_entities emitting sentence fragments and function words: 86.1% of distinct entity keys attach to exactly one belief, five-word fragments are the single largest bucket of distinct keys, and and carries a fan of 1,120.

Two things follow for this PR, neither of them a change request.

It confirms the mechanism. The fan term discounts and to ≈3.68 against ≈10.0 for a fan-1 key on that store — 2.7x, derived from the corpus rather than a list. That is the same reason the #1176 disposition refuted the static stopword filter.

It also names a behaviour change this PR does not test. Fan rewards rarity, so a five-word fragment attached to exactly one belief gets maximal activation. Where the count lane treats a fragment match and a real identifier match identically — both overlap = 1, resolved by id — this lane promotes the fragment to the top of a tier with unconditional budget precedence. Defensible (an exact five-word span shared between query and belief is a real signal, and the query side must emit the same fragment for it to match) but untested here in either direction. My fixtures use path-shaped and identifier-shaped keys throughout, so nothing in tests/test_fan_effect_1176.py exercises the fragment case.

I have left the tests as they are rather than adding a fragment fixture, because what the right behaviour is is a disposition question, not a coverage gap — and inventing an assertion for it here would pin an answer nobody has ratified.

The sequencing consequence is on #1233 in full; short version: fan makes the lane strictly more sensitive to whatever extract_entities emits, so if a producer-side fix lands between this merging and the #1176 A/B running, the A/B measures a different lane than the one the kill gate cleared on, and a null result would be uninterpretable.

@robotrocketscience
robotrocketscience force-pushed the feat/issue-1176-fan-effect branch from 99fd3f5 to c9c00eb Compare July 31, 2026 05:32
@robotrocketscience

Copy link
Copy Markdown
Owner Author

You are right, and the finding is bigger than "the body quotes a stale number" — the implementation was genuinely slow. Fixed in e97c03e4 (perf) and c9c00eb2 (changelog), and the branch is rebased onto current main.

I re-measured independently rather than taking the numbers, on the live 44,584-belief store over the 38 distinct non-empty query-entity sets from real user turns (harness blocks excluded using the hook's own prompt_shape_gate_skip label):

p50 p95
shipped overlap lane 0.045 ms 4.021 ms
fan lane, N memoised 0.039 ms 5.487 ms
fan lane, N per query 1.588 ms 7.703 ms
count_active_beliefs() alone 1.315 ms
store_generation() alone 0.0031 ms

Reproduces your result: ~35x on my run, and the count is the whole of it. Your diagnosis of the cause is exactly right — the only partial index on valid_to IS NULL carries (origin, created_at) and does not cover a bare count, so it scans every row.

Keyed on store_generation() rather than a plain per-instance cache. It is a keyed schema_meta lookup at 0.0031 ms — 424x cheaper than the count it replaces — and it is bumped inside the same transaction as every content mutation, so the memo is exact, not merely fresh: any write that could change the count also changes the key. That answers the invalidation question you raised more cheaply than reusing _bm25f_shared_cache, which would have coupled this lane's correctness to the index cache's lifecycle for a single integer. Ordering is identical memoised or cold on all 38 sets.

One case declines the memo. store_generation() reads 0 both for a store that has never been mutated and for a pre-v4.2 DB that has not been reopened, and from inside those are indistinguishable. Caching under a key that never moves would pin a stale N for the life of the process — and as you noted, N is not droppable, because it multiplies each belief's own overlap count and therefore moves the ranking between beliefs of differing overlap. Those stores pay the scan.

Three new tests, each mutation-checked to fail alone:

mutation fails
revert the memo (recompute per query) test_active_count_is_not_recomputed_per_query
memo ignores the generation key test_a_write_invalidates_the_memo
generation 0 falls through to the memo test_generation_zero_declines_the_memo

Plus test_memoising_n_does_not_change_the_ordering as the control, so the fix cannot trade correctness for latency unnoticed. 19 pass in the file; full suite 6577 passed, 69 skipped, 71 xfailed.

The changelog line is corrected to the measured parity figure and now names the memoisation it depends on — you were right that a number 35x out is exactly what gets quoted into a default-flip argument later, so it should not sit there as a bare claim.

Also worth recording against this lane while it is in view: I ran the #1177 Roaring-postings kill experiment on the same store afterwards and measured the shipped lookup_entities at 0.039 ms p50. Any future proposal arguing from L2.5 latency should be checked against that first — there is no latency problem on this tier, which is why the count regression mattered so much in relative terms and so little in absolute ones.

Re-flagging attn:review.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Pre-flight result for the A/B this PR exists to enable, posted in full on #1176: the L2.5 lane fires on 93.5% of real user turns against the live store but only 10.5% of real LoCoMo questions against a LoCoMo-shaped one, and the sweep is flat in corpus size (9.5% at 232 beliefs, 10.5% at 622).

The mechanism is the one #1233 documents, seen from the query side: the extractor emits whole noun-phrase fragments, so a match needs the same five-word span verbatim on both sides. The live store supplies that because it ingests its own sessions — which is #1174's echo pathology showing up in a second place, not the entity lane doing retrieval work.

Nothing here blocks this PR. It ships default-off, the mechanism is correct, and its tests are synthetic by construction, so none of them depend on the fire rate. What it blocks is running the A/B on LoCoMo or LongMemEval and believing the answer — with the treatment inert on ~90% of queries, a null result would be a measurement failure rather than evidence about fan weighting.

Recording it here so the connection between this PR and its A/B is not lost: the corpus has to be a replay of real production prompts against the live store, and whatever corpus is chosen should have its lane fire rate measured first.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-07-31T05:56:34Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Re-reviewed e97c03e4 + c9c00eb2 on a fresh build of the branch head. The fix
is better than the one I suggested, and I verified the property it rests on
rather than the numbers.

The memo is exact, and I checked the path that would break it

Keying on store_generation() instead of a plain per-instance cache is the
right call — my suggestion (reuse _bm25f_shared_cache) would have coupled this
lane's correctness to the index cache's lifecycle for a single integer, and
yours makes staleness structurally impossible instead of merely unlikely.

That only holds if every write that changes the active count bumps the
generation. Insert and hard-delete are the obvious ones; the one that would
actually bite is soft-delete, which changes valid_to without adding or
removing a row. Exercised each path against a live memo:

after memo true count generation
baseline (10 inserts) 10 10 10
soft_delete_belief (retire) 9 9 11
restore_belief 10 10 12
second soft_delete_belief 9 9 13
delete_belief 8 8 14

Exact at every step. No path leaves the memo behind.

Numbers reproduce

Live 44,584-belief store, 150 distinct real user-turn entity sets:

p50 p95
shipped overlap lane 0.0597 ms 2.2304 ms
fan lane, memoised 0.0663 ms 3.1674 ms
count_active_beliefs() 1.1511 ms 1.3088 ms
store_generation() 0.0028 ms 0.0030 ms

Parity with the lane it replaces, and store_generation() is ~410× cheaper than
the count — matches your 424×. Ordering is identical memoised versus
force-cleared across all 150 sets, so the memo is a pure performance change.

Mutations

  • Memo ignores the generation key (never invalidates) → test_a_write_invalidates_the_memo.
  • Generation-0 stores take the memo anyway → test_generation_zero_declines_the_memo.

19 pass unmutated. Both new guards are covered by a test that distinguishes
them, which is the thing that actually matters here — a memo whose invalidation
is untested is a memo that will go stale silently.

On the generation-0 carve-out

Declining the memo when store_generation() reads 0 is right and the reasoning
in the docstring is correct: a never-mutated store and a pre-v4.2 DB that has
not been reopened are indistinguishable from inside, and N is not a free
approximation — it multiplies each belief's own overlap count, so a pinned stale
value does move the ranking between beliefs of differing overlap. Paying the
scan there is the correct trade, and it is the conservative direction.

Closing my own finding

The corrected changelog line is what I asked for. To be explicit about
provenance: the "0.04 ms vs 0.09 ms" figure that was in the original body was
mine, from the R5 round on #1176, measured on an implementation without a
per-query count. You re-measured independently rather than taking my numbers,
and got ~35× where I got ~25× — the discrepancy is machine load, and the
diagnosis is the same.

No further findings. Full suite on the branch head: 6577 passed, 69 skipped, 71 xfailed. CI green, discretion
grep clean on added lines. Approving; adding ready-to-merge.

Still true and worth carrying into the A/B, unchanged from my first review: this
establishes the lane is cheap and correct, not that the reorder ranks
better. And the arm split is not optional — I measured the fan lane reordering
23.3% of user-arm queries against 47.8% of harness ones, so a pooled
number would be roughly half artefact.

@robotrocketscience robotrocketscience added ready-to-merge Trigger merge-train: FF main to this PR's head and removed attn:review Needs review (PR open, awaiting reviewer) labels Jul 31, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-07-31T06:02:14Z]

`lookup_entities` gains `fan_effect` (default off), which orders by
Anderson's ACT-R fan effect — `A_i = Sum_j ln((N + 1) / fan_j)` over the
query entities a belief carries — instead of `COUNT(DISTINCT
entity_lower)`. The count prices every matched entity the same, but
`tmp` sits in 1,480 beliefs and 86% of entities sit in exactly one, so a
match on a ubiquitous token buys the rank of a match on a unique symbol.

Written as a log ratio the term is algebraically IDF, so it reuses a
calibration already in the system. Every term is non-negative, so an
extra match cannot demote, and equal fans degenerate to the overlap
count exactly.

No `entity_fan` table and no migration, per the operator disposition:
fan is counted inline over the query's own keys and the log taken in
Python, since SQL LN() needs SQLITE_ENABLE_MATH_FUNCTIONS. Fan counts
active beliefs only, matching the lane's own valid_to filter. The
returned tuple still carries the overlap count — this changes the
ordering, not the interface.
`AELFRICE_FAN_EFFECT` resolves through `is_fan_effect_enabled` (env ->
kwarg -> default False) and threads `retrieve_v2` -> `retrieve_with_tiers`
-> `_l25_hits` -> `lookup_entities`. `retrieve()` passes it too: the
hooks call `retrieve()`, not `retrieve_v2`, and a lane reachable only
from the latter is inert on the path the A/B has to measure (#1107).

Default off. The kill gate cleared and the measured cost is below the
lane it replaces, but that the reorder ranks *better* is what the A/B
decides; the default flip is a separate operator call.

Tests pin the reorder, the retired-belief fan guard, the degenerate
equal-fan case, limit-after-sort, composition with the #1089 origin
tie-break, and reachability from `retrieve()` — each with a
distinguishing assert. One deliberate negative result is recorded in the
docstrings rather than asserted: spelling the `retrieve()` kwarg `False`
instead of `None` leaves the file green, because the resolver is
env-first. `None` is convention there, not a guard, and the code comment
now says so instead of claiming otherwise.
Insert-only under [Unreleased] -> Added. Records the kill-gate figure
(337 sole-high-fan displacements on the user arm), the refuted stopword
alternative, the no-migration disposition, the measured cost, and the
arm-split requirement the bench has to honour.
Review finding: the lane recomputed `count_active_beliefs()` per query.
That is `SELECT COUNT(*) FROM beliefs WHERE valid_to IS NULL`, and the
only partial index on that predicate carries `(origin, created_at)` and
does not cover a bare count — so it scans every row. Measured on a
44,584-belief store over 38 real user-turn query-entity sets, it costs
1.315 ms and made the fan lane ~35x slower than the overlap lane it
replaces, not faster.

Memoised on `store_generation()`, a keyed `schema_meta` lookup at
0.0031 ms that is bumped inside the same transaction as every content
mutation — so the memo is exact rather than merely fresh. Result is
parity: 0.039 ms p50 against the shipped lane's 0.045 ms, with ordering
identical memoised or not on all 38 sets.

Generation 0 declines the memo. It reads 0 both for an unmutated store
and for a pre-v4.2 DB not yet reopened, and those are indistinguishable
from here; caching under an unmoving key would pin a stale N, which is
not free because N multiplies each belief's overlap count and so moves
the ranking between beliefs of differing overlap.

Each of the three behaviours has a distinguishing test: reverting the
memo, dropping the generation check, and removing the generation-0
fall-through each turn exactly one red.
The entry quoted 0.04 ms against 0.09 ms, taken from the R&D round,
which measured an implementation shape without a per-query
`count_active_beliefs()`. The shipped shape has one. Corrected to the
measured parity figure and the memoisation it depends on, since a
performance line that is out by 35x is the kind that gets quoted into a
default-flip argument later.
@robotrocketscience
robotrocketscience force-pushed the feat/issue-1176-fan-effect branch from c9c00eb to 30ce1e5 Compare July 31, 2026 06:07
@github-actions
github-actions Bot merged commit 30ce1e5 into main Jul 31, 2026
25 checks passed
@github-actions

Copy link
Copy Markdown

merge-train: merged 30ce1e5main via FF push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Setr PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant