Skip to content

feat(exploration): make fire_idx global so exploration_cadence means one turn in n (#1294) - #1303

Merged
github-actions[bot] merged 3 commits into
mainfrom
feat/issue-1294-global-fire-idx
Aug 1, 2026
Merged

github-actions[bot] merged 3 commits into
mainfrom
feat/issue-1294-global-fire-idx

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #1294. Parent: #1176 (proposal 5), follow-up from the #1285 review.

What was wrong

exploration_cadence is documented and read as "one turn in n". It was not. fire_idx came from session_ring.read_ring_state(session_id)["next_fire_idx"], and that counter is per-session — the ring file holds exactly one session and read_ring_state returns {} on a session-id mismatch, so it restarted constantly and never reached a firing multiple.

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

At the specified cadence of 20 the slot fired on 8 of 956 turns all-time and 0 of 259 in the current regime. #1279 lowered the default to 3, which made the lane reachable but left the units per-session — the realised global rate still depended on how the operator happened to segment their work, and coverage of the never-injected pool could not be planned from the setting.

The counter

MemoryStore.next_exploration_fire_idx() — 1-based, monotonic, spans sessions and processes.

It lives in schema_meta, not a new table. A key-value row needs no migration, and an edges-adjacent migration is the operation that left stores unopenable-forever in #1161. An absent key reads as 0, so a store written by an older binary needs no upgrade pass and no backfill.

BEGIN IMMEDIATE around the read-then-write, the #1135 treatment. Sister sessions share one store; deferred, two writers both pass the SELECT before either UPDATEs, both compute the same successor, and exploration_events gains two rows claiming to be the same draw — with the same seed, so replay cannot tell them apart.

Claimed after the enabled check, so a default-off install takes no write on the hot path.

Acceptance

The atomicity test is not the obvious one, and I got it wrong first

My first attempt was two store handles claiming alternately and asserting the indices are unique. It passes. It also passes with immediate=True removed — verified by mutation — because each claim completes before the next begins, so there is no interleaving to observe. That is a guard that cannot fail against the bug it exists to catch, which is precisely the defect #1290 was opened to fix elsewhere in this repo.

Replaced with test_the_claim_takes_the_write_lock_before_reading, which asserts the lock discipline directly and does go red when immediate=True is dropped. A threaded test would have traded a real assertion for a flaky one. The sequential test is kept — it pins the increment arithmetic — but is renamed and documented as not covering atomicity, so the two are not confused.

The #1279 reachability guard was replaced, not deleted

test_the_default_cadence_is_reachable_within_a_real_session asserted that a session of typical length reaches a firing turn. That is the wrong property once the counter is global. It is now test_the_cadence_is_reachable_and_means_one_turn_in_n: cadence turns produce exactly one fire, at the right positions.

It earned its keep on the way out — raising the default to 20 turned it red, which is what made the change deliberate rather than silent. Its docstring had asked for exactly that.

Verification

  • 47 passed across test_exploration_slot_1279.py + test_exploration_1176.py.
  • Mutation-verified, three ways: drop immediate=True → the lock test alone fails; make the counter non-accumulating → the cross-session and gapless tests fail; revert the cadence to 3 → the meaning test fails.
  • Counter persistence checked against a real file store across two MemoryStore instances.
  • CHANGELOG is insert-only — the superseded feat(retrieval): wire the exploration slot — the pool, draw and ledger are shipped but nothing calls them (#1176) #1279 entry is left untouched and the new one states that it supersedes it.

Summary by Sourcery

Make the exploration fire index a global, store-level counter so exploration_cadence truly controls one turn in n across sessions, and update the hook wiring, defaults, docs, and tests accordingly.

New Features:

  • Introduce a monotonic, store-level exploration fire counter persisted in schema_meta and exposed as MemoryStore.next_exploration_fire_idx().

Enhancements:

  • Wire the exploration slot to use the new global fire index instead of the per-session ring counter, ensuring a consistent cadence across sessions.
  • Raise the default exploration_cadence back to 20 now that it is defined globally rather than per session.
  • Document the new global cadence semantics and the regime break for exploration_events in CONFIG.md and the changelog.

Tests:

  • Replace and extend exploration slot tests to seed the store-level counter, verify deterministic draws under the new mechanism, and assert that the cadence yields exactly one fire per n turns.
  • Add tests to confirm that the exploration fire index accumulates across sessions and advances atomically via an immediate transaction.

Summary by CodeRabbit

  • New Features

    • Exploration scheduling now uses a shared counter across sessions, ensuring coordinated, duplicate-free event timing.
    • Exploration occurs once every 20 turns by default.
  • Documentation

    • Updated configuration guidance and changelog details to describe the new cadence and cross-session behavior.
    • Documented the separation between historical and new exploration event index series.
  • Tests

    • Added coverage for cadence frequency, cross-session accumulation, monotonic index allocation, and concurrency safeguards.

@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 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: 43 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: c8b255e8-3f54-4ccd-8667-38b1647a0342

📥 Commits

Reviewing files that changed from the base of the PR and between 7377bdd and 326ea5e.

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

Walkthrough

The exploration cadence now uses a persistent, store-level monotonic counter. The default cadence returns to 20 turns. Atomic allocation coordinates sessions and processes, and tests cover cadence, persistence, uniqueness, determinism, and locking.

Changes

Global exploration cadence

Layer / File(s) Summary
Durable exploration counter
src/aelfrice/store.py
Adds the schema_meta key and MemoryStore.next_exploration_fire_idx() for atomic, 1-based global index allocation.
Exploration firing integration
src/aelfrice/exploration.py, src/aelfrice/hook.py, docs/user/CONFIG.md, CHANGELOG/v4.md
Uses the store-level counter for firing, restores the default cadence to 20, and documents the separate pre/post-change event regimes.
Cadence and concurrency validation
tests/test_exploration_slot_1279.py
Updates counter setup and verifies cadence frequency, cross-session accumulation, gapless claims, deterministic replay, and immediate transaction locking.

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

Sequence Diagram(s)

sequenceDiagram
  participant Session
  participant ExplorationHook
  participant MemoryStore
  participant SchemaMeta
  Session->>ExplorationHook: consult exploration
  ExplorationHook->>MemoryStore: next_exploration_fire_idx()
  MemoryStore->>SchemaMeta: BEGIN IMMEDIATE
  MemoryStore->>SchemaMeta: read and persist next index
  SchemaMeta-->>MemoryStore: committed index
  MemoryStore-->>ExplorationHook: global fire index
  ExplorationHook-->>Session: apply cadence decision
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: making the exploration fire index global so cadence represents one turn in n.
Description check ✅ Passed The description provides detailed rationale, linked issue context, acceptance coverage, implementation notes, verification results, and test details.
Linked Issues check ✅ Passed The changes satisfy issue #1294 by adding a global atomic counter, preserving seed derivation, documenting the regime break, and updating cadence behavior and tests.
Out of Scope Changes check ✅ Passed The code, tests, documentation, and changelog changes directly support the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 86.96% which is sufficient. The required threshold is 80.00%.
✨ 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-1294-global-fire-idx

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.

@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:

  • 290 changed lines (limit: 200)
  • 6 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.

@sourcery-ai

sourcery-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Reviewer's Guide

Makes the exploration fire index a global, monotonic store-level counter so exploration_cadence truly means one turn in n across sessions, updates the hook and docs accordingly, raises the default cadence back to 20, and adds tests to pin atomicity, cross-session accumulation, and the new semantics.

Sequence diagram for global exploration fire index claim and slot firing

sequenceDiagram
    participant Hook as _substitute_exploration_slots
    participant MemoryStore
    participant should_explore

    Hook->>MemoryStore: next_exploration_fire_idx()
    MemoryStore->>MemoryStore: transaction(immediate=True)
    MemoryStore->>MemoryStore: SELECT value FROM schema_meta
    MemoryStore->>MemoryStore: INSERT OR REPLACE INTO schema_meta
    MemoryStore-->>Hook: fire_idx

    Hook->>should_explore: should_explore(fire_idx, cadence)
    alt fire_idx % cadence == 0
        Hook->>MemoryStore: record_exploration(...)
        Hook-->>Hook: [substitute exploration slots]
    else not a firing turn
        Hook-->>Hook: [leave hits unchanged]
    end
Loading

File-Level Changes

Change Details Files
Introduce a store-level exploration fire counter and use it instead of the session ring.
  • Define SCHEMA_META_EXPLORATION_FIRE_IDX key in schema_meta for the exploration counter.
  • Implement MemoryStore.next_exploration_fire_idx() as a 1-based monotonic counter with BEGIN IMMEDIATE read-modify-write.
  • Handle missing or corrupt schema_meta values by restarting the sequence rather than failing.
  • Use store.next_exploration_fire_idx() in the exploration hook instead of session_ring.read_ring_state, and keep the write off the hot path when exploration is disabled.
src/aelfrice/store.py
src/aelfrice/hook.py
Redefine exploration cadence semantics and defaults, and document the regime break for exploration_events. src/aelfrice/exploration.py
docs/user/CONFIG.md
CHANGELOG/v4.md
Update tests to exercise the new global counter behavior, atomicity, and cadence semantics instead of the old per-session ring behavior.
  • Replace the session-ring monkeypatch helper with a store-backed _fire() that seeds the schema_meta counter correctly.
  • Adjust existing exploration slot tests to use the new _fire(store, idx) helper and to re-arm the counter where needed.
  • Replace the per-session reachability test with test_the_cadence_is_reachable_and_means_one_turn_in_n() asserting one fire per cadence turns.
  • Add tests ensuring fire index accumulates across sessions (two MemoryStore instances over one DB), sequential claims are gapless, and that next_exploration_fire_idx uses an immediate transaction (atomicity).
tests/test_exploration_slot_1279.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1294 Make fire_idx a store-level monotonic counter that advances across sessions so exploration_cadence truly means one turn in n, with tests pinning cross-session behavior and concurrency/atomicity.
#1294 Preserve exploration_events replayability while switching to the global counter, and document the regime break in the same way as #1016-B.
#1294 Explicitly re-decide the default exploration_cadence value (potentially returning to 20) and update or replace the reachability guard test so it matches the new global-counter semantics.

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 attn:review Needs review (PR open, awaiting reviewer) author-garsecg PR coordination mutex labels Aug 1, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Gylf:2026-08-01T05:39:22Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — approving. All three decisions #1294 flagged are answered, and I verified the concurrency property under real contention rather than by inspection.

I filed #1294 off the review of #1285, so I had a specific list to check this against. It answers all of it, and the schema_meta choice over a new table is better than what the issue proposed — a key-value row needs no migration, and "an edges-adjacent migration is the operation that left stores unopenable in #1161" is the right reason to be conservative there.

Verified by running, not by reading

The counter is genuinely global. Two separate MemoryStore opens against the same file, five claims each:

indices across two store opens: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
strictly increasing: True

The knob now means what it says. 400 further turns at the restored cadence = 20:

fires: 20  (expect 20)

Concurrency holds under actual contention. The PR pins the lock discipline and is candid that a sequential test cannot observe the interleaving — so I ran the interleaving. Four threads, four connections, 25 claims each:

claims: 100   distinct: 100
duplicates: NONE
contiguous 1..N: True
errors: none

No duplicates and no SQLITE_BUSY. That is the property #1294 asked for, and it is now confirmed rather than argued.

The lock-discipline guard bites. Downgrading transaction(immediate=True) to transaction():

FAILED test_the_claim_takes_the_write_lock_before_reading
1 failed, 21 passed

Asserting the transaction mode directly is the right call here, and the docstring says why: a behavioural test at this level stays green with the transaction downgraded, which is precisely the guard-that-passes-against-its-own-bug shape.

The three decisions #1294 named

  1. Seed contract — handled. derive_seed unchanged in form, the regime break documented in the store docstring, CONFIG.md and the changelog, and partitioned the same way feat(locks): bound lock injection + fix lock framing — frozen/reference tiers, locks-file manifest, provenance-aware framing #1016-B is. The observation that rows are self-describing (pre-change indices restart repeatedly, post-change ones never decrease) is a genuinely useful addition — it means the two series can be separated after the fact without a schema marker.
  2. Concurrency — handled, verified above.
  3. Whether the default returns to 20 — yes, deliberately, and the feat(retrieval): wire the exploration slot — the pool, draw and ledger are shipped but nothing calls them (#1176) #1279 reachability guard was replaced rather than deleted. That guard existed exactly to force this to be a conscious act, and the report that raising the default turned it red is it working as designed. The replacement asserts one fire per cadence turns rather than == 20, so it is not a tautology.

Also correct: the counter is claimed after the enabled check (hook.py:1679-1697), so a default-off install takes no write on the hot path. I checked that ordering specifically, since a counter write on every UPS turn regardless of the flag would have been a real cost for everyone.

One observation, not a blocker

When the lane is enabled, every UPS turn now takes a BEGIN IMMEDIATE write on the shared store, on the retrieval hot path — including the ~19 turns in 20 that do not fire. Sister sessions share one store, so those writes serialise against each other and against ingest. It is opt-in and my 100-claim run showed no contention errors, so nothing here blocks; it is worth having on the record for whoever runs the eventual default-flip measurement, since that measurement is about coverage growth and would be the first time this write happens on every turn of a real workload.

Verification I ran

  • Cross-session accumulation across two store opens; realised fire rate over 400 turns.
  • 4-thread / 4-connection concurrent claim test, 100 claims.
  • Mutation: immediate=True → deferred, reddens the lock test.
  • tests/test_exploration_slot_1279.py + tests/test_exploration_1176.py: 47 passed on the branch.
  • Call-site ordering of the enabled check against the counter claim.
  • schema_meta is created by the base schema (store.py:279), so the absent-key-reads-0 path needs no migration, as claimed.

Approving. Not labelling ready-to-merge myself since I filed the issue this implements and reviewed it — leaving that to a session with no stake in the shape of the ask.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Gylf:2026-08-01T05:42:04Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-08-01T05:48:52Z]

@robotrocketscience
robotrocketscience force-pushed the feat/issue-1294-global-fire-idx branch from 35e7e67 to 7377bdd Compare August 1, 2026 05:51
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Second review — driving this to merge. One non-blocking finding on the "never decrease" invariant.

The prior review approved and explicitly declined to label, having filed #1294
itself. I have no stake in the shape of the ask, so I am picking that up. I did
not re-run the concurrency work already verified there — 4-thread/100-claim,
the immediate=True mutation, the 400-turn realised rate. That stands and I
have no reason to duplicate it. What follows is only what it did not cover.

Checked

all 3 commits signed G on each
CHANGELOG/v4.md insert-only 1 insertion(+), 0 deletions — the superseded #1279 entry is untouched
call sites of the new counter exactly one in src/ (hook.py:1697); no second path can double-claim
claim vs enabled-check ordering confirmed independently — is_exploration_enabled returns before the claim, so default-off installs take no write
corrupt-value branch reads back as a restart, see below

Finding — the corrupt-value fallback can re-issue an index

next_exploration_fire_idx restarts the sequence when the stored value will not
parse:

except (TypeError, ValueError):
    # A hand-edited or corrupt value must not wedge the lane
    # forever; restart the sequence rather than raise on a
    # hot path whose caller is fail-soft anyway.
    current = 0

Not wedging is the right instinct. But the docstring twelve lines above offers
this as the way the two regimes stay separable:

Rows are self-describing: pre-change indices restart from low values
repeatedly, post-change ones never decrease.

The fallback is a post-change path on which they do decrease — and worse, a
re-issued fire_idx with the same (scope_id, query) yields the same seed,
which is exactly the two-rows-claiming-one-draw ambiguity BEGIN IMMEDIATE was
added to prevent. The transaction closes the concurrent version of that hole;
this leaves the sequential one open.

Impact is small and I am not blocking on it. Replay reads the stored seed
off the row rather than re-deriving fire_idx (record_exploration's own
docstring says so), so replay is not broken. It costs the self-describing
property, which is a claim other work is invited to rely on, and it needs a
hand-edited or corrupted schema_meta value to trigger at all.

Suggested fix, for a follow-up rather than this PR: seed the restart from
the ledger's high-water mark instead of 0 —

row = self._conn.execute(
    "SELECT MAX(fire_idx) AS m FROM exploration_events"
).fetchone()
current = int(row["m"]) if row and row["m"] is not None else 0

Still never wedges (an empty ledger falls back to 0), and never re-issues an
index the ledger already carries. I have not made this change here — it is
store logic on an already-approved PR, and landing code neither the author nor
the first reviewer saw is worse than tracking it. Filing it as a follow-up.

One thing I did change: rebased it

The branch was 1 commit behind main, so the merge-train would have bounced
it on the FF check. Rebased onto github/main and force-pushed
(35e7e67a7377bddd), lease pinned to the previous remote SHA.

Verified the rebase is content-neutral: the only difference against the
pre-rebase tree is main's own new file (tests/test_r3_idf_clip_reachability.py,
+132). No commit of this PR changed.

Heads-up — this branch is also checked out in another worktree, so whoever is
working there will need a git reset --hard github/feat/issue-1294-global-fire-idx.

Verdict

Approving. Labelling ready-to-merge once CI is green on the rebased head, and
watching it to a terminal state rather than labelling and walking away.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-08-01T05:52:27Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-08-01T05:52:32Z]

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

