fix(exploration): clamp the fire-index claim to the ledger high-water mark - #1322
Conversation
There was a problem hiding this comment.
Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
|
Warning Review limit reached
Next review available in: 4 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
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. Comment |
Reviewer's GuideThis PR changes the exploration fire-index allocation so it is always clamped to the ledger high-water mark, adds tests to pin the non-regression and edge cases around corrupt/hand-edited counters, adjusts a test helper to keep the ledger consistent for re-arming scenarios, and documents the fix in the changelog. Sequence diagram for clamped exploration fire index allocationsequenceDiagram
participant Caller
participant Store
participant schema_meta
participant exploration_events
Caller->>Store: next_exploration_fire_idx()
Store->>schema_meta: SELECT value FROM schema_meta
schema_meta-->>Store: value or null
Store->>Store: parse int(value) or set current = 0
Store->>exploration_events: SELECT MAX(fire_idx) AS m FROM exploration_events
exploration_events-->>Store: high_water = m
Store->>Store: current = max(current, high_water)
Store->>Store: nxt = current + 1
Store->>schema_meta: INSERT OR REPLACE exploration_fire_idx = nxt
Store-->>Caller: nxt
Entity relationship diagram for exploration fire index clampingerDiagram
schema_meta {
string key
string value
}
exploration_events {
int fire_idx
int scope_id
string query
}
schema_meta ||--o{ exploration_events : tracks_fire_indices
schema_meta }o--o{ exploration_events : clamped_to_MAX_fire_idx
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
[claim:review:Setr:2026-08-04T04:52:37Z] |
… 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.
Insert-only under [Unreleased] / Fixed. States the population honestly — default-off lane, `str(int(...))` is the only writer, so the branch needs a hand-edit or disk corruption — and records that the high-water read pools both `exploration_events` regimes because the schema cannot separate them, which is safe only because the value is a floor.
37e70a5 to
050e4a6
Compare
Review — no defects found; rebased onto main (
|
|
merge-train: merged 050e4a6 → |
|
[release:review:Setr:2026-08-04T05:01:47Z] |
Closes #1308.
MemoryStore.next_exploration_fire_idxread the counter out ofschema_meta, and on a parse failure fell back tocurrent = 0beforenxt = current + 1— so a corrupt value returned index 1 regardless of what the ledger already held, re-issuing every index the lane had already used and breaking the never-decrease invariant #1294 established.The fix is an unconditional clamp, not a corrupt-value branch
The obvious shape — trigger the recovery only when the value fails to parse, or when it is negative — does not meet the issue's own first acceptance bullet. A hand-edited smaller positive value is both the likeliest corruption and invisible to that trigger:
schema_meta = '5'with ledger rows at 20/40/60 parses fine, is not negative, and yieldsnxt = 6, re-issuing 6..60.So the claim is unconditional:
The "keep it conditional to protect the hot path" argument does not survive inspection: the caller fires at most once per
UserPromptSubmitturn, and only after the enabled check, which is default-OFF — andMAXon an indexed column is a single seek.The pooling question, stated rather than buried
store.py's own docstring says pre-#1294 and post-#1294exploration_eventsrows are not comparable and must not be pooled, the same way #1016-B partitions the injection-pack series. An unqualifiedSELECT MAX(fire_idx)pools across exactly that boundary.This is called out in a code comment and in the commit message rather than passed over. The reasoning for accepting it: the clamp can only move the counter up, never down and never backwards over an already-issued index. Pooling across the era boundary can therefore only make the claim more conservative — it cannot cause the re-issue this fix exists to prevent. Reviewers who disagree should say so, because the alternative (qualifying the read by era) needs a schema distinction that does not currently exist.
Honest population note
The lane is default-OFF, and the only writer of the key is
str(int), so no code path can produce a corrupt value. Reaching this branch requires a hand-edit or disk corruption. This is a cheap invariant guard, not a live-incident fix, and should be weighed as such.Verification
Mutations verified to go red (file staged before mutating, restored after) — see the commit message for the full table. Tests were run with
AELFRICE_DBpinned to a fresh temp path so the repo-local live store cannot leak in. CHANGELOG is insert-only under[Unreleased].Summary by Sourcery
Clamp the exploration fire-index counter to the ledger high-water mark to prevent re-issuing indices already recorded in the exploration ledger.
Bug Fixes:
Enhancements:
Documentation: