Skip to content

perf(hot-path): persistent BM25F index, flat bulk ingest, batched write groups (#1135) - #1140

Merged
github-actions[bot] merged 14 commits into
mainfrom
perf/issue-1135-hot-path
Jul 21, 2026
Merged

perf(hot-path): persistent BM25F index, flat bulk ingest, batched write groups (#1135)#1140
github-actions[bot] merged 14 commits into
mainfrom
perf/issue-1135-hot-path

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Closes #1135.

Implements the measured perf audit structurally — pure performance, no ranking change; retrieval outputs are byte-identical on a fixed store.

P1

1. BM25F index persisted across processes (was: rebuilt per retrieve()).

  • New durable store_generation counter in schema_meta, bumped inside the same transaction as every belief/edge content mutation (exactly the 11 _fire_invalidation sites, now _commit_mutation()). Feedback/touch/corroboration writes do not bump it — they change ranking inputs, not index content. Seed is read-first so store opens stay lock-free.
  • BM25IndexCache.get() loads a <db-path>.bm25f sidecar when its generation + scope-id stamp and k1/b/anchor_weight all match; otherwise builds and atomically rewrites it (os.replace, fail-soft both directions, :memory: skips persistence). The scope-id in the stamp catches a different DB swapped in at the same path.
  • serialize() format bumped to v2: k1/b/avgdl widened float32 → float64 so a loaded index scores byte-identically to a built one (the float32 round-trip perturbed low-order score bits, which could flip near-ties). No v1 blobs exist anywhere — nothing called serialize() before this PR.
  • retrieve()'s fallback cache is now store-scoped and reused: fixes the pre-existing leak of one invalidation-callback subscription per query on long-running processes (MCP server), which also rebuilt per query.
  • Chose the sidecar filename <db-path>.bm25f over the issue's <db-dir>/bm25f.idx so two DB files in one directory can't share a sidecar.

2. Bulk ingest de-quadratified.

  • WorkerResult gains outcomes: {log_id: (belief_id, was_inserted)}; _ingest_turn_ids reads its rows' fate from there instead of snapshotting set(list_belief_ids()) per turn and re-reading each log row. Fallback to the log row covers the sibling-process race (a row stamped elsewhere is by definition not a brand-new insert of ours).
  • Partial index idx_ingest_log_unstamped ON ingest_log(id) WHERE derived_belief_ids IS NULL — the log grows monotonically while the unstamped set stays tiny. list_unstamped_ingest_log also names its columns instead of SELECT *.

3. Commit-per-row churn.

  • New MemoryStore.transaction() context manager: per-call commits suppressed via a depth counter, one commit at outermost exit, invalidation callbacks deferred to a single post-commit fire (so derived caches never read uncommitted state), full-group rollback on exception. Reentrant.
  • Wired into: the ingest turn (~8 commits → 1; a turn is now crash-atomic — no unstamped orphans), record_retrieval (apply_feedback per hit + stamp), the injection-events and touches loops, and the deferred-feedback enqueue (~45–60 commits per hook prompt with hits → a handful).
  • PRAGMA synchronous=NORMAL set alongside WAL (the documented pairing).

P2

  • Partial index for list_locked_beliefs (ran up to 3× per retrieve as a full-table scan), matching its ORDER BY; index on edges(type) for the has_edge_type probe that fires per non-empty query since the Temporal spine: ingest-time chronological edges + dedicated retrieval lane (confirmed +14.6pp coverage on LoCoMo) #1064 lane flip. Tests assert the planner actually uses each index (EXPLAIN QUERY PLAN), not just that they exist.
  • UPS hook opens the store once per prompt and threads the handle through the relevance sweep, retrieval, injection events, touches, and session-start block. Every helper keeps its legacy self-open fallback (callers/tests unchanged; a failed shared open degrades per-helper). Default-off lanes (sentiment, category boost, phantom blocks) unchanged.
  • .aelfrice.toml parse memoized per (path, mtime_ns, size). The directory walk still runs per call, so config creation/deletion/move is honoured; only the read + tomllib parse is cached. Malformed-file stderr traces now print once per file version instead of ~24× per retrieve.
  • The v1.2 origin backfill (_BACKFILL_STATEMENTS, two full-table UPDATEs) is now a schema_meta-gated one-shot. Contemporary writers set origin explicitly (derive() routes, cli lock upgrade), so the flip only ever mattered once per legacy DB.

Acceptance

  • BM25F index persisted (sidecar) + process-cached (store-scoped); hook + MCP paths no longer rebuild per query
  • bulk ingest per-turn cost flat w.r.t. corpus size (no full-table scans per turn)
  • write groups batched; synchronous=NORMAL set alongside WAL
  • P2 indexes added; single store open per hook invocation; TOML memoized
  • no pytest regression; retrieval byte-identity: the sidecar path is covered by a dedicated score-equality test (test_loaded_index_scores_identical_to_built) and the serialize v2 float64 widening exists precisely to keep it exact

Out of scope (the issue's "correctness smells" section)

Left for operator disposition per the issue's own framing ("may deserve their own issues") — not touched here:

  1. retrieval.py int(time.time()) inside retrieve_with_tiers (clock seam, same class as bug: test_retrieve_v2_temporal_sort_explicit_half_life_kwarg is a wall-clock time-bomb (main red ~2026-06-22, blocks all PR gates) #982)
  2. RetrievalCache.retrieve skipping exposure/feedback side effects on cache hits
  3. telemetry JSONL ring read-all→rewrite with no inter-process lock (session_ring got flock; telemetry didn't)

Notes for review

  • The known local flakes reproduced on this machine during development and were verified pre-existing against pristine main sources: test_search_tool_hook_bash 5s-timeout pair (BM25 build under load — the exact cost item 1 addresses) and the doctor ambient-store flake. CI is authority.
  • One behavioral nuance in ingest's sibling-race fallback: a row stamped by a concurrent process counts as not-inserted (previously it could count as inserted). Both old and new behavior are wrong in one rare sub-case each; the new choice under-counts instead of over-counting and is documented at the fallback site.

Summary by CodeRabbit

  • Performance

    • Faster retrieval and ingestion, especially for larger datasets.
    • Reduced startup and repeated processing overhead through improved caching.
    • Retrieval results remain consistent across sessions.
  • Reliability

    • Batched operations now complete atomically, reducing partial updates if an error occurs.
    • Improved handling of concurrent changes and corrupted cached data.
  • Data Migration

    • Legacy data upgrades now apply missing metadata corrections safely and only once.

@robotrocketscience robotrocketscience added the author-Kulili PR coordination mutex label Jul 21, 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 21, 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

Run ID: 8d08472f-6528-495d-acc6-5b7d9f0d3a59

📥 Commits

Reviewing files that changed from the base of the PR and between febd7b2 and d603cd2.

📒 Files selected for processing (17)
  • CHANGELOG/v4.md
  • src/aelfrice/bm25.py
  • src/aelfrice/deferred_feedback.py
  • src/aelfrice/derivation_worker.py
  • src/aelfrice/hook.py
  • src/aelfrice/hook_search.py
  • src/aelfrice/ingest.py
  • src/aelfrice/migrate.py
  • src/aelfrice/retrieval.py
  • src/aelfrice/store.py
  • tests/test_bm25_sidecar.py
  • tests/test_derivation_worker.py
  • tests/test_retrieval_toml_memo.py
  • tests/test_store_hot_path_indexes.py
  • tests/test_store_transaction.py
  • tests/test_v1_to_v1x_migration.py
  • tests/test_worktree_concurrency.py
📝 Walkthrough

Walkthrough

This change adds persistent BM25F sidecars, durable store-generation invalidation, transactional write batching, outcome-based ingestion, shared prompt store handles, targeted indexes, TOML memoization, and marker-gated origin migration behavior.

Changes

Retrieval and ingest hot-path overhaul

Layer / File(s) Summary
Store generations and transaction batching
src/aelfrice/store.py
MemoryStore batches nested writes, defers invalidation, tracks durable generations, uses WAL with synchronous=NORMAL, adds hot-path indexes, and routes mutations through unified commit helpers.
Persistent BM25F and retrieval configuration
src/aelfrice/bm25.py, src/aelfrice/retrieval.py
BM25F serialization uses float64 fields, sidecars are validated by store generation and parameters, store-scoped caches are reused, and retrieval TOML parsing is memoized.
Outcome-based transactional ingest
src/aelfrice/derivation_worker.py, src/aelfrice/ingest.py
Workers report per-log belief outcomes, allowing per-turn transactional ingest to compute returned ids, inserted beliefs, and derived edges without corpus snapshots.
Shared prompt-store and batched feedback writes
src/aelfrice/hook.py, src/aelfrice/hook_search.py, src/aelfrice/deferred_feedback.py
Prompt processing reuses one store handle and batches retrieval, touch, injection, exposure, and feedback writes in transactions.
Migration behavior and validation
src/aelfrice/migrate.py, tests/*, CHANGELOG/v4.md
Legacy origin conversion is gated by a completion marker, and tests cover caching, transactions, indexes, worker outcomes, memoization, migration idempotence, and SQLite settings.

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

Sequence Diagram(s)

sequenceDiagram
  participant UserPromptSubmit
  participant MemoryStore
  participant Retrieval
  participant BM25IndexCache
  participant FeedbackWriters
  UserPromptSubmit->>MemoryStore: open shared store
  UserPromptSubmit->>Retrieval: retrieve using shared store
  Retrieval->>BM25IndexCache: request BM25F index
  BM25IndexCache->>MemoryStore: validate store generation
  BM25IndexCache-->>Retrieval: return cached or rebuilt index
  Retrieval-->>UserPromptSubmit: return beliefs
  UserPromptSubmit->>FeedbackWriters: record prompt side effects
  FeedbackWriters->>MemoryStore: commit batched transaction
Loading

Possibly related PRs

Suggested labels: attn:review

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it misses the required template sections like Summary, Linked issues, Type of change, Verification, Test plan, and Notes. Restructure the PR text to match the repo template and add the missing sections, especially Linked issues, Type of change, Verification, Test plan, and Notes for reviewer.
Docstring Coverage ⚠️ Warning Docstring coverage is 67.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main performance work: persistent BM25F caching, flatter bulk ingest, and batched writes.
Linked Issues check ✅ Passed The code changes match #1135's main goals: BM25F persistence, flat ingest scans, transaction batching, hot-path indexes, and single-handle hook usage.
Out of Scope Changes check ✅ Passed No clear out-of-scope code changes stand out; the migration, tests, and changelog all support the performance audit objectives.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/issue-1135-hot-path

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.

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jul 21, 2026
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

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

Comment thread tests/test_store_transaction.py
Comment thread tests/test_store_transaction.py
Comment thread src/aelfrice/bm25.py
Comment thread src/aelfrice/bm25.py
Comment thread src/aelfrice/hook.py
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Gylf:2026-07-21T18:54:19Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Kulili:2026-07-21T18:57:52Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Kulili:2026-07-21T18:57:58Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Reviewed the full diff (all 12 commits, ~1.6k lines) against main.

Verdict: approve, with one fix pushed to the branch (same-account, so verdict-by-comment; will queue ready-to-merge once CI is green on the new head).

What I verified:

  • Generation counter correctness: _commit_mutation covers exactly the sites that fired invalidation before — including the ones I specifically went looking for (soft_delete_belief / restore_belief, so a retired belief can't survive in a stale sidecar). Feedback/touch/corroboration writes correctly do not bump (ranking inputs, not index content). Seed-at-open is read-first with OR IGNORE for the first-ever race; missing key degrades to 0 = today's behavior.
  • transaction() semantics: depth-counted, rollback only at depth zero, invalidation deferred to post-commit and cleared on rollback (tests pin all four properties). The swallowed-inner-exception caveat is documented at the definition. No remaining direct self._conn.commit() calls bypass the batching — the two in store.py are the transaction() internals, and the one in deferred_feedback.py is the pre-existing operator-run sweeper (BEGIN IMMEDIATE per row), which is never called inside a group.
  • Sidecar framing: scope-id catches DB-swap-at-same-path, stamp-before-build makes a mid-build mutation conservatively stale, os.replace keeps concurrent readers untorn, everything fail-soft. The serialize v2 float64 widening + test_loaded_index_scores_identical_to_built back the byte-identity claim.
  • Ingest rework: outcomes-based fate tracking preserves the pre-[v2.x] Derivation worker — beliefs become materialized state #264 inserted-count contract without the per-turn full-table snapshot; the sibling-race fallback's under-count choice is documented and is the right direction. The turn transaction also picks up any other unstamped rows via run_worker — safe, since rollback leaves them re-derivable and the worker is idempotent.
  • migrate origin catch-up: the conversion-time mapping (locked-unknown → user_stated, correction-unknown → user_corrected) matches _BACKFILL_STATEMENTS semantics exactly, closing the gap the one-shot gating opened.
  • hook.py threading: shared handle used as-is and left open; every helper keeps its legacy self-open fallback; finally: close().
  • Indexes: partial-index predicates match their queries and the tests assert the planner actually uses them (EXPLAIN QUERY PLAN), not just existence. migration-policy-check green.
  • Content-scan on added lines: clean. CI fully green on the pre-fix head, including both pytest matrices and bench-smoke.

The fix I pushed (febd7b2): the store-scoped cache + sidecar made the index survive across queries, but get() never revalidated the durable generation once an index was resident — the in-process invalidation callback only covers own-process mutations. So a long-running process (the MCP server) holding the cache would never see sibling-process writes (the default-on transcript/commit ingest hooks are exactly that), serving a stale L1 indefinitely. Pre-#1135 behavior rebuilt per query, so this was a real freshness regression on a shipped surface. The fix is one indexed schema_meta point-read per get(): on mismatch the cache drops the index and re-loads the (possibly sibling-refreshed) sidecar before falling back to a build. New test test_resident_cache_sees_sibling_process_writes pins it with two handles on one DB. Locally: sidecar + transaction + full bm25/retrieval selection green (144 passed).

Two nits, no action needed:

  1. _load_sidecar's comment "k1/b round-trip through float32 in the blob" is stale — v2 writes them as float64; the float32-compare is now just a tolerance choice. Harmless.
  2. The PR's out-of-scope list (clock seam in retrieve_with_tiers, cache-hit side-effect skip, telemetry-ring lock) loses its tracking surface when perf(hot-path): BM25F index rebuilt per retrieve(); O(n^2) bulk-ingest scans; commit-per-row churn #1135 auto-closes on merge. Operator may want those either re-filed or explicitly waived — flagging so the disposition is a decision, not an accident.

Resolved the five CodeQL threads: the two "unreachable" statements are the rollback tests' post-raise guards (deliberate), the BaseException handler in _write_sidecar is cleanup-and-reraise, and the empty excepts are the documented best-effort unlink paths.

@robotrocketscience robotrocketscience added ready-to-merge Trigger merge-train: FF main to this PR's head and removed attn:review Needs review (PR open, awaiting reviewer) labels Jul 21, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Gylf:2026-07-21T19:07:15Z]

@github-actions

Copy link
Copy Markdown

merge-train: blocked

branch is not fast-forward on main (branch base f9c3484e7beb07bd95dddf0e3aa6a121b725a8f1, current main a3bc41f50f453024b45e1447928351f9f9f297f2). Rebase locally (git rebase github/main), force-push, and re-add the label.

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

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 21, 2026
WAL was set at open but synchronous stayed at the FULL default, paying
a full fsync per commit. NORMAL is the documented WAL pairing: fsync at
checkpoint, not per commit; app crashes lose nothing. Measured ~2x
cheaper per-commit on the ingest write path.
#1135)

_BACKFILL_STATEMENTS (two full-table UPDATEs) ran unguarded on every
store open, unlike every other one-shot pass. Contemporary writers set
origin explicitly, so the flip only matters once per legacy DB. Marker
follows the entity-backfill convention; the pass rides the existing
open commit.
…an (#1135)

Three query shapes ran unindexed on every hot-path invocation:
- derivation worker's unstamped ingest_log scan (O(total log) per turn
  on a monotonically growing table) -> partial index on the NULL stamp
- list_locked_beliefs L0 tier (up to 3x per retrieve, full-table scan)
  -> partial index matching the ORDER BY
- has_edge_type() probe (per non-empty query since the #1064 lane
  flip; miss case scanned all edges) -> index on edges(type)

The unstamped scan also names its columns instead of SELECT *.
…et snapshot (#1135)

_ingest_turn_ids snapshotted set(store.list_belief_ids()) every turn
(full-table scan, linear in corpus size) solely to distinguish new
inserts from corroborations — a fact insert_or_corroborate already
returns. WorkerResult now carries outcomes: {log_id: (belief_id,
was_inserted)}; ingest reads its rows' fate from there, which also
removes the per-sentence get_ingest_log_entry re-read. Fallback to the
log row covers the sibling-process race (a row stamped elsewhere is
not a brand-new insert of ours). Bulk-ingest per-turn cost is now flat
w.r.t. corpus size.
…1135)

Every mutating method committed per call (~8 commits per ingested
turn, ~45-60 per hook prompt with hits). transaction() suppresses the
per-call commits via a depth counter and issues one commit at
outermost exit; measured 33x cheaper than commit-per-row for a
200-insert group. Invalidation callbacks defer to one post-commit fire
so derived caches never read uncommitted state; exceptions roll the
whole group back. No call sites wired yet - behavior outside
transaction() is byte-identical.
…ns (#1135)

Wire store.transaction() into the four commit-per-row hot spots:
- ingest turn (record_ingest per sentence + worker insert/stamp per
  row + edge inserts): one write group per turn, which also makes the
  turn atomic — a crash mid-turn leaves no unstamped orphans
- hook record_retrieval (apply_feedback per hit + stamp_retrieved):
  one group per retrieval; an un-committable group (closed handle)
  reports 0 rows written, preserving the non-blocking contract
- hook injection_events + belief_touches loops: one commit per batch
- deferred-feedback exposure enqueue: one commit per batch

transaction()'s rollback path no longer masks the original exception
when the rollback itself fails on a dead handle.
…1135)

BM25F is default-ON but no caller passed a bm25f_cache, so every
retrieve() re-tokenized + Porter-stemmed the whole corpus and rebuilt
the CSR matrix (584 ms at 5k beliefs; the UPS hook is a fresh process
per prompt, and even the MCP server rebuilt per query while leaking
one invalidation callback per call).

- MemoryStore gains a durable store_generation counter, bumped inside
  the same transaction as every belief/edge content mutation (the 11
  _fire_invalidation sites, now _commit_mutation). Feedback/touch
  writes don't bump it - they change ranking inputs, not index
  content. Seed is read-first so opens stay lock-free.
- BM25IndexCache.get() loads <db-path>.bm25f when its generation +
  scope-id stamp and k1/b/anchor_weight match, else builds and
  atomically rewrites it (os.replace; fail-soft both ways; :memory:
  skips persistence).
- serialize() v2 widens k1/b/avgdl to float64: a loaded index now
  scores byte-identically to a fresh build (float32 round-trip
  perturbed low-order score bits). No v1 blobs exist - nothing called
  serialize() before this.
- retrieve() reuses one store-scoped cache instead of constructing a
  fresh one per call.
~24 resolver call sites fall through to the TOML rung per retrieve(),
each re-reading and re-parsing the file. The directory walk (stat
calls) still runs per call so created/deleted config files are
honoured, but the read + tomllib parse is cached per file until its
mtime_ns/size changes. Malformed-file stderr traces now print once
per file version instead of once per resolver call.
The UPS process opened MemoryStore 4-6 times per prompt (relevance
sweep, retrieval, injection events, touches, plus session-start on
first prompt), each replaying the schema battery (~25-40 ms/prompt of
pure re-open). user_prompt_submit now opens one handle after payload
parse and threads it through those helpers; each keeps its legacy
self-open fallback for direct callers and tests, and the default-off
lanes (sentiment, category boost, phantom blocks) are unchanged. A
failed shared open degrades to the fallback path, preserving
per-helper fail-softness.
…sion (#1135)

The catch-up used to be re-applied by the target store on every open;
now that it is a marker-gated one-shot, the target's marker is already
stamped (on an empty store) before legacy rows land, so migrated
locked/correction rows stayed at origin=unknown. Apply the same
mapping in _read_legacy_beliefs — migrate output is unchanged from
pre-#1135 (regression: test_apply_preserves_origin_from_legacy_row,
#224 contract).
…get() (#1135)

The in-process invalidation callback only covers own-process
mutations, so a resident process (MCP server) holding the
store-scoped cache would never observe sibling-process writes
(default-on ingest hooks) — a freshness regression vs the pre-#1135
rebuild-per-query behavior. One indexed schema_meta point-read per
get(); on mismatch the cache drops the index and reloads the (possibly
sibling-refreshed) sidecar before rebuilding.
@robotrocketscience
robotrocketscience force-pushed the perf/issue-1135-hot-path branch from febd7b2 to 2592c2f Compare July 21, 2026 19:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

318-349: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Confirm the intended failure semantics of moving the optional substrate writers inside the per-turn transaction.

write_semantic_edges and write_temporal_spine (the latter default-ON per is_temporal_spine_write_enabled) now execute inside store.transaction(). Because the outer block rolls back the whole group on any exception, a failure in either optional writer will now discard this turn's primary belief/log rows as well and propagate to the ingest_turn caller. Pre-batching, those primary rows committed per-row before the substrate writers ran, so a substrate bug degraded only the optional edges — not core ingestion.

If the intent is that a secondary-substrate failure should not block primary ingest, consider isolating these calls so their failure can't abort the turn:

♻️ Optional: shield primary ingest from optional-writer failures
         if inserted:
             from aelfrice.relationship_detector import (
                 is_auto_relationship_detection_enabled,
                 write_semantic_edges,
             )
-
-            if is_auto_relationship_detection_enabled():
-                write_semantic_edges(store, new_belief_ids=inserted)
+            try:
+                if is_auto_relationship_detection_enabled():
+                    write_semantic_edges(store, new_belief_ids=inserted)
+            except Exception:
+                traceback.print_exc()

If the atomic all-or-nothing turn (relying on idempotent re-ingest to recover) is deliberate, this is fine as-is — please confirm.

🤖 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/ingest.py` around lines 318 - 349, Decide and document the
intended failure semantics for the optional writers in the ingest transaction.
If primary ingestion must remain successful when write_semantic_edges or
write_temporal_spine fails, isolate each call from the outer store.transaction
rollback and preserve the exception handling policy; otherwise, explicitly
confirm that ingest_turn intentionally propagates writer failures and rolls back
the primary belief and log rows atomically.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_bm25_sidecar.py`:
- Around line 123-130: Update the persisted-parameter validation in
BM25IndexCache._load_sidecar to compare k1 and b at full float64 precision
before accepting a sidecar, without narrowing to np.float32. Extend
test_anchor_weight_mismatch_rejects_sidecar coverage to vary each parameter
independently using math.nextafter and assert the sidecar is rejected.

---

Nitpick comments:
In `@src/aelfrice/ingest.py`:
- Around line 318-349: Decide and document the intended failure semantics for
the optional writers in the ingest transaction. If primary ingestion must remain
successful when write_semantic_edges or write_temporal_spine fails, isolate each
call from the outer store.transaction rollback and preserve the exception
handling policy; otherwise, explicitly confirm that ingest_turn intentionally
propagates writer failures and rolls back the primary belief and log rows
atomically.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 659dbfd7-16a5-4a31-91ab-322356ca2088

📥 Commits

Reviewing files that changed from the base of the PR and between f9c3484 and febd7b2.

📒 Files selected for processing (17)
  • CHANGELOG/v4.md
  • src/aelfrice/bm25.py
  • src/aelfrice/deferred_feedback.py
  • src/aelfrice/derivation_worker.py
  • src/aelfrice/hook.py
  • src/aelfrice/hook_search.py
  • src/aelfrice/ingest.py
  • src/aelfrice/migrate.py
  • src/aelfrice/retrieval.py
  • src/aelfrice/store.py
  • tests/test_bm25_sidecar.py
  • tests/test_derivation_worker.py
  • tests/test_retrieval_toml_memo.py
  • tests/test_store_hot_path_indexes.py
  • tests/test_store_transaction.py
  • tests/test_v1_to_v1x_migration.py
  • tests/test_worktree_concurrency.py

Comment thread tests/test_bm25_sidecar.py
@github-actions

Copy link
Copy Markdown

merge-train: blocked

1 review thread(s) are unresolved on these files: tests/test_bm25_sidecar.py. Resolve them on the PR (click 'Resolve conversation' on each) and re-add the label.

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

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 21, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Out-of-scope smells from #1135's audit section have been dispositioned by the operator: all three re-filed as issues — #1143 (clock seam in retrieval), #1144 (cache-hit exposure skip), #1145 (telemetry ring lock + the pytest-3.13 ring-cap timeout flake). Nothing further tracked against this PR.

The v2 sidecar serialises k1/b as float64, so the round-trip is exact
and the float32-narrowed comparison could let two configs that differ
only below float32 precision share a sidecar. Compare exactly and pin
with a nextafter mismatch test for each parameter.
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Addressed the k1/b precision thread in d603cd2: the v2 sidecar stores k1/b as float64 so the round-trip is exact — the float32-narrowed comparison was legacy from v1 and is now a full-precision compare, pinned by nextafter mismatch tests for both parameters. Will re-add ready-to-merge when CI is green on the new head.

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

Copy link
Copy Markdown

merge-train: merged d603cd2main via FF push.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 21, 2026
@github-actions
github-actions Bot merged commit d603cd2 into main Jul 21, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(hot-path): BM25F index rebuilt per retrieve(); O(n^2) bulk-ingest scans; commit-per-row churn

2 participants