feat(onboard): rejection ledger so re-runs don't re-classify rejected sentences (#801) - #806
Conversation
Adds a per-DB ledger keyed on the same `belief_id` SHA used by `beliefs.id`. Rejections and accepted beliefs share a key space so a later persist=True moves the row out of this table and into `beliefs`. Store methods (insert/delete/is_/list_/count_) follow the existing onboard_sessions surface. Forward-compat: CREATE TABLE IF NOT EXISTS leaves prior schemas untouched and the table starts empty on existing stores.
On host verdict persist=False, record `(belief_id, text, source, ts)` into onboard_rejections so the next emit does not re-classify the same sentence. On persist=True, delete any prior ledger row for that belief_id — a rejected-then-accepted sentence (e.g. via --force) leaves the ledger when its canonical belief lands in `beliefs`. belief_id is computed with the same _derive_belief_id() the dedup path in start_onboard_session uses, so the rejection key matches the filter key without waiting for the worker to assign a canonical id.
start_onboard_session and check_onboard_candidates now load the rejection-ledger id set once per call and bucket candidates into already_present / already_rejected / new. Both result dataclasses carry a new `n_already_rejected` field (default 0, additive for existing callers). `force=True` (kwarg, default False) bypasses the ledger so a re-run re-emits previously rejected sentences. Already-present beliefs stay filtered either way — force only opts back in to noise the classifier previously discarded, not to content the store already holds.
`aelf onboard <path> --force` (works with --emit-candidates and --check) bypasses the rejection ledger so previously rejected candidates re-emit. Default stays ledger-on, so the common case is the new no-op behavior the issue asks for. `--check` output gains an `already rejected: <N> candidates` line above `new since last onboard:`. The slash command's `n_new == 0` short-circuit (onboard.md step 2) still works as-is — rejected sentences just move from `new` into the new bucket. `--emit-candidates` JSON gains `n_already_rejected`. Existing keys unchanged.
New tests/test_onboard_rejection_ledger.py (16 cases) covers store CRUD, accept_classifications writes/deletes ledger rows, emit + check honor the ledger, force=True bypasses it, and the recorded repro (pass-2 emits 0 candidates after pass-1 rejects all). tests/test_cli_onboard_handshake.py gains four cases for the CLI surface: emit-candidates JSON exposes n_already_rejected, --check reports the new bucket, --force re-emits, and --check --force notes the ledger bypass.
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 Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (5)
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
PR-size soft capThis PR is over the advisory size threshold:
Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the |
|
[claim:review:sankara:2026-05-14T18:52:27Z] |
|
LGTM. Six atomic signed commits, all CI green, mergeable, discretion-clean, 24 new tests including the recorded repro (pass-2 → 0 candidates after rejecting pass-1). Ledger keyed on the same Two non-blocking observations, both follow-up-shaped:
Approving and adding |
|
[release:review:sankara:2026-05-14T18:55:05Z] |
|
merge-train: merged 4b22eeb → |
Summary
Closes #801.
aelf onboard <path>historically re-classified everysentence the host had previously rejected with
persist=False, becauserejected sentences are never stored as beliefs and the dedup filter in
start_onboard_sessiononly consultsbeliefs.id. The recorded reproin the issue: pass 1 emits 2276 candidates → 723 rejected; pass 2 emits
726 candidates ≈ the 723 rejects + a few new files; pass 3 the same
again. Each pass burns one classification round-trip per re-rejected
sentence.
This PR persists
(belief_id, text, source, rejected_at)rows in a newonboard_rejectionstable fromaccept_classificationswhenever thehost returns
persist=False, andstart_onboard_session+check_onboard_candidatesfilter against that table the same way theyalready filter against
beliefs.aelf onboard --forcebypasses theledger so the operator can re-roll a prior pass they think was too
aggressive.
Behavior change
--force--forceafter pass-1 acceptThe slash command's
n_new == 0short-circuit (onboard.mdstep 2)keeps working because rejected sentences move out of
n_newinto thenew
n_already_rejectedbucket — no slash-command change needed.Files changed
src/aelfrice/store.py—onboard_rejectionstable in_SCHEMA(forward-compat via
CREATE TABLE IF NOT EXISTS); five newMemoryStoremethods (insert_onboard_rejection,delete_onboard_rejection,is_onboard_rejected,list_onboard_rejection_ids,count_onboard_rejections).src/aelfrice/classification.py—start_onboard_sessionandcheck_onboard_candidatesgain aforce: bool = Falsekwarg, bucketcandidates into
already_present / already_rejected / new, andreturn a new
n_already_rejectedfield on both result dataclasses(default 0, additive for existing callers).
accept_classificationswrites a ledger row on
persist=Falseand deletes any prior row onpersist=Trueso a rejected-then-accepted sentence leaves the ledger.src/aelfrice/cli.py—aelf onboard --forceflag;--checkoutputgains an
already rejected:line;--emit-candidatesJSON gainsn_already_rejected.tests/test_onboard_rejection_ledger.py— 16 new tests coveringstore CRUD, accept-time ledger writes/deletes, emit + check filter
behaviour, the
force=Truebypass, and the recorded repro.tests/test_cli_onboard_handshake.py— 4 new CLI cases for theflag and the new output fields.
CHANGELOG.md— Unreleased / Fixed entry.Design notes
belief_idderivation is shared between the rejection ledger and theexisting dedup path (
_derive_belief_id(text, source)=sha256(source\0text)[:16]), so the filter key matches the write keywithout waiting for
run_workerto assign a canonical id. The tableis per-project (lives in the same
brain.dbasbeliefs), so the"per-project rejection memory" semantics fall out for free.
--forceonly re-opens the noise lane. Already-present beliefs stayfiltered, by design — the issue's
--forceproposal is aboutre-rolling a prior classification verdict, not duplicating stored
content. Verified by a dedicated test
(
test_already_present_still_filtered_under_force).Test plan
uv run pytest -x -q→ 4183 passed, 62 skipped, 75 xfailed.all → re-emit
--forcereturns the original N; reject then--forceaccept → ledger drops to 0.github/mainclean.Alternatives considered
ingest_logwith arejected=1flag. Rejected sentencesare not ingests — they were never written to the belief substrate —
so a separate table keeps the ingest-log audit trail clean.
alternative). Rejected because it blocks the legitimate "I added new
docs, re-onboard the directory" case unless
--forceis used; theledger handles incremental onboarding naturally.
dropped —
--forcealready covers "I want to re-roll because theprior pass was too aggressive". Adding a version column means
arguing about what counts as a version change; the simple flag is
enough.
Out of scope
and fills as new rejections happen. No retroactive promotion of
already-rejected content from prior runs (those are lost; they will
be re-rejected on the next pass and then stick).
the rejected lane until
--forceaccepts a row. If this becomes asize issue, a follow-up can add
aelf onboard --clear-rejectionsor an age-based GC. Today the bound is "everything that ever got a
persist=Falseverdict", which is finite per repo.