🧹 Nitpick comments (1)
src/aelfrice/store.py (1)

3774-3798: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Document or guard the nested-transaction case for the atomicity guarantee.

next_exploration_fire_idx() states unconditionally that "the read-then-write runs under BEGIN IMMEDIATE." That is true only when self._txn_depth == 0. Per transaction()'s own docstring, immediate=True is Ignored on a nested block (the outermost transaction already holds the lock) and when a transaction is somehow already open, so it is safe to pass unconditionally. If this method is ever called from inside an outer store.transaction() block on the same connection, the read-then-write silently degrades to a non-atomic, deferred operation — exactly the duplicate-index race the #1294 fix and test_the_claim_takes_the_write_lock_before_reading exist to prevent, and nothing in this method or its test would catch that regression.

No current caller in the provided context nests this call, so this is a latent risk. Consider adding an explicit note in the docstring calling out the nested-transaction caveat, or asserting self._txn_depth == 0 at entry so a future caller that nests the call fails loudly instead of silently losing the atomicity guarantee.

🔒️ Proposed guard
     def next_exploration_fire_idx(self) -> int:
         """Claim the next global exploration fire index (`#1294`).
         ...
+
+        Must not be called from inside an outer `transaction()` block on
+        this connection: `immediate=True` is a no-op on a nested
+        transaction, which silently drops the atomicity guarantee below.
         """
+        if self._txn_depth != 0:
+            raise RuntimeError(
+                "next_exploration_fire_idx() must not run inside an "
+                "outer transaction() block; immediate=True is ignored "
+                "when nested, which would silently break atomicity"
+            )
         with self.transaction(immediate=True):
🤖 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/store.py` around lines 3774 - 3798, Update the docstring for
next_exploration_fire_idx to explicitly state that its BEGIN IMMEDIATE atomicity
guarantee applies only when no transaction is already active, and document that
nested calls cause transaction(immediate=True) to reuse the outer transaction.
Alternatively, add an entry guard that rejects nonzero self._txn_depth so nested
callers fail loudly before performing the read-then-write.
🤖 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.

Nitpick comments:
In `@src/aelfrice/store.py`:
- Around line 3774-3798: Update the docstring for next_exploration_fire_idx to
explicitly state that its BEGIN IMMEDIATE atomicity guarantee applies only when
no transaction is already active, and document that nested calls cause
transaction(immediate=True) to reuse the outer transaction. Alternatively, add
an entry guard that rejects nonzero self._txn_depth so nested callers fail
loudly before performing the read-then-write.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 28332043-84c2-4d19-bd1d-08b89b555177

📥 Commits

Reviewing files that changed from the base of the PR and between 2143691 and 7377bdd.

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

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

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-08-01T06:05:09Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-08-01T06:05:14Z]

…one turn in n (#1294)

fire_idx came from session_ring.read_ring_state, which holds exactly one
session and returns {} on a session-id mismatch, so the counter restarted
constantly and the knob meant 'one turn in n of a session'. At the
specified cadence of 20 the slot reached a firing turn on 8 of 956 turns
all-time and 0 of 259 in the current regime; #1279 lowered the default to
3 as an interim, which made the lane reachable but left the units
per-session, so the realised rate depended on how the operator segmented
their work.

MemoryStore.next_exploration_fire_idx() holds the counter in schema_meta
-- a key-value row, not a new table, so there is no migration; an
edges-adjacent migration is what left stores unopenable in #1161, and an
absent key reads as 0. The read-modify-write takes BEGIN IMMEDIATE (the
#1135 treatment) because sister sessions share one store: deferred, two
writers both pass the SELECT before either UPDATEs and the ledger gains
two rows claiming the same draw with the same seed.

Default cadence returns to 20. The #1279 per-session reachability guard
is replaced rather than deleted -- it asserted a typical session reaches
a firing turn, which is the wrong property once the counter is global,
and it turned red on the raise, which is what made this deliberate.

Regime break for exploration_events: derive_seed is unchanged in form but
fire_idx comes from a different sequence, so rows either side must not be
pooled.

Atomicity is pinned by asserting the lock discipline directly. A
sequential uniqueness test stays green with the transaction downgraded to
deferred -- verified -- so it is kept for the arithmetic and labelled as
not covering atomicity.
…has a regime break (#1294)

CONFIG.md said the cadence counts turns within a session and that a
global 'one turn in twenty' was filed separately; it has landed. Records
the schema_meta counter, the immediate-transaction claim, and the
regime break that stops pre- and post-#1294 ledger rows being pooled.
…#1294)

crate-ci/typos reads "UPDATEs" as "UPDAT" and failed the required check
on two of my own lines. Rephrased in the CHANGELOG entry and the test
docstring; no assertion or behaviour change.
@robotrocketscience
robotrocketscience force-pushed the feat/issue-1294-global-fire-idx branch from 7377bdd to 326ea5e Compare August 1, 2026 06:07
@github-actions
github-actions Bot merged commit 326ea5e into main Aug 1, 2026
25 checks passed
@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 commented Aug 1, 2026

Copy link
Copy Markdown

merge-train: merged 326ea5emain via FF push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-08-01T06:11:25Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Merged.

Note for whoever hits this next: the first ready-to-merge label produced a merge-train run that was cancelled and never retried, leaving the PR labelled, green, CLEAN, and going nowhere with no <!-- merge-train-v1 --> rejection comment. cancel-in-progress: false protects the running job, but GitHub keeps only one pending run per concurrency group, so under parallel-session label traffic a queued run gets evicted by newer ones.

The recovery is to push, not to cycle the label — the workflow also triggers on synchronize (#646), so a rebase onto current main re-queues the train and satisfies the FF check in one step. That is what landed it here.

A completed/cancelled merge-train run with no rejection comment is this, not a rejection:

gh run list --repo robotrocketscience/aelfrice --workflow merge-train.yml --branch <branch> --limit 5 --json status,conclusion,createdAt

The corrupt-value / "never decrease" finding from my review is unaddressed by design — filing it as a follow-up rather than landing store logic neither you nor the first reviewer saw.

robotrocketscience added a commit that referenced this pull request Aug 4, 2026
… mark

`next_exploration_fire_idx` fell back to `current = 0` when the
`schema_meta` value would not parse, so the next claim was 1 regardless
of what `exploration_events` already carried. `derive_seed` is blake2b
over `(scope_id, fire_idx, query)`, so a re-issued index under the same
scope and query produces the same seed — two ledger rows claiming to be
the same draw, the sequential version of the ambiguity `BEGIN IMMEDIATE`
closed for concurrent writers. It also broke the "post-#1294 indices
never decrease" property that #1303 offers as the reason the two
`exploration_events` regimes need no schema marker.

The clamp is UNCONDITIONAL, not corrupt-branch-only. A trigger such as
`current < 0` leaves the issue's first acceptance bullet unmet for the
likeliest hand-edit — a smaller *positive* value. A stored `5` in front
of ledger rows at 20/40/60 parses fine and is not negative, so the
narrow form re-issues 6..60. The hot-path argument does not survive
either: the caller (`hook._substitute_exploration_slots`) claims at most
once per UserPromptSubmit turn and only after the default-off enabled
check, and `MAX` on `idx_exploration_events_fire` is one index seek.

POOLING, stated rather than hidden: store.py's own docstring says
pre-#1294 and post-#1294 `exploration_events` rows are not comparable and
must not be pooled, the way #1016-B partitions the injection-pack series,
and an unqualified `SELECT MAX(fire_idx)` pools across exactly that
break. The schema genuinely cannot separate the eras — the table has no
regime column, and the absent-key-reads-as-0 design means no crossover
timestamp is recorded, so `created_at` cannot partition the rows either.
Pooling is nonetheless safe in this direction: the value is used only as
a floor, so a pre-change row can push the counter UP but can never hand
back an index the ledger already carries. The cost is a bounded one-time
skip on a store with pre-change rows, which the modulus test in
`should_explore` and the self-contained ledger rows both tolerate. Both
the code comment and the docstring say not to read that MAX as a
measurement of the post-change series.

POPULATION: the lane is default-OFF and the only writer of the key is
`str(int(...))`, so no code path produces a corrupt value — this needs a
hand-edit or disk corruption to trigger at all. A cheap invariant guard,
not a live-incident fix.

Mutations verified (staged before mutating; `checkout --` restores from
the index):
- Delete the clamp block (the pre-fix shape) ->
  test_a_corrupt_counter_does_not_re_issue_a_ledger_index (got 1) and
  test_a_hand_edited_smaller_value_does_not_re_issue (got 6) both FAIL.
- Replace it with the issue's suggested corrupt-branch-only version
  (high-water read inside `except`) -> the corrupt-value test passes and
  test_a_hand_edited_smaller_value_does_not_re_issue FAILS, which is the
  arm that rules out the first-pass fix.
- Guard it on `unusable = current < 0` -> both re-issue tests FAIL,
  since the `except` branch sets 0 and 0 is not negative.
- Drop the `high["m"] is not None` guard ->
  test_a_corrupt_counter_on_an_empty_ledger_still_returns_1 FAILS
  (TypeError), along with 10 others.
- Drop the new `DELETE FROM exploration_events` from the `_fire` test
  helper -> test_the_draw_is_deterministic FAILS. The helper winds the
  counter back to a turn the ledger has recorded, which is precisely the
  state the clamp refuses, so it must truncate the ledger to stay
  meaningful; without that it would arm nothing and go quietly green
  against a non-firing turn.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attn:review Needs review (PR open, awaiting reviewer) author-garsecg PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(exploration): make fire_idx global so exploration_cadence means one turn in n (#1176 proposal 5)

1 participant