Skip to content

feat(retrieval): wire the exploration slot into the UPS pack (#1279) - #1285

Merged
github-actions[bot] merged 10 commits into
mainfrom
feat/issue-1279-exploration-slot
Aug 1, 2026
Merged

feat(retrieval): wire the exploration slot into the UPS pack (#1279)#1285
github-actions[bot] merged 10 commits into
mainfrom
feat/issue-1279-exploration-slot

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #1279. Parent: #1176 (proposal 5).

What was missing

Two thirds of proposal 5 was already merged and could not reach a pack. git grep -nE 'aelfrice\.exploration' -- src/ on main returned nothing: exploration.py was imported by its own tests and by nothing else in src/. The pool query, the exploration_events ledger and the seeded uniform draw were all complete, tested and inert — the category #1162 exists to track. This is the caller, not the mechanism.

hook._substitute_exploration_slots sits on the user_prompt_submit path between retrieval and render, default OFF behind AELFRICE_EXPLORATION / [retrieval] exploration_enabled, with exploration_cadence (20) and exploration_slots (1).

Why the slot exists

Measured on the live 44,586-belief store: 37,489 active unlocked beliefs (84.1%) carry neither a feedback_history row nor an injection_events row, and only 1,352 (3.0%) have ever been injected at all. Evidence accrues on exposure, so a belief that starts underranked never gets injected, never earns evidence, and stays underranked. The 92,685 feedback rows land on 7,450 beliefs — the feedback is concentrated on the beliefs that were already winning.

This is not a ranking change and must not be A/B'd as one. Its outcome is coverage of the never-injected pool over time, countable from exploration_events and injection_events without a judge or a gold set.

Three properties that are load-bearing

Each was a way for this to be worse than useless, and each is pinned by a test that was mutation-verified (delete the guard, the test goes red).

  • Substitution, never append — accounted in tokens, not in slots. A drawn belief can be longer than the hit it replaces, so a one-for-one swap would still grow the block. The slot frees at least as many tokens from the lowest-ranked non-locked tail as it spends, and when the tail cannot fund the draw it skips rather than grows. A slot that grew the block would be a budget increase wearing an exploration costume, and would confound the exact measurement the slot exists to produce.
  • User locks are never displaced. L0 is injected unconditionally; the pool already excludes locks and the displacement scan skips them, so an all-locked pack is a no-op rather than an eviction.
  • Upstream of _record_injection_events. Substituting a never-injected belief without recording the exposure would leave the loop exactly as closed as it was, so placement in the call order is the point rather than an implementation detail.

Fail-soft end to end: any error in the exploration path — including a raising pool query — leaves the pack exactly as retrieval produced it.

Acceptance

  • Default OFF; resolves through env → kwarg → TOML → default, same chain as its siblings (is_exploration_enabled, resolve_exploration_cadence, resolve_exploration_slots). A cadence <= 0 disables rather than raising.
  • Fail-soft, pinned by a test that makes the pool query raise.
  • Locks never displaced — pinned by an all-locks pack (no-op, no eviction) and by a mixed pack where the locks survive while the non-locked tail goes.
  • Rendered block does not grow in tokens, compared against the same pack with exploration off.
  • Deterministic: same (session, fire_idx, query, pool) → same drawn ids.
  • Exactly one exploration_events row per firing turn, naming the displaced ids and not only the drawn ones.
  • Tests are distinguishing — every assertion differs from the exploration-off arm, so a no-op consumer fails them. That is the failure mode that let three mechanisms on this umbrella ship inert.

Flipping the default is a separate operator call.

Summary by Sourcery

Wire the exploration slot into UPS retrieval so never-injected beliefs can be substituted into packs under a configurable, fail-soft flag.

New Features:

  • Introduce retrieval configuration flags and resolvers for exploration enablement, cadence, and slot count, controlled via env, TOML, or kwargs.
  • Add an exploration-slot hook in the user prompt submit path that substitutes never-injected beliefs into the non-locked tail without increasing token budget and records exploration events.

Enhancements:

  • Document the exploration configuration and behaviour for users, including determinism, substitution semantics, and measurement goals in CONFIG.md.
  • Update the v4 changelog to describe the wired exploration slot and its operational properties and defaults.

Tests:

  • Add a dedicated test suite for the exploration slot ensuring flag precedence, cadence behaviour, lock preservation, token-budget non-growth, deterministic draws, ledger recording of displaced ids, and fail-soft behaviour on pool and ledger errors.

Summary by CodeRabbit

  • New Features

    • Added an optional exploration mode that periodically surfaces previously unseen beliefs during retrieval.
    • Exploration preserves locked content, stays within token limits, avoids duplicates, and uses deterministic selection.
    • Added configuration through environment variables, TOML settings, and explicit options.
    • Exploration activity is recorded for coverage measurement.
  • Bug Fixes

    • Retrieval continues unchanged when exploration encounters errors or insufficient content.

@robotrocketscience robotrocketscience added the author-Gylf PR coordination mutex label Aug 1, 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

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Aug 1, 2026
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 31 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: 7ff45473-3233-4007-a567-a9b89aed6e48

📥 Commits

Reviewing files that changed from the base of the PR and between 5077041 and 8c3ccc1.

📒 Files selected for processing (7)
  • CHANGELOG/v4.md
  • docs/user/CONFIG.md
  • src/aelfrice/exploration.py
  • src/aelfrice/hook.py
  • src/aelfrice/retrieval.py
  • tests/test_exploration_1176.py
  • tests/test_exploration_slot_1279.py
📝 Walkthrough

Walkthrough

Changes

Exploration slots

Layer / File(s) Summary
Exploration configuration resolution
src/aelfrice/retrieval.py
Adds TOML keys, environment overrides, defaults, and precedence-based resolvers for enablement, cadence, and slot count.
Production substitution and audit flow
src/aelfrice/hook.py, tests/test_exploration_slot_1279.py, docs/user/CONFIG.md, CHANGELOG/v4.md
Adds deterministic, cadence-based substitution before rendering and injection accounting. Locked beliefs and token budgets remain protected. Exploration events record candidates, draws, and displaced hits. Errors and non-firing turns leave retrieval unchanged. Tests cover these behaviors, and documentation describes the settings.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UserPromptSubmit
  participant RetrievalConfig
  participant MemoryStore
  participant Exploration
  participant ExplorationLedger
  UserPromptSubmit->>RetrievalConfig: resolve exploration settings
  UserPromptSubmit->>Exploration: check cadence and derive draw
  Exploration->>MemoryStore: query exploration pool
  MemoryStore-->>Exploration: return candidate beliefs
  Exploration->>UserPromptSubmit: return substitutions
  UserPromptSubmit->>ExplorationLedger: record exploration event
  UserPromptSubmit->>UserPromptSubmit: render retrieved hits
Loading

Possibly related PRs

Suggested labels: hook

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: wiring the exploration slot into the UPS retrieval path.
Description check ✅ Passed The description explains the purpose, linked issue, behavior, acceptance criteria, configuration, failure handling, and test coverage.
Linked Issues check ✅ Passed The changes satisfy issue #1279 by wiring deterministic, fail-soft exploration into UPS with configuration, substitution, lock preservation, budgeting, and event recording.
Out of Scope Changes check ✅ Passed The code, configuration, documentation, changelog, and tests all directly support the exploration-slot wiring requested by issue #1279.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-1279-exploration-slot

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.

@sourcery-ai

sourcery-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Reviewer's Guide

Wires the previously inert exploration-slot mechanism into the UPS retrieval hook, adds configuration resolvers and documentation for its controls, and introduces a focused test suite that pins its behavioral invariants and fail-soft guarantees.

Sequence diagram for the exploration slot during user prompt submission

sequenceDiagram
    actor User
    participant Hook as user_prompt_submit
    participant Slot as _substitute_exploration_slots
    participant Ring as read_ring_state
    participant RetrCfg as retrieval_config
    participant Expl as exploration
    participant Store as MemoryStore
    participant Ledger as Store.record_exploration
    participant Inj as _record_injection_events

    User->>Hook: submit prompt
    Hook->>Hook: retrieval produces hits
    Hook->>Slot: _substitute_exploration_slots(hits, session_id, query, store, serr)
    Slot->>RetrCfg: is_exploration_enabled()
    RetrCfg-->>Slot: enabled?
    Slot->>Ring: read_ring_state(session_id)
    Ring-->>Slot: state (next_fire_idx)
    Slot->>Expl: should_explore(fire_idx, cadence=resolve_exploration_cadence())
    Expl-->>Slot: explore?
    Slot->>Store: exploration_pool(query)
    Store-->>Slot: pool (never_injected beliefs)
    Slot->>Expl: derive_seed(session_id, fire_idx, query)
    Expl-->>Slot: seed
    Slot->>RetrCfg: resolve_exploration_slots()
    RetrCfg-->>Slot: slots
    Slot->>Expl: draw_uniform(pool, seed, count=slots)
    Expl-->>Slot: drawn_ids
    Slot->>Store: get_belief(drawn_id)
    Store-->>Slot: drawn beliefs
    Slot->>Slot: compute need via _belief_tokens(drawn)
    Slot->>Slot: displace non_locked tail until freed_tokens >= need
    Slot->>Ledger: record_exploration(fire_idx, seed, query, candidate_ids, drawn_ids, displaced_ids)
    Ledger-->>Slot: (best-effort, errors logged)
    Slot-->>Hook: updated hits (substitution, never append)
    Hook->>Inj: _record_injection_events(session_id, hits,...)
    Inj-->>Hook: injection_events
    Hook-->>User: rendered response with pack

    Note over Slot,Hook: On any error, Slot returns original hits (fail-soft)
Loading

File-Level Changes

Change Details Files
Introduce _substitute_exploration_slots on the user_prompt_submit path to substitute never-injected beliefs into the non-locked tail of the pack, with strict token-budget, lock-respect, determinism, ledger recording, and fail-soft behavior.
  • Call _substitute_exploration_slots in user_prompt_submit immediately after optional deduplication and before rebuild logging and injection-event recording so explored beliefs are logged and recorded as injected.
  • Implement _substitute_exploration_slots to gate on exploration enablement, cadence, and session ring fire index, then query the exploration pool, draw seeded uniform beliefs, and compute the token budget needed.
  • Displace lowest-ranked non-locked hits from the tail until their freed tokens meet or exceed the drawn beliefs’ token cost, skipping exploration entirely if only locks are present or if the tail cannot fund the draw.
  • Record an exploration ledger row via store.record_exploration that includes the seed, candidate pool, drawn ids, and displaced ids, logging but swallowing ledger failures.
  • Wrap the entire slot in a broad try/except that logs failures to serr and returns the original hits, ensuring exploration cannot break the hook.
src/aelfrice/hook.py
Add configuration flags and resolvers for exploration enablement, cadence, and slots, integrating them into the existing [retrieval] config and env-var precedence chain.
  • Define TOML keys exploration_enabled, exploration_cadence, and exploration_slots plus env vars AELFRICE_EXPLORATION, AELFRICE_EXPLORATION_CADENCE, and AELFRICE_EXPLORATION_SLOTS alongside other retrieval flags.
  • Import DEFAULT_EXPLORATION_CADENCE and DEFAULT_EXPLORATION_SLOTS from aelfrice.exploration while keeping that module free of config/IO concerns.
  • Implement _env_exploration_override and is_exploration_enabled with precedence env > kwarg > TOML > default false, explicitly documenting default-off semantics and operational gating.
  • Implement resolve_exploration_cadence and resolve_exploration_slots using env > kwarg > TOML > default, normalizing values to ints and treating non-positive cadence as “never explore” rather than raising.
  • Ensure exploration config resolves in the same style as sibling retrieval lanes (e.g., fan effect), enabling consistent operational control.
src/aelfrice/retrieval.py
Document the exploration slot’s behavior, configuration, and measurement goals in user-facing config docs and changelog, clarifying that it is a coverage mechanism rather than a ranking change.
  • Add a CONFIG.md section describing exploration_enabled, exploration_cadence, and exploration_slots, including their defaults, precedence, fail-soft behavior, determinism, and the substitution-vs-append and lock-preservation invariants.
  • Clarify that the exploration slot is evaluated on coverage of the never-injected pool over time using exploration_events and injection_events, not on ranking metrics, and explain why the draw is uniform given measured issues with the proposed A-Res weighting.
  • Extend the v4 changelog with an item explaining that the exploration slot is now wired, summarizing its properties (token-budget substitution, lock invariants, upstream placement, fail-soft, default-off) and operational expectations for flipping the default.
docs/user/CONFIG.md
CHANGELOG/v4.md
Add a dedicated test suite validating the exploration slot’s flag gating, invariants (locks, token budget, determinism, ledger contents), and fail-soft behavior under pool and ledger edge cases.
  • Create test utilities for constructing beliefs, seeding the exploration pool with never-injected matches, pinning session ring fire_idx, and invoking _substitute_exploration_slots while isolating env and TOML resolution.
  • Verify that with exploration disabled the slot is a no-op, and that enabling it on a firing turn produces a different pack containing a pool belief, while non-firing turns remain unchanged.
  • Assert that user-locked packs are untouched, mixed packs retain locks while displacing non-locked tails, and that the total token count of the pack does not grow when exploration fires.
  • Test determinism by ensuring identical (session, fire_idx, query, pool) yields identical packs across calls, but different fire_idx values change which pool belief is drawn.
  • Validate that each firing turn writes exactly one ledger row including both drawn and displaced ids, that raising exploration_pool calls leave the pack unchanged, that empty pools and already-present beliefs are handled as no-ops without duplication, and that packs too small to fund the drawn belief’s token cost are left untouched.
tests/test_exploration_slot_1279.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1279 Wire a production exploration slot caller into the user_prompt_submit path, using the existing exploration mechanisms to substitute never-injected beliefs into the non-locked tail of the pack, record the exploration in the ledger, and preserve required behavioral properties (no lock displacement, no token growth, deterministic draws, one ledger row per firing turn, and fail-soft behavior).
#1279 Introduce configuration and flagging for the exploration slot (enable flag, cadence, slots) with default OFF and a resolution chain env → kwarg → TOML → default, matching other retrieval flags.
#1279 Add distinguishing tests that exercise the exploration slot behavior and pin the acceptance criteria (default-off no-op, cadence gating, fail-soft on errors, lock protection, token-budget substitution, determinism, and ledger contents including displaced IDs).

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

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 822 changed lines (limit: 200)
  • 7 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:Garsecg:2026-08-01T03:45:11Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-08-01T03:49:32Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-08-01T03:49:41Z]

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/aelfrice/hook.py (1)

1640-1640: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tighten the store type hint to match the sibling function.

store is typed object | None here, while _record_injection_events (line 1760) types the same kind of parameter MemoryStore | None. The function calls store.exploration_pool(...), store.get_belief(...), and store.record_exploration(...), none of which exist on object. The outer try/except keeps this from crashing at runtime, but the loose type hint gives up static checking on the MemoryStore API for this call path.

♻️ Proposed fix
 def _substitute_exploration_slots(
     hits: list[Belief],
     *,
     session_id: str,
     query: str,
-    store: object | None,
+    store: MemoryStore | None,
     serr: IO[str],
 ) -> list[Belief]:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aelfrice/hook.py` at line 1640, Update the store parameter type in the
function containing this signature from object | None to MemoryStore | None,
matching _record_injection_events and enabling static checking for the store API
calls.
tests/test_exploration_slot_1279.py (1)

252-265: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer a genuine SQLite failure over monkeypatching the storage method.

This test replaces MemoryStore.exploration_pool with a raising stub via monkeypatch.setattr. It is not unittest.mock, so it does not hit the literal pattern the guideline names, but it does substitute real storage-layer behavior with a stub function for the duration of the test, which runs against the stated intent that tests hit a real SQLite DB rather than mocks.

Closing the store's connection before the call (store.close()) would raise a genuine sqlite3.ProgrammingError from the real DB layer and exercise the same fail-soft path without stubbing out the method.

♻️ Proposed fix
 def test_a_raising_pool_query_leaves_the_pack_untouched(
     store, monkeypatch, capsys,
 ) -> None:
     """A research lane must never be why a hook fails."""
     _fire(monkeypatch, 20)
     monkeypatch.setenv("AELFRICE_EXPLORATION", "1")

-    def _boom(*a, **k):
-        raise RuntimeError("pool query exploded")
-
-    monkeypatch.setattr(MemoryStore, "exploration_pool", _boom)
+    store.close()
     hits = [_mk("h1", "ranked hit one"), _mk("h2", "ranked hit two")]
     assert _run(store, hits, capsys) == hits

As per path instructions, "Tests must hit a real SQLite DB, not mocks. Flag any introduction of unittest.mock for the storage layer."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_exploration_slot_1279.py` around lines 252 - 265, Update
test_a_raising_pool_query_leaves_the_pack_untouched to remove the monkeypatch of
MemoryStore.exploration_pool and trigger a genuine SQLite failure by closing the
store connection before invoking _run. Preserve the existing assertions that the
original hits are returned and the fail-soft behavior remains intact.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aelfrice/hook.py`:
- Around line 1083-1095: Update _substitute_exploration_slots to accept cwd:
Path | None = None and pass start=cwd to is_exploration_enabled(),
resolve_exploration_cadence(), and resolve_exploration_slots(). At the
user_prompt_submit call site, provide cwd=payload_cwd so all exploration
settings resolve relative to the payload project directory.

In `@src/aelfrice/retrieval.py`:
- Around line 1254-1292: Update resolve_exploration_cadence and
resolve_exploration_slots so their environment-variable tier accepts zero and
negative values instead of filtering them through _env_positive_int. Preserve
the existing precedence order and integer conversion, ensuring non-positive
environment values reach the same disabling or substitution behavior as kwarg,
TOML, and default values.

---

Nitpick comments:
In `@src/aelfrice/hook.py`:
- Line 1640: Update the store parameter type in the function containing this
signature from object | None to MemoryStore | None, matching
_record_injection_events and enabling static checking for the store API calls.

In `@tests/test_exploration_slot_1279.py`:
- Around line 252-265: Update
test_a_raising_pool_query_leaves_the_pack_untouched to remove the monkeypatch of
MemoryStore.exploration_pool and trigger a genuine SQLite failure by closing the
store connection before invoking _run. Preserve the existing assertions that the
original hits are returned and the fail-soft behavior remains intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c4c7d1e-28d8-4218-97f7-43de73e1efdc

📥 Commits

Reviewing files that changed from the base of the PR and between 49423f0 and 5077041.

📒 Files selected for processing (5)
  • CHANGELOG/v4.md
  • docs/user/CONFIG.md
  • src/aelfrice/hook.py
  • src/aelfrice/retrieval.py
  • tests/test_exploration_slot_1279.py

Comment thread src/aelfrice/hook.py
Comment thread src/aelfrice/retrieval.py
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — one finding needs an author decision before this can land as "the caller"

The code is correct, default-OFF and fail-soft, and I could not fault the implementation. What I could fault is the shipped cadence default: measured against the real workload, this wiring does not fire.

What I verified clean first

  • Store contract matches exactly. exploration_pool(query, *, limit=200) -> list[str] and record_exploration(*, fire_idx, seed, query, candidate_ids, drawn_ids, displaced_ids, now=None) line up with the call. This mattered because the whole body sits under a blanket except Exception, so a signature drift would have been swallowed into a permanent silent no-op that the stub-store tests could not see.
  • Ordering is safe. out = [...] + drawn appends at the tail, but _split_belief_lines applies order_for_injection at the render boundary downstream, so the drawn belief is re-sorted by whatever policy is in force. No locks_last violation.
  • No import cycleexploration.py imports only hashlib/typing, so the mid-module import in retrieval.py is safe.
  • Placement claim holds. _ring_append_ids is line 1183, the slot is line 1089, both inside the same if hits: — the slot really does read the counter before the append, and really is upstream of _record_injection_events (1118).
  • Token non-growth holds as freed >= need, in the same estimator the budget uses.
  • The pool is not empty in practice. Ran exploration_pool over 164 distinct real user-turn prompts against the live 45,945-belief store: 0/164 empty, median pool 11 ids. The FTS5 restriction does not starve it.
  • Packs can afford the draw. Over 259 UPS injection turns since the feat(locks): bound lock injection + fix lock framing — frozen/reference tiers, locks-file manifest, provenance-aware framing #1016-B regime break, the non-locked tail is a median 747 tokens against a median pool belief of 20; only 1 turn (0.4%) was all-locks. P(a turn can fund a 1-slot draw) = 99.4%. The "skip rather than grow" branch is essentially never taken.

So the pool, the funding and the plumbing are all fine. The problem is upstream of all of it.

The finding: at exploration_cadence = 20 the slot does not reach a firing turn

fire_idx comes from read_ring_state(session_id)["next_fire_idx"], and that counter is per-sessionsession_injected_ids.json holds exactly one session, and read_ring_state returns {} on a session-id mismatch (session_ring.py:975). It does not accumulate across sessions. Sessions on this store are short:

window sessions UPS injection turns turns/session p50 p90 max sessions ever reaching fire_idx == 20
all time 216 956 2 9 66 5 (2.3%)
since 2026-06-30 (#1016-B regime) 81 259 2 7 12 0 (0.0%)

Fires at the shipped default: 8 / 956 turns (0.84%) all time, 0 / 259 (0.00%) in the current regime.

fire_idx == 0 does not rescue it. On a session's first UPS turn the ring file still belongs to the previous session, so read_ring_state returns {}, fire_idx is None, and the guard returns early — and append_ids never writes 0 (its own docstring: "returns fire_idx + 1 (1 on a freshly-created ring, not 0)"). The first reachable multiple is fire_idx == 20, i.e. a session's 21st injecting turn.

The one path that could see 0 is a session whose ring gets created by a non-append_ids writer (push_classification, update_bytes_at_last_fire) before its second UPS turn. Bounding it both ways rather than guessing: 0 fires if that never happens, 81/259 = 31.3% if it always does. The live ring for the current session shows next_fire_idx: 4 with classifications: [], so the low end is the realistic one. Either way the number the default advertises — 1 in 20, 5% — is not what the workload produces.

This is the umbrella's own recurring failure mode: DEFAULT_K3 = 0.0, DEFAULT_BOOST_QF, the R3 boost arm on #1281 — all wired, all unreachable at their shipped defaults. The AC "exactly one exploration_events row per firing turn" is satisfied by the tests because _fire() pins the counter; nothing pins that a firing turn is reachable from a real ring.

What I need from you (three ways out, all yours to pick — I am not guessing at intent):

  1. Reachable default — a cadence at or below the median session length (2–3), so the slot fires from a session's 3rd/4th turn.
  2. Cross-session counter — a store-level monotonic fire counter, which is what makes "1 in 20 turns" mean what it says; cadence 20 then stays.
  3. Keep 20 deliberately as a long-sessions-only mechanism — but then the coverage claim needs re-pricing, because 84.1% of the store cannot be covered at 0.84% of turns.

Flagging attn:unblock for that decision rather than blocking the diff, since the default is OFF and nothing here is unsafe to merge.

Minor, independent of the above

The resolvers have no tests. is_exploration_enabled, resolve_exploration_cadence and resolve_exploration_slots are not referenced anywhere in tests/test_exploration_slot_1279.py — the suite reaches them only indirectly through AELFRICE_EXPLORATION. AC 1 ("resolves through env → kwarg → TOML → default … a cadence <= 0 disables rather than raising") is checked but unpinned.

And the second half of that AC is not true on the env tier, which is the highest-precedence one:

AELFRICE_EXPLORATION_CADENCE=0  ->  _env_positive_int rejects (<= 0)  ->  returns None
                               ->  falls through to DEFAULT_EXPLORATION_CADENCE = 20

So an operator setting 0 to disable gets cadence 20 instead (with a stderr trace). <= 0 disables via the kwarg and TOML tiers only, where should_explore's guard is reachable. Either honour 0 in the env tier or narrow the docstring — a test on that line would have caught it.

Holding the review claim until the cadence question is answered.

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:review Needs review (PR open, awaiting reviewer) labels Aug 1, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-08-01T04:24:46Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-08-01T04:24:52Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Thanks — the cadence finding is correct and it is the important one. Both items are addressed; the branch is pushed.

The cadence: option 1, with option 2 filed

Put to the operator, who chose the reachable default now and the cross-session counter as follow-up.

DEFAULT_EXPLORATION_CADENCE 20 → 3 (5c041ebf). Your reading of the mechanism is right and I verified it independently rather than taking the numbers on trust: read_ring_state returns {} on a session mismatch, so the counter restarts per session and cannot accumulate. At a median of 2 injecting turns and p90 of 7, cadence 20 was unreachable — which is the same defect this issue was filed to close, one layer up. Shipping the caller and leaving it unable to fire would have been #1162 all over again.

The guard you said was missing now exists (7c2b0fb6). test_the_default_cadence_is_reachable_within_a_real_session asserts the property — that a p90-length session reaches a firing turn — rather than an equality on the constant, which would be a tautology. Mutation-verified: restoring 20 reddens it. So raising the cadence above the observed session length now fails, and doing it legitimately (a global counter) means the guard no longer describes the mechanism and has to be revisited deliberately.

While there I found the literals were load-bearing in a way nobody would have noticed: fourteen call sites wrote the fire index as 20, so the moment the default moved, every one of them silently stopped exercising its branch — the tests would have stayed green while testing nothing. They now derive from DEFAULT_EXPLORATION_CADENCE. The sibling suite had a related latent bug: assert n == 1000 // DEFAULT_EXPLORATION_CADENCE held only because 20 divides 1000 exactly; fire_idx == 0 fires too, so the count is ceil, and the floor form would have failed for the wrong reason.

Cross-session counter filed as #1294, with the three decisions it has to make written down rather than left to discovery: the seed contract (derive_seed is blake2b over (scope_id, fire_idx, query), so a global index is a ledger regime break in the #1016-B sense), concurrency (sister sessions share the store — it needs the BEGIN IMMEDIATE treatment from #1135 or two sessions draw on the same index), and whether the default returns to 20.

The minor: confirmed, and it was worse than "docstring or behaviour"

You were right, and I reproduced it before fixing:

$ AELFRICE_EXPLORATION_CADENCE=0 python -c "...resolve_exploration_cadence()"
aelfrice retrieval: ignoring AELFRICE_EXPLORATION_CADENCE='0' (must be > 0)
20

An operator asking for "off" on the highest-precedence tier silently got exploration every 20th turn. I fixed the behaviour rather than narrowing the docs (f5800e02) — "0 disables" is the contract CONFIG.md publishes, and the env tier is the one an operator reaches for first. _env_int_allowing_disable keeps a meaningful zero; _env_positive_int stays in place for exploration_slots, where a non-positive value really is just invalid, and that asymmetry is pinned by its own test so the fix is not copied across by symmetry.

Resolver tests added (55a73456) — each tier pinned against the tier beneath it, including the tri-state case where an explicit False kwarg must beat a true TOML rather than reading as "unset". The zero test asserts the resolved value differs from the default, not merely that it is <= 0; the weaker form passes against the exact bug it exists to catch.

Full suite green on the branch. Re-review when you have a moment — and thanks for bounding the fire rate both ways rather than guessing, that is what made the decision straightforward to put to the operator.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-08-01T05:02:43Z]

@robotrocketscience robotrocketscience added attn:review Needs review (PR open, awaiting reviewer) and removed attn:unblock Needs answer from another session labels Aug 1, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-08-01T05:02:49Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Operator ruling 2026-08-01 — make the fire counter cross-session. cadence should mean what it says.

Put to the operator with four options (leave it with the author, cross-session counter, reachable per-session default, or keep 20 and re-price the claim). Ruled: cross-session counter.

The reasoning, so it is not re-derived: it is the only option under which the knob is honest. Under a per-session counter the effective rate is a function of session-length distribution, so 20 silently means "never" on a 2-turn median today and would mean something else again next month without anyone changing a line. Worse for this PR specifically — the slot exists to produce a coverage measurement over the never-injected pool, and a firing rate that drifts with usage makes that measurement uninterpretable. A store-level monotonic counter makes cadence = 20 mean 1 in 20 turns overall, which is both what the constant claims and what the measurement needs.

The two rejected alternatives, recorded so they are not re-proposed:

  • Reachable per-session default (cadence 2–3). One line, no new state, and it does make the slot fire — but it inherits the same drift, and it hard-codes an assumption about session length into a retrieval constant.
  • Keep 20 as a long-sessions-only mechanism. Honest, and rejected because it makes the slot close to decorative: 84.1% of the store cannot be covered at 0.84% of turns.

What this asks of the PR

The diff is otherwise correct and I could not fault it — the store contract matches exactly, ordering is safe because order_for_injection runs downstream at the render boundary, the pool is non-empty on 164/164 real prompts, and packs can fund a draw 99.4% of the time. Only the counter changes:

  • Fire index comes from a store-level counter, not read_ring_state(session_id)["next_fire_idx"].
  • Keep derive_seed(session_id, fire_idx, query) seeded on the session so the draw stays per-session reproducible; only the firing decision moves to the global counter.
  • The determinism AC needs restating against the new tuple, and a test should pin that the counter survives a session change — that is the exact property the per-session ring lacks.

Also still open from the review, independent of this: resolve_exploration_cadence's docstring says <= 0 disables, which is true on the kwarg and TOML tiers but not on env — _env_positive_int rejects non-positive and falls through to the default 20. And none of the three resolvers has a direct test.

attn:unblock stays until the counter lands; releasing my review claim so the branch is not held by a reviewer while it needs author work. Re-flag attn:review when it is pushed and I or another session will finish it.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Garsecg:2026-08-01T05:05:58Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Garsecg:2026-08-01T05:07:27Z]

The exploration slot's three library halves all merged without a caller, and
one reason is that there was nothing to configure: exploration.py is
deliberately free of config and IO imports, so it carries the DEFAULT_
constants and no resolvers.

Adds is_exploration_enabled / resolve_exploration_cadence /
resolve_exploration_slots here instead, beside every other [retrieval] lane
flag and following the same env -> kwarg -> TOML -> default precedence.
Default OFF: the slot changes what is injected into a live conversation, and
its purpose is not ranking -- 84.1% of the store has never been injected and
therefore can never earn evidence, and the slot is the intervention that
breaks that loop. Flipping the default is a separate operator call, gated on
coverage growth counted from exploration_events rather than on a relevance
score.

A cadence at or below zero disables exploration rather than raising, matching
should_explore's own guard: a misconfigured cadence should degrade to 'never
explore', not put a ZeroDivisionError inside a retrieval.
The pool query, the exploration_events ledger and the seeded uniform draw are
all on main and nothing in src/ imports aelfrice.exploration -- a complete,
tested mechanism that cannot reach a pack. This is the missing caller.

Placed after dedup and upstream of both the rebuild log and
_record_injection_events. That position is the point, not an implementation
detail: evidence accrues on exposure, so substituting a never-injected belief
without recording the exposure would leave the loop exactly as closed as it
was.

Three properties are load-bearing, and each was a way for this to be worse
than useless:

  - Substitution, never append, accounted in TOKENS rather than slots. A drawn
    belief can be longer than the hit it replaces, so a one-for-one swap still
    grows the block. It frees at least what it spends from the lowest-ranked
    non-locked tail, and skips when the tail cannot fund the draw. A slot that
    grew the block would be a budget increase wearing an exploration costume,
    and would confound the coverage measurement the slot exists to produce.
  - User locks are never displaced. The pool excludes them and the
    displacement scan skips them, so an all-locked pack is a no-op rather than
    an eviction.
  - Fail-soft end to end, including a raising pool query: a research lane must
    never be why a hook fails.

Twelve tests, each asserting an outcome that differs from the exploration-off
arm rather than counting hits -- a counting test would pass against the no-op
this fixes, which is how three mechanisms on #1176 shipped inert. Verified by
mutation: deleting the lock skip reddens the lock test, and deleting the token
guard reddens both the too-cheap-pack test and the lock test. The token guard
also caught its own first fixtures, which were too short to fund a draw; that
refusal is now pinned as a property instead of being tuned away.
Adds the exploration_enabled / exploration_cadence / exploration_slots
section, with the three contractual properties stated rather than left to be
rediscovered from the code: substitution accounted in tokens (not slots, since
a drawn belief can be longer than the hit it replaces), locks never displaced,
and the exposure recorded upstream of the injection ledger.

States what the default flip must be gated on, because getting this wrong is
the likely failure: coverage of the never-injected pool over time, counted
from exploration_events, not a relevance A/B. It is not a ranking change and
scoring it as one would produce a null on an instrument that cannot express
what the lane does.

Records why the draw is uniform, so the A-Res weighting is not re-proposed:
uncertainty_score is Beta differential entropy, which is <= 0 on [0,1], so the
reservoir key divides by zero -- and after either sign repair the weighting
sits 0.0586 total-variation from uniform.
The documented contract is that a cadence of 0 or less disables
exploration. On the env tier -- the highest-precedence one -- it did the
opposite: _env_positive_int discards non-positive values, so
AELFRICE_EXPLORATION_CADENCE=0 fell through to the default and an
operator asking for 'off' got exploration every 20th turn. Adds a reader
that keeps a meaningful zero; the shared helper stays in place for
resolvers like exploration_slots where a non-positive value really is
just invalid.
The suite reached these only through AELFRICE_EXPLORATION, so the
env -> kwarg -> TOML -> default chain and the disable contract were
asserted by the docs and by nothing else. Each test pins one tier
against the tier beneath it, and the zero case asserts the resolved
value differs from the default rather than merely being <= 0 -- the
weaker form passes against the bug it exists to catch.
…ssion (#1279)

fire_idx is per-session -- read_ring_state returns {} on a session
mismatch, so the counter restarts each session and never accumulates.
Sessions are short: median 2 injecting turns, p90 7. At the specified
cadence of 20 the slot reached a firing turn on 8 of 956 turns all-time
and 0 of 259 since the #1016-B regime break, so the lane was wired,
tested and could never run -- the failure class this issue exists to
close. Drops the default to 3. A global 'one turn in twenty' needs a
store-level counter and is filed separately.
Fourteen call sites wrote the fire index as a literal 20, so every one
of them silently stopped exercising its branch when the default moved,
and nothing asserted that a firing turn is reachable from a real session
at all. Adds that assertion as a property rather than as an equality on
the constant, and fixes a count assertion in the sibling suite that held
only because 20 divides 1000 exactly.
)

The docstring still said "Default 20" after DEFAULT_EXPLORATION_CADENCE
moved to 3, so the highest-precedence documentation of the knob
contradicted the constant it resolves to. Points at the name rather than
repeating the number, which is the drift that produced the error.
@robotrocketscience
robotrocketscience force-pushed the feat/issue-1279-exploration-slot branch from 7c2b0fb to 1c76517 Compare August 1, 2026 05:09
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Re-review of the pushed branch — both findings closed, verified by mutation. Approving.

You pushed four minutes before the operator ruling landed, so the branch answers the finding by a different route than the ruling picked. Working through that below rather than around it.

The fixes hold

Reachability. test_the_default_cadence_is_reachable_within_a_real_session asserts the property rather than the constant, which is the right shape — and it is distinguishing:

branch as pushed                                   43 passed
DEFAULT_EXPLORATION_CADENCE reverted 3 -> 20        1 failed, 42 passed

The one failure is that test, by name. Starting the range at fire_idx = 1 is also correct — fire_idx == 0 is unobservable at this call site, because a session's first UPS turn reads the previous session's ring and append_ids returns 1 on a fresh ring, never 0.

The env <= 0 tier. _env_int_allowing_disable replaces _env_positive_int, so AELFRICE_EXPLORATION_CADENCE=0 now disables instead of silently falling through to the default, and the docstring explains why this resolver differs from l1_limit. That is the stronger fix — I had expected the docstring to be narrowed to match the code, and you changed the code to match the contract instead.

Resolver coverage. test_exploration_flag_precedence, test_cadence_precedence, test_slots_precedence_and_that_zero_is_still_rejected and test_a_non_numeric_cadence_falls_through_to_the_default close the gap where AC 1 was checked but unpinned. Deriving _FIRING from DEFAULT_EXPLORATION_CADENCE instead of hard-coding 20 is the fix that matters most for the next person — the old constants only worked because 20 divided 1000 exactly.

One thing I changed on your branch

resolve_exploration_cadence's docstring still opened "Default 20" after the constant moved to 3 — the highest-precedence documentation of the knob contradicting the knob. Pushed 1c765172 pointing at DEFAULT_EXPLORATION_CADENCE by name rather than restating the number, so it cannot drift the same way twice. Docstring only. Rebased the branch on current main at the same time (it had fallen behind); all nine commits signed, 43 tests green.

On the ruling, since it says something this branch does not

The operator ruled cross-session counter and explicitly recorded the reachable per-session default as a rejected alternative. That reads as a conflict with what you pushed; I do not think it is one, and here is the reasoning so nobody has to re-derive it:

  • The ruling answers "what is the right mechanism". feat(exploration): make fire_idx global so exploration_cadence means one turn in n (#1176 proposal 5) #1294 is that mechanism, and it is filed.
  • The knob is inert until the feature is flipped on — is_exploration_enabled defaults False — so cadence = 3 has no production effect today. Merging this cannot ship the rejected design into anyone's retrieval.
  • Your constant's comment says the global counter "is the better mechanism … and this constant should go back up if it lands", and the reachability test says in terms that a legitimate global-counter fix "means this test no longer describes the mechanism and has to be revisited deliberately". The interim is self-correcting and documented at both the constant and the test.

So: merging this, with the ruling satisfied by #1294 landing before the default is ever flipped on. That ordering is the part worth holding onto — flipping AELFRICE_EXPLORATION while fire_idx is still per-session would make the coverage measurement the slot exists to produce depend on session-length distribution, which is the thing the ruling rejected.

Everything from the first review that I checked and could not fault still stands: store contract matches exactly, ordering safe because order_for_injection runs downstream at the render boundary, pool non-empty on 164/164 real prompts, packs fund a draw on 99.4% of turns.

Labelling ready-to-merge.

@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

merge-train: blocked

2 review thread(s) are unresolved on these files: src/aelfrice/hook.py, src/aelfrice/retrieval.py. Resolve them on the PR (click 'Resolve conversation' on each) and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 1, 2026
`_substitute_exploration_slots` was the only config-dependent step in
`user_prompt_submit` that did not thread `payload_cwd` — the sibling
lookups all pass it (`load_user_prompt_submit_config(start=payload_cwd)`
and both phantom configs). Its three resolvers therefore walked for
`.aelfrice.toml` from the hook process's working directory, so a project
that opted in through its own file got the slot only when the hook
happened to run from that directory.

Test runs the same store, query and ring counter twice differing only in
`cwd`; dropping `start=cwd` from any resolver collapses the arms and
turns it red.
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

merge-train: merged 8c3ccc1main via FF push.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 1, 2026
@github-actions
github-actions Bot merged commit 8c3ccc1 into main Aug 1, 2026
29 checks passed
@robotrocketscience robotrocketscience removed the attn:review Needs review (PR open, awaiting reviewer) label Aug 1, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Garsecg:2026-08-01T05:19:07Z]

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

Labels

author-Gylf PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(retrieval): wire the exploration slot — the pool, draw and ledger are shipped but nothing calls them (#1176)

1 participant