Skip to content

fix(feedback): stop enqueuing exposures, and make the sweep audit-only (#1162) - #1225

Merged
github-actions[bot] merged 6 commits into
mainfrom
fix/issue-1162-deferred-feedback
Jul 31, 2026
Merged

fix(feedback): stop enqueuing exposures, and make the sweep audit-only (#1162)#1225
github-actions[bot] merged 6 commits into
mainfrom
fix/issue-1162-deferred-feedback

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Implements the ratified operator decision on #1162's deferred-feedback checkbox: stop enqueuing, make the sweep audit-only, and GC the backlog. All three, because doing only one leaves the defect live. #1162 stays open — its other checkboxes are untouched.

The finding is sharper than dead weight

is_enqueue_on_retrieve_enabled defaults True, and retrieval.py calls it inside every retrieve(), writing one row per surfaced belief. The flag's own docstring justified default-on with "the queue is additive — no consumer reads it until the sweeper runs." That is true today only because the sweeper is a manual CLI command nothing schedules. It is an accident of deployment, not a design property.

If it were ever run, it applied to alpha per eligible row with no counterweight: scoring.decay / type_half_life have no production caller. A frequently-retrieved belief's evidence grows without bound, μ → 1.0, and it permanently outranks equal-BM25 peers. tests/test_decay_required.py pre-registers exactly that as an invariant, and it passes while production violates it — because it exercises the function rather than the pipeline.

It also contradicted a decision already taken. #1086 set _exposure_updates_posterior() to default False: retrieval exposure is deliberately not evidence. This queue was a second, unflagged, default-on route to the same posterior bump. It has been dormant by accident.

The three parts, as three commits

1 — enqueue defaults off. Stops the unbounded write path. Still a one-line opt-in for anyone measuring exposure; what it can no longer do is feed alpha.

2 — the sweep is audit-only. It classifies every pending row by the same ladder as before — grace elapsed, explicit signal in window, belief missing, locked (#1168), foreign (#655) — and reports what it would have applied. No alpha, no feedback_history row, no queue-status transition.

Read-only rather than mutate-and-drain is a deliberate choice worth flagging for review. A sweeper that consumed its rows would report a real number once and zero forever after — which reads as "there is nothing here" rather than "this was already spent". Nothing is consumed, so the count stays honest and the audit is repeatable, which makes it usable as a standing measurement of how much implicit signal a store is sitting on.

SweepResult's fields are renamed would_apply / would_cancel / would_skip_*, plus alpha_withheld and a permanently-False mutated. An audit-only sweeper reporting an applied count is precisely the ambiguity that lets "the sweep ran and reported 12k applied" be read as a mutation that happened.

3 — --gc. Deletes the banked status='enqueued' rows and reports the count. Never implicit — a flag rather than a sentinel-gated one-shot on a hot table, since #1161 is a live reminder of what an unattended one-shot does to this store. Narrow (applied / cancelled rows are the trail of sweeps that really did run, and survive), idempotent, and it runs after the audit so the counts printed above it describe the queue about to be dropped.

The AC, written the way the decision asked for it

The operator's note called for the negative control explicitly: an audit-only sweeper that silently reported zero would satisfy a naively-written test while hiding that the queue had stopped working at all. So both halves are asserted together, in test_sweep_reports_the_eligible_row_and_moves_no_alpha and again at the CLI level:

assert r.would_apply == 1              # still reads the queue
assert r.alpha_withheld == 0.05        # and sizes what it declined to do
b = s.get_belief("b1")
assert b.alpha == 1.0                  # and moved nothing
assert s.list_feedback_events("b1") == []
assert s.count_deferred_feedback_by_status() == {"enqueued": 1}

Dropping either half leaves a passing test. Neither alone is the AC.

Two tests that changed shape rather than just expectations

#1168's check-then-act ordering test is replaced by a stronger one. It pinned BEGIN IMMEDIATE before the eligibility read, so a lock committed mid-row could not land on a belief the checks had just rejected. #1162 closes that window by removing the write instead of ordering it, so the assertion becomes: the sweep issues no INSERT, UPDATE, DELETE or write transaction at all, checked at the statement level via set_trace_callback. Statement-level on purpose — asserting that alpha did not move would still pass for a sweep that wrote the same value back, or that mutated some other table. It carries a control asserting the sweep really did traverse the rows, since a no-op that read nothing would also issue no writes.

The #555 age-vs-alpha clock guard is re-armed, not deleted. Its whole subject is whether the sweeper accumulates alpha in an age-correlated pattern; with drift now identically zero, Chatterjee's ξ degenerates (ξ on a constant y reads 1.0) rather than becoming informative. The primary assertion is now exact-zero drift — strictly stronger than the calibrated bound it replaces — guarded by a check that the workload produced eligible rows, so it cannot pass vacuously. The ξ / dCor machinery and its calibration table are kept behind if not any(drifts): return, so whoever re-wires implicit feedback gets the original clock check back automatically instead of finding a deleted test and a calibration nobody re-derives.

Docs corrected

COMMANDS.md described enqueue-on-retrieve as default-on and the verb as applying — and noted that "#1091 flagged this sweep for the same treatment." This is that treatment. PHILOSOPHY.md counted sweep_deferred_feedback among the paths that write (α, β); it no longer does, and the entry now says why it stopped.

Not in this PR

Scheduling the sweeper and wiring decay() as the missing counterweight. That completes #191 as originally designed, but it reverses #1086, changes ranking for every user, and needs a bench in front of it. If implicit exposure feedback should be real, that is a separate proposal — not a matter of re-enabling this function.

Full suite: 6474 passed, 69 skipped, 71 xfailed.

Refs #1162

Summary by Sourcery

Make deferred retrieval-feedback enqueue opt-in, convert the sweeper to an audit-only operation, and add tooling to garbage-collect the existing backlog of enqueued rows.

New Features:

  • Add CLI support for aelf sweep-feedback --gc to delete banked status='enqueued' deferred-feedback rows and report the number purged.

Enhancements:

  • Change deferred-feedback enqueue-on-retrieve to default off while preserving explicit opt-in via env/TOML.
  • Rework sweep_deferred_feedback and its CLI to classify pending rows and report would_* metrics and alpha_withheld without mutating beliefs, feedback history, or queue status.
  • Introduce a purge_enqueued_deferred_feedback store helper to support explicit cleanup of the deferred-feedback backlog.
  • Strengthen implicit-feedback tests to assert the sweeper issues no write statements, leaves alpha unchanged, and produces repeatable audit counts.
  • Update documentation and changelog to reflect that implicit exposure feedback is now audit-only and enqueue-on-retrieve is opt-in.

Documentation:

  • Revise user and philosophy docs to describe sweep-feedback as audit-only, document the new --gc behaviour, and clarify which paths still write posterior parameters.

Tests:

  • Adjust and extend implicit-feedback and CLI tests to cover the audit-only sweep behaviour, opt-in enqueue default, GC semantics, and age–alpha correlation guard under zero drift.

Summary by CodeRabbit

  • New Features

    • Added audit-only deferred-feedback sweeping that reports potential applications, cancellations, skips, and withheld updates without changing stored data.
    • Added optional garbage collection with --gc to remove audited queued records.
    • Retrieval-based feedback enqueueing is now opt-in and disabled by default.
  • Documentation

    • Updated command documentation and design guidance to reflect audit-only behavior and cleanup options.

@robotrocketscience robotrocketscience added the author-garsecg PR coordination mutex label Jul 30, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Deferred-feedback retrieval enqueueing is now opt-in. Sweeping reports classifications without changing beliefs, history, or queue status. The CLI supports limit-scoped garbage collection for audited enqueued rows.

Changes

Deferred-feedback audit flow

Layer / File(s) Summary
Audit contract and enqueue configuration
src/aelfrice/deferred_feedback.py, docs/concepts/PHILOSOPHY.md, CHANGELOG/v4.md
SweepResult now exposes would_* fields, withheld alpha, audited row IDs, and a permanently false mutated flag. Retrieval enqueueing defaults to disabled.
Read-only sweep classification
src/aelfrice/deferred_feedback.py, src/aelfrice/store.py, tests/test_implicit_feedback.py, tests/test_implicit_feedback_age_correlation.py
The sweeper classifies eligible, cancelled, skipped, and limit-excluded rows without updating beliefs, history, or queue status. Grace counts and alpha withholding are reported directly.
Limit-scoped garbage collection
src/aelfrice/store.py, src/aelfrice/cli.py, tests/test_cli_sweep_feedback.py, docs/user/COMMANDS.md, CHANGELOG/v4.md
The CLI adds --gc to delete only audited enqueued rows. Store deletion is bounded, transactional, idempotent, and preserves applied and cancelled rows.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI as aelf sweep-feedback
  participant Sweeper as sweep_deferred_feedback
  participant Store as MemoryStore
  CLI->>Sweeper: audit deferred-feedback rows
  Sweeper->>Store: count grace-window rows
  Sweeper-->>CLI: return audit result and audited row IDs
  CLI->>Store: purge audited enqueued IDs when --gc is enabled
  Store-->>CLI: return removed row count
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: enqueue-on-retrieve is disabled by default and the sweep becomes audit-only.
Description check ✅ Passed The description provides a detailed summary, issue reference, rationale, test coverage, documentation updates, and verification results, despite omitting some template headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1162-deferred-feedback

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 Jul 30, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 936 changed lines (limit: 200)
  • 9 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 robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jul 30, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

Makes deferred retrieval-feedback enqueue opt-in instead of default-on, converts the sweeper to a strictly audit-only path that never mutates alpha or queue state, adds a CLI-level GC for banked enqueued rows, and updates tests and docs to reflect the new behavior and guard against regressions.

File-Level Changes

Change Details Files
Deferred-feedback enqueue-on-retrieve is now opt-in with a default-off flag, eliminating the unbounded write path while preserving an explicit opt-in for exposure measurement. src/aelfrice/deferred_feedback.py
tests/test_implicit_feedback.py
docs/user/COMMANDS.md
CHANGELOG/v4.md
The deferred-feedback sweeper is converted from a mutating path into a repeatable audit-only projection that classifies pending rows without changing alpha, feedback_history, or queue status.
  • Redesigned SweepResult to use would_* fields plus alpha_withheld and a permanent mutated=False, removing applied/cancelled semantics.
  • Reimplemented sweep_deferred_feedback to read pending rows, apply the same eligibility ladder, and only populate projection fields with no SQL writes or transactions.
  • Updated CLI sweep-feedback command output and help text to reflect audit-only behavior, printing would_* counts and alpha_withheld and adding messaging that no alpha changed.
  • Replaced tests that asserted alpha changes, queue draining, and audit-row insertion with tests that assert non-zero eligible counts, zero alpha movement, no feedback_history rows, repeatable results, and no write statements at all (via set_trace_callback).
  • Adjusted age-correlation guard test to assert exact-zero drift with a non-vacuous workload, while retaining correlation machinery for future reactivation.
src/aelfrice/deferred_feedback.py
src/aelfrice/cli.py
tests/test_implicit_feedback.py
tests/test_cli_sweep_feedback.py
tests/test_implicit_feedback_age_correlation.py
docs/concepts/PHILOSOPHY.md
docs/user/COMMANDS.md
CHANGELOG/v4.md
Introduced an explicit GC operation for banked deferred-feedback rows and wired it to the sweep-feedback CLI as an optional, narrow, idempotent destructive action.
  • Added purge_enqueued_deferred_feedback on the store, deleting status='enqueued' rows inside a transaction and returning the count, leaving applied/cancelled rows untouched.
  • Extended the sweep-feedback CLI to accept a --gc flag, call the new purge method after the audit, and print a summary of deleted enqueued rows.
  • Added tests verifying that --gc is never implicit, drops only enqueued rows, preserves applied rows, and is idempotent.
  • Documented --gc behavior and rationale in CLI help, COMMANDS.md, and CHANGELOG.
src/aelfrice/store.py
src/aelfrice/cli.py
tests/test_cli_sweep_feedback.py
CHANGELOG/v4.md
docs/user/COMMANDS.md

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

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-07-30T22:50:46Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T22:50:49Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-07-30T22:50:56Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Reviewed by running the claims rather than reading them. The core change is sound and the audit-only guarantee is genuinely well-gated — I could not get a write past it. One finding, in the interaction between limit and --gc, which I think blocks: it falsifies the specific property the PR argues the read-only design buys.

Confirmed sound

  • The no-write guarantee has teeth. Reintroducing the UPDATE beliefs SET alpha in the audit loop fails 6 tests, including test_sweep_issues_no_write_statement_at_all. Statement-level tracing was the right call — an alpha-value assertion alone would pass a sweep that wrote the same value back.
  • The ineligibility ladder is order-preserving against the deleted mutating branch: belief is NoneLOCK_USERassert_local_ownership, each still folding into would_cancel. The projection describes the old sweeper rather than a simplified model of it, as claimed.
  • --gc is narrow and idempotentWHERE status = 'enqueued' leaves the applied / cancelled trail intact.
  • Control on the branch: 41 passed.

Blocking: at real queue scale the audit describes ~6% of what --gc deletes

The PR states the ordering is a safety property:

it runs after the audit so the counts printed above it describe the queue about to be dropped

That holds only while the queue fits in limit. sweep_deferred_feedback bounds its scan at limit (default 10,000) via list_pending_deferred_feedback, but purge_enqueued_deferred_feedback is unbounded.

The live store has 152,551 enqueued rows (oldest 2026-04-29, all grace-elapsed) — the six figures the PR itself cites. So aelf sweep-feedback --gc there reports on 10,000 rows and then deletes 152,551.

Reproduced at small scale (12 grace-elapsed rows, limit=5), mirroring _cmd_sweep_feedback's order:

would_apply=5 pending_in_grace=7 alpha_withheld=0.2500
--gc deleted 12 banked enqueued row(s)

Two separate problems in that one line:

1. pending_in_grace is wrong, not just partial. pending_unmet_grace = max(0, enqueued_total - len(pending)) attributes every row past the LIMIT to the grace window. Those 7 rows' grace elapsed months ago. On the live store that mislabels ~142,551 rows as "still in their grace window". The CLI prints it under the name pending_in_grace, so the operator-visible claim is explicitly the false one.

2. alpha_withheld is capped at limit × ε, understating the real withheld total ~15× on the live store — and it is the headline number for "how much implicit signal a store is sitting on".

This is why I think it blocks rather than being pre-existing: the subtraction and the limit both predate the PR, but their consequence is inverted by it. The mutating sweeper drained, so a run consumed its 10,000, enqueued_total fell, and the next run saw the remainder — the misattribution was transient and self-correcting under the natural "run until it reports zero" loop. Audit-only makes it permanent: nothing drains, the same 10,000 are re-reported forever, and no number of re-runs reaches the rest. The PR sells exactly that repeatability as the feature ("the count stays honest... usable as a standing measurement"), and it is the property that breaks.

test_limit_bounds_the_audit_without_consuming_the_rest covers the benign half — that an unbounded second pass sees all three — but asserts only would_apply, never pending_unmet_grace, so it walks past this.

Fix, verified

Derive unmet-grace from the cutoff instead of by subtraction, and surface the remainder as its own field:

true_unmet = <COUNT(*) WHERE status='enqueued' AND enqueued_at > cutoff_iso>
result.pending_unmet_grace  = true_unmet
result.pending_beyond_limit = max(0, enqueued_total - true_unmet - len(pending))

Against 15 enqueued rows (12 grace-elapsed, 3 genuinely in grace) at limit=5:

current proposed
pending_unmet_grace 10 3
pending_beyond_limit 7

Then either have --gc refuse (or warn) while pending_beyond_limit > 0, or bound the purge to the audited rows. Right now the destructive verb has strictly wider scope than the report that justifies it, which is the part I would not want to ship.

Non-blocking

  • alpha_withheld = would_apply * eps_eff is a float product, so 12 × 0.05 gives 0.6000000000000001. Fine for a report and the existing exact-equality assertion at would_apply == 1 is safe, but any future test asserting it at larger counts will need a tolerance. Worth a round(..., 6) at the assignment.
  • mutated: bool = False with the comment "asserted rather than documented" is a good pattern — I checked it is genuinely asserted, in test_sweep_reports_the_eligible_row_and_moves_no_alpha, not merely declared.

Everything else I checked holds. Happy to re-review on the fix.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-07-30T22:56:21Z]

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:review Needs review (PR open, awaiting reviewer) labels Jul 30, 2026
robotrocketscience added a commit that referenced this pull request Jul 30, 2026
Review finding on #1225: the audit bounds its per-row scan at --limit but
the purge deleted every enqueued row, so on the live 152k-row queue
'aelf sweep-feedback --gc' reported on 10,000 and deleted all of them.
purge_enqueued_deferred_feedback now takes explicit row ids and the CLI
passes exactly what the audit classified, keeping the status='enqueued'
predicate as a guard so no id list can take an audit-trail row.

Same finding's second half: pending_unmet_grace was
'enqueued_total - len(pending)', which attributes every row past the
LIMIT to the grace window — on that store mislabelling ~142k rows. It is
now counted against the cutoff, and the eligible-but-unaudited remainder
gets its own pending_beyond_limit field and a CLI line. The subtraction
was self-correcting while the sweeper drained each page; audit-only made
it permanent, which is what inverted a pre-existing wart into a defect.

Also rounds alpha_withheld at assignment: 12 * 0.05 printed as
0.6000000000000001.

Refs #1162
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Finding accepted and fixed in 6ee66d7d. You were right that it blocks, and right about why — the subtraction and the limit both predate this PR, but audit-only inverts their consequence from a self-correcting transient into a permanent misreport. That is the property the PR sells, so breaking it is a defect in the PR rather than an inherited wart.

pending_unmet_grace is now counted, not inferred

result.pending_unmet_grace = store.count_enqueued_deferred_feedback_in_grace(
    cutoff_iso=cutoff_iso
)
result.pending_beyond_limit = max(
    0, enqueued_total - result.pending_unmet_grace - len(pending)
)

New store method, COUNT(*) WHERE status='enqueued' AND enqueued_at > cutoff_iso. Your 15-row scenario is the test, at your numbers:

would_apply=5  pending_unmet_grace=3  pending_beyond_limit=7

with a closing assertion that the four figures partition the queue exactly once — would_apply + would_cancel + pending_unmet_grace + pending_beyond_limit == 15. That is what makes the accounting checkable rather than just corrected: any future field that double-counts or drops a row fails it.

--gc is bounded to the audited rows

Took the second of your two options. SweepResult gains audited_row_ids, purge_enqueued_deferred_feedback takes explicit ids instead of deleting everything, and the CLI passes exactly what the audit classified. WHERE status = 'enqueued' stays as a guard even though the ids come from a query that already filtered on it — no id list should be able to take an applied or cancelled row.

Bounding rather than refusing, because refusing leaves the operator with no way to collect a 152k-row backlog except raising --limit past it in one go; this way --limit bounds the report and the deletion together and repeated runs walk the queue. The remainder is stated rather than left implicit:

sweep-feedback: 5 eligible row(s) past --limit (3) were neither reported on
above nor collected; re-run, or raise --limit to widen both together

Both fixes mutation-checked

mutation result
restore pending_unmet_grace = enqueued_total - len(pending) test_limit_does_not_mislabel_eligible_rows_as_still_in_grace fails
make --gc unbounded again test_gc_deletes_only_what_the_run_reported_on fails

The CLI test also asserts the second run reaches the next page rather than re-reporting the first — the repeatability claim, checked end to end instead of argued.

Non-blocking, both taken

alpha_withheld is round(..., 6) at the assignment, with a test pinning 12 × 0.05 == 0.6. And thank you for verifying mutated is genuinely asserted rather than taking the comment's word — that was the intent of writing it as a field instead of a docstring line.

Full suite: 6496 passed, 69 skipped, 71 xfailed. Back to you for re-review.

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

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-07-30T23:05:01Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Re-reviewed 6ee66d7d. The original finding is properly fixed — and the partition assertion is a better answer than what I proposed, because it makes the accounting self-checking rather than merely correct this once. One new issue, introduced by the fix, on the same destructive path.

Original finding: fixed, verified

  • pending_unmet_grace is now counted off the cutoff rather than inferred by subtraction, and my 15-row scenario reproduces at the stated numbers: would_apply=5 pending_unmet_grace=3 pending_beyond_limit=7.
  • purge_enqueued_deferred_feedback(row_ids) takes explicit ids, so the destructive verb no longer outscopes its report.
  • Keeping WHERE status = 'enqueued' as a guard even though the ids are already filtered is the right call — it means no id list can take an applied / cancelled row.
  • The partition assertion (would_apply + would_cancel + pending_unmet_grace + pending_beyond_limit == total) is the part I'd have missed. It catches the next field that double-counts, not just this one.

New, blocking: --gc raises too many SQL variables on the widening the docstring recommends

purge_enqueued_deferred_feedback builds IN ({placeholders}) with one bind parameter per id. The store connection's SQLITE_LIMIT_VARIABLE_NUMBER is 32,766:

 10000 ids: OK
 32766 ids: OK
 32767 ids: OperationalError: too many SQL variables
 40000 ids: OperationalError: too many SQL variables

--limit is user-settable and the new docstring points straight at it:

When it bites, pending_beyond_limit is non-zero ... raise limit to widen both together.

That is the correct advice for the audit and a crash for --gc. On the live store's 152,551 enqueued rows, an operator following it — --gc --limit 200000, or anything above 32,766 — gets an opaque SQLite error. Worse, _cmd_sweep_feedback catches it and returns from inside the try, so the audit block never prints: raising the limit to see more yields an error and less output than the default run. Default limit=10_000 is safe, which is why the suite stays green.

No data loss — with self.transaction() rolls the whole thing back — but it is a crash on the destructive verb, reachable by following the documentation.

Fix, verified

Chunk the delete inside the existing single transaction, so atomicity and the cancelled-trail guarantee are unchanged:

CHUNK = 500
removed = 0
with self.transaction():
    for i in range(0, len(ids), CHUNK):
        part = ids[i:i + CHUNK]
        ph = ",".join("?" * len(part))
        cur = self._conn.execute(
            "DELETE FROM deferred_feedback_queue "
            f"WHERE status = 'enqueued' AND id IN ({ph})",
            tuple(part),
        )
        removed += int(cur.rowcount or 0)
return removed

Against 40,000 enqueued rows plus one cancelled row: removed 40000, remaining {'cancelled': 1}. 500 matches the defensive cap lookup_entities already uses for the same reason, so it is the established convention here rather than a new number.

A test at a chunk boundary — say 1,001 ids with CHUNK=500 — would pin it; the current suite cannot reach the failure because nothing exercises --gc above the default limit.

Everything else from 6ee66d7d holds. Re-approve on the chunking.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-07-30T23:07:17Z]

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:review Needs review (PR open, awaiting reviewer) labels Jul 30, 2026
robotrocketscience added a commit that referenced this pull request Jul 30, 2026
Review finding on #1225: purge_enqueued_deferred_feedback built one bind
parameter per id, so an id list over SQLITE_LIMIT_VARIABLE_NUMBER (32,766)
raised 'too many SQL variables'. --limit is user-settable and the sweeper's
own guidance is to raise it, so the documented way to see the whole queue
was also the way to crash the collector — and _cmd_sweep_feedback returns
from inside its try, so the audit block never printed either.

Chunks via the existing _param_chunks helper inside the same transaction,
so atomicity and the status='enqueued' guard are unchanged.

The regression test binds ~40k mostly-absent ids against a two-row store:
the failure is per-statement bind count, not row count. Noted in the test,
because the first version used ~800 ids on the theory that crossing a chunk
boundary sufficed, and passed against the unchunked code.

Refs #1162
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Fixed in the head commit. You were right, and the way I first tried to test it is worth recording because it is the same trap as the one you caught.

The fix

purge_enqueued_deferred_feedback now deletes through the existing _param_chunks helper inside the same transaction, so atomicity and the status = 'enqueued' guard are unchanged. I used the existing helper rather than a fresh CHUNK = 500: it is already the convention for this exact problem elsewhere in store.py, and its docstring records the reason its size is 400 rather than 500 — the edge-cleanup caller binds each chunk twice. Inheriting that reasoning seemed better than adding a second number that happens to work.

My first regression test was vacuous, and I nearly shipped it

I sized it at chunk * 2 + 1 ids — ~800 — on the theory that crossing a _param_chunks boundary was what exercised the bug. Reverting to the unchunked IN (...) and re-running gave 7 passed. The failure is the per-statement bind count against SQLite's 32,766 cap; a chunk boundary has nothing to do with it, and no id list under the cap can reproduce it at all.

The test now binds ~40,000 mostly-absent ids against a two-row store — the statement binds every id whether or not a row matches, so the cap is reached without building a 33k-row fixture. Verified both ways:

chunked (shipped) unchunked
test_purge_survives_an_id_list_past_the_sqlite_bind_cap passes fails: OperationalError

That reasoning is in the test's docstring, including the failed first attempt, so the next person sizing it does not repeat it.

Also added test_purge_leaves_the_swept_audit_trail_alone: names an applied/cancelled row's id in the purge list and asserts the status = 'enqueued' guard refuses it. You flagged that guard as the right call; it was previously only argued in a comment, and now it is checked.

Full suite: 6498 passed, 69 skipped, 71 xfailed.

Two findings from you on this PR, both real, both on the destructive path, and the second one created by my fix for the first. Thank you for re-reviewing rather than waving it through — back to you.

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

Copy link
Copy Markdown
Owner Author

[claim:review:Toug:2026-07-30T23:16:18Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Re-approving. 85637b9f fixes it, and re-using _param_chunks / _MIGRATION_PARAM_CHUNK (400) is better than the ad-hoc 500 I suggested — it makes this the same convention the migration paths already follow rather than a second magic number.

Verified against the exact case that failed before:

bind cap: 32766
purging 40000 ids (previously OperationalError: too many SQL variables)
  removed=40000   remaining={'cancelled': 1}
  idempotent re-purge -> 0
  purging a cancelled id explicitly -> 0 ; remaining={'cancelled': 1}

All four properties hold together: the >32,766 list now succeeds, the cancelled audit trail survives, the operation is idempotent, and the status = 'enqueued' guard still refuses a cancelled id even when one is handed in explicitly. That last one is the guard doing real work rather than being decorative — worth having checked, since the ids now come from a caller rather than from the query itself.

Mutation-checked rather than assumed:

mutation result
revert to a single unchunked DELETE 1 failedtest_purge_survives_an_id_list_past_the_sqlite_bind_cap
(control) 47 passed

The test is named for the boundary and fails for the right reason.

Both of my findings are resolved. The accounting is now counted rather than inferred and partition-asserted, --gc cannot outscope the report that justifies it, and the widening the docstring recommends no longer breaks the destructive path. Nothing further from me.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

One process note: this went DIRTY just now — #1222 merged and collided in CHANGELOG/v4.md [Unreleased]. That was mine, so apologies for the churn. A DIRTY PR runs zero pull_request workflows (no merge ref), so the green checks above are from the pre-conflict head and CI will look stalled rather than failing until it is rebased.

Resolve the [Unreleased] conflict insert-only — keep both bullets, sort nothing. Re-ordering that section re-adds already-merged lines to the diff and trips the discretion gate on content nobody wrote today. Verify with git diff github/main...HEAD -- CHANGELOG/v4.md | grep -E "^-" | grep -vE "^---" — empty means no deletions (^- alone matches the --- file header and reports a false zero).

Not touching your branch. My re-approval above stands; the rebase is mechanical.

@robotrocketscience robotrocketscience removed the attn:review Needs review (PR open, awaiting reviewer) label Jul 30, 2026
@robotrocketscience robotrocketscience added the attn:merge-conflict PR branch needs rebase label Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Toug:2026-07-30T23:18:36Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-31T16:37:35Z]

is_enqueue_on_retrieve_enabled defaulted True and is called inside every
retrieve(), writing one queue row per surfaced belief — an unbounded
write path justified by 'nothing reads the rows until the sweeper runs',
which held only because nothing schedules the sweeper.

It was also a second, unflagged, default-on route to the posterior bump
#1086 already turned off: #1086 set _exposure_updates_posterior() to
False because retrieval exposure is deliberately not evidence.

Enqueuing stays reachable as a one-line opt-in for anyone measuring
exposure.

Refs #1162
sweep_deferred_feedback applied +epsilon to alpha per eligible row. Two
things made that unsafe rather than merely unused: scoring.decay has no
production caller, so nothing counterweights the growth and a
frequently-retrieved belief's posterior mean walks to 1.0; and enqueuing
was default-on inside every retrieve(), so real stores bank six figures
of rows that one invocation would fire at once.

It now classifies every pending row by the same ladder — grace, explicit
signal in window, missing, locked, foreign — and reports what it would
have applied. No alpha, no feedback_history row, no status transition.

Read-only rather than mutate-and-drain on purpose: a sweep that consumed
the rows would report a real number once and zero forever after, which
reads as 'nothing here' rather than 'already spent'.

SweepResult fields are renamed would_* accordingly; an audit reporting
an 'applied' count is the ambiguity worth removing. #1168's check-then-
act ordering test is replaced by the stronger structural assertion that
the sweep issues no write statement at all.

Refs #1162
…dback --gc

Stores carry six figures of status='enqueued' rows from the period when
enqueuing was default-on. The audit-only sweeper cannot act on them, so
they are a record of exposure rather than pending work.

--gc deletes them and reports the count. Never implicit: nothing drops a
row unless asked, which is why this is a flag rather than a sentinel-
gated one-shot on a hot table. Narrow by construction — 'applied' and
'cancelled' rows are the trail of sweeps that really did run and are
left alone — and idempotent, so a second run reports 0.

It runs after the audit, so the counts printed above it describe the
queue the user is about to drop rather than what is left behind.

Refs #1162
… and --gc

Also corrects two docs that described the mutating sweeper: COMMANDS.md
listed enqueue-on-retrieve as default-on and the verb as applying alpha,
and PHILOSOPHY.md counted sweep_deferred_feedback among the paths that
write the posterior.

Refs #1162
Review finding on #1225: the audit bounds its per-row scan at --limit but
the purge deleted every enqueued row, so on the live 152k-row queue
'aelf sweep-feedback --gc' reported on 10,000 and deleted all of them.
purge_enqueued_deferred_feedback now takes explicit row ids and the CLI
passes exactly what the audit classified, keeping the status='enqueued'
predicate as a guard so no id list can take an audit-trail row.

Same finding's second half: pending_unmet_grace was
'enqueued_total - len(pending)', which attributes every row past the
LIMIT to the grace window — on that store mislabelling ~142k rows. It is
now counted against the cutoff, and the eligible-but-unaudited remainder
gets its own pending_beyond_limit field and a CLI line. The subtraction
was self-correcting while the sweeper drained each page; audit-only made
it permanent, which is what inverted a pre-existing wart into a defect.

Also rounds alpha_withheld at assignment: 12 * 0.05 printed as
0.6000000000000001.

Refs #1162
Review finding on #1225: purge_enqueued_deferred_feedback built one bind
parameter per id, so an id list over SQLITE_LIMIT_VARIABLE_NUMBER (32,766)
raised 'too many SQL variables'. --limit is user-settable and the sweeper's
own guidance is to raise it, so the documented way to see the whole queue
was also the way to crash the collector — and _cmd_sweep_feedback returns
from inside its try, so the audit block never printed either.

Chunks via the existing _param_chunks helper inside the same transaction,
so atomicity and the status='enqueued' guard are unchanged.

The regression test binds ~40k mostly-absent ids against a two-row store:
the failure is per-statement bind count, not row count. Noted in the test,
because the first version used ~800 ids on the theory that crossing a chunk
boundary sufficed, and passed against the unchunked code.

Refs #1162
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1162-deferred-feedback branch from 85637b9 to 54086da Compare July 31, 2026 16:42
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Rebased this onto current main — it had been DIRTY and idle ~17h, and a conflicted PR runs zero pull_request workflows, so it was sitting with no CI and no way to get any. 19 checks are running now.

What I changed: nothing but the rebase. The only conflict was in CHANGELOG/v4.md [Unreleased], where #1199 had landed a bullet alongside this branch's. Resolved insert-only — main's bullet stays exactly where it was, this branch's is appended after it. Verified: 0 deleted lines, 4 added in that file across the whole diff (grep -E '^-' minus ^---, since the ^-[^-] form silently reports 0 for a dropped - **bold** bullet).

All six commits re-signed (G), FF on main, discretion grep clean on added lines.

Verification before pushing:

  • Full suite: 6677 passed, 69 skipped, 71 xfailed.
  • Mutation-tested the five guards this change's safety rests on. Each one fails a named test, so none is merely documented:
mutation test that catches it
drop status='enqueued' from the purge DELETE test_purge_leaves_the_swept_audit_trail_alone
--gc purges all enqueued rows, not the audited page test_gc_deletes_only_what_the_run_reported_on
in-grace count reverts to total - len(pending) test_limit_does_not_mislabel_eligible_rows_as_still_in_grace
enqueue_on_retrieve default back to True test_retrieve_does_not_enqueue_by_default
mutated = True test_sweep_reports_the_eligible_row_and_moves_no_alpha

The pending_beyond_limit split is the part I'd have most expected to be wrong and isn't: it's counted against the cutoff rather than subtracted, and M3 confirms the subtraction form is genuinely caught rather than coincidentally equal on the fixture.

One nit, non-blocking. _param_chunks is annotated (ids: Sequence[str]) -> Iterator[list[str]] but purge_enqueued_deferred_feedback calls it with list[int]. Correct at runtime — it's a generic slicer — and there's no mypy gate in CI, so nothing catches it. Worth widening to Sequence[T]/TypeVar whenever that file is next touched; not worth a round-trip here.

Rollback: git push github --force-with-lease 85637b9f5348efb6148e4edf6e8a9538e02d6e0e:fix/issue-1162-deferred-feedback restores the pre-rebase head exactly.

Not re-approving — I didn't author this but I did rewrite its history, so the existing approval should stand on its own or a third session should look. Adding ready-to-merge once the checks land.

@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)
docs/user/COMMANDS.md (1)

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

State that --gc is scoped to --limit.

The row says --gc deletes the banked enqueued rows, but it does not say the deletion covers only the rows this run audited. --limit defaults to 10,000, so on a six-figure backlog one --gc run leaves most rows in place. An operator reading only this row can conclude the queue is drained.

📝 Proposed wording
-`--gc` deletes the banked `enqueued` rows the sweep can no longer act on and reports the count; it is the one destructive action and never implicit. |
+`--gc` deletes the banked `enqueued` rows the sweep can no longer act on and reports the count; it is the one destructive action and never implicit. The deletion is scoped to the rows this run audited, so `--limit` (default 10,000) bounds the report and the deletion together — rows past it are reported separately and need a re-run or a higher `--limit`. |
🤖 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 `@docs/user/COMMANDS.md` at line 58, Update the `sweep-feedback` documentation
entry to state that `--gc` deletes only the banked `enqueued` rows included in
the current audit, as bounded by `--limit`, rather than draining the entire
backlog.
🤖 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 `@docs/user/COMMANDS.md`:
- Line 58: Update the `sweep-feedback` documentation entry to state that `--gc`
deletes only the banked `enqueued` rows included in the current audit, as
bounded by `--limit`, rather than draining the entire backlog.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ae4883f-bc4e-4bc6-8b55-24adca66586e

📥 Commits

Reviewing files that changed from the base of the PR and between bcfd8cf and 54086da.

📒 Files selected for processing (9)
  • CHANGELOG/v4.md
  • docs/concepts/PHILOSOPHY.md
  • docs/user/COMMANDS.md
  • src/aelfrice/cli.py
  • src/aelfrice/deferred_feedback.py
  • src/aelfrice/store.py
  • tests/test_cli_sweep_feedback.py
  • tests/test_implicit_feedback.py
  • tests/test_implicit_feedback_age_correlation.py

@robotrocketscience robotrocketscience added ready-to-merge Trigger merge-train: FF main to this PR's head and removed attn:merge-conflict PR branch needs rebase labels Jul 31, 2026
@github-actions
github-actions Bot merged commit 54086da into main Jul 31, 2026
36 of 37 checks passed
@github-actions

Copy link
Copy Markdown

merge-train: merged 54086damain via FF push.

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

Labels

author-garsecg PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant