Skip to content

feat(text): v3-1 FTS hardening — upsert / count / routing (final 3 tasks)#192

Merged
pilotspacex-byte merged 3 commits into
mainfrom
feat/v3-1-fts-remaining
Jun 16, 2026
Merged

feat(text): v3-1 FTS hardening — upsert / count / routing (final 3 tasks)#192
pilotspacex-byte merged 3 commits into
mainfrom
feat/v3-1-fts-remaining

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

v3-1 FTS hardening — final 3 tasks (upsert / count / routing)

Completes the v3-1-fts-hardening milestone. Stacked work #189/#190 already
merged to main, so this branches cleanly off main (no stack).

All five milestone exit criteria are now delivered (high-DF O(M²) cliff and the
OR/combinator/count fixes landed in #190; this PR closes the remaining three):

Commit Task Fix
8e6488d fts-upsert-incremental Re-indexing a doc (HSET upsert / bulk pass) called remove_doc, which scanned every term's posting — O(total-vocabulary) per doc (the ~376 vs ~18,052 docs/s indexing cliff). Adds a reverse doc_id → term_ids index so remove_doc visits only the doc's own terms — O(terms-in-doc), independent of V. Search output byte-identical.
93f0ada fts-search-count-semantics The FT.SEARCH integer reply (match count) was capped at the LIMIT/top_k page size, and the multi-shard coordinator counted merged-and-truncated docs. Now reports the true matched, key-resolvable count (RediSearch semantics): evaluator surfaces the count before truncation; multi-shard merge sums each shard's local matched count (keys partition to one shard → exact; errored shards contribute 0). Returned pages unchanged.
4de1d6c fts-query-routing-robustness Three routing/panic-safety fixes: (R1) TextStore::search_field's 3× .expect("posting exists") on the BM25 AND/scoring path → defensive let-else (no server panic); (R2) is_text_query matched the bare substring KNN , so FT.SEARCH idx "knn tutorial" mis-routed to the KNN parser → ERR invalid KNN query syntax — now keys on the canonical [KNN bracket, so prose with "knn" searches as text while *=>[KNN …] still routes to the vector engine; (R3) a standalone SPARSE @field $param clause with a text-looking query string was silently dropped onto the text path — a has_sparse_clause guard now defers it to the vector engine at all six text-route gates. Output byte-identical for existing queries.

Method (ADD)

Each task ran the full specification bundle (spec → scenarios → frozen contract →
red tests → build → verify gate PASS). Tasks 2 and 3 use additive symbols
(eval_query_counted, build_text_response_with_total, has_sparse_clause) so
they never edit the frozen eval_query / build_text_response / is_text_query
contracts from the combinators work.

Verification

  • Full lib regression: 3597 passed / 0 failed / 1 ignored.
  • Tokio-runtime suite (--no-default-features --features runtime-tokio,jemalloc): green.
  • Clippy clean on default and runtime-tokio,jemalloc; cargo fmt --check clean.
  • 3 new routing unit tests (prose-knn is text, has_sparse_clause detects standalone,
    route predicate defers SPARSE); count-semantics unit + e2e tests over a corpus
    larger than the page; upsert incremental-removal tests.
  • Wire probes (1- and 4-shard): count is the true total; FT.SEARCH idx "knn" → text
    results (not a KNN error); canonical *=>[KNN …] still routes to the vector engine.

Carried follow-ups (open ADD deltas — not blockers)

  • A stale NUMERIC test, the dead scatter_text_search_filter path, and a multi-field-OR
    IDF approximation are tracked as open deltas for the milestone-close fold.

Closes the v3-1-fts-hardening milestone.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed FT.SEARCH routing to correctly treat "knn" as text content in prose searches
    • Fixed handling of standalone sparse clauses in text queries
    • Fixed potential crash in BM25 scoring when encountering missing index data
    • Fixed match count reporting to display true total matches instead of limited result size
  • Performance

    • Improved bulk re-index performance through optimized document removal

…al scan

`PostingStore::remove_doc` scanned every term's posting list to clear a single
document, so each HSET upsert / bulk re-index cost O(total-vocabulary) and the
indexing rate collapsed as the corpus grew (~376 vs ~18,052 docs/s). Add a
reverse `doc_id -> SmallVec<[term_id; 8]>` index, recorded exactly once per
(doc, term) edge on the new-doc branch of `add_term_occurrence`; `remove_doc`
now visits only the terms a document actually contributed — O(terms-in-doc),
independent of V — removing each at its rank-aligned index and erasing the
reverse entry.

Search output is byte-identical: the inherited rank-aligned PostingList contract
(term_freqs/positions sorted-doc-id aligned with the doc_ids bitmap) is
preserved, so matched doc sets, BM25 scores, doc_freq, num_docs and avgdl are
unchanged. Defensive on desync: an absent doc is a no-op returning an empty Vec,
and a stale reverse entry (posting missing or no longer holding the doc) is
skipped — no unwrap/expect/panic on the removal path.

Tests (src/text/mod.rs): 6 unit tests covering reverse-map population (unique
terms), removal via the reverse map, absent-doc no-op, stale-entry skip, memory
reclamation across repeated upserts, and byte-identical posting state after
index->upsert->delete vs a fresh build of the final state; plus a #[ignore]'d
scaling proof (V~40000 removal < 20x V~20; old impl ~2000x). Lib regression
3590 passed / 0 failed; clippy clean on default + runtime-tokio,jemalloc.

ADD: v3-1-fts-hardening / fts-upsert-incremental — verify gate PASS.

author: Tin Dang
…semantics)

The integer match count in an FT.SEARCH reply was capped at the LIMIT/top_k page
size. `build_text_response` reported `results.len()`, but the result vector had
already been truncated to `top_k = offset + count` by `eval_query`, so
`FT.SEARCH idx "term" LIMIT 0 5` over 100 matches replied `5` instead of `100`.
The multi-shard DFS coordinator compounded it: `merge_text_results` counted the
merged-and-truncated returned docs, discarding each shard's true matched count.

Surface the true count from the evaluator (it already builds the full matched set
before truncating) WITHOUT editing the frozen fts-query-eval-dispatch contract:
add `eval_query_counted` returning (top_k page, pre-truncation matched-and-
resolvable count); `eval_query` is now its `.0` wrapper. Add
`build_text_response_with_total` carrying an explicit reply[0]; the 3-arg
`build_text_response` delegates with `results.len()` (legacy behavior for the
off-live-path callers). `run_text_query_on_index` threads the true total;
`merge_text_results` sums each shard's items[0] (keys partition to one shard, so
the sum is exact) and tolerates errored/odd frames (contribute 0, never panic).

A matched doc with no doc_id_to_key entry is unreturnable, so it is excluded from
both the page and the total (count = matched AND resolvable).

Tests: 4 unit (ft_text_search) for the response builder + merge sum + errored-
shard tolerance; 3 e2e (fts_query_eval_e2e) for eval_query_counted, single-shard
LIMIT, and no-LIMIT no-regression. Over-the-wire probe: 1-shard and 4-shard
servers both report total=30 under LIMIT 0 5 (returned=5), parity confirmed. Full
lib regression 3594 passed / 0 failed; clippy clean on default + tokio,jemalloc.

ADD: v3-1-fts-hardening / fts-search-count-semantics — verify gate PASS.

author: Tin Dang
…one SPARSE, no AND-path panic

Three routing/panic-safety fixes on the FT.SEARCH classification path. No
result, score, key, or ordering change on any query that already worked.

R1 (panic-safety): TextStore::search_field carried three
`.expect("posting exists: checked above")` on the BM25 AND + scoring path.
Replace each with defensive control flow — a missing AND-term posting returns
an empty result set; a missing posting during scoring skips that term's
contribution. The upstream is_none guard keeps these branches unreachable
today, so output is byte-identical; this removes a latent server panic if that
guard is ever refactored away.

R2 (text mis-routed to vector): is_text_query uppercased the query then matched
the bare substring "KNN ", so `FT.SEARCH idx "knn tutorial"` was classified
non-text and fell through to the case-sensitive KNN parser, returning
`ERR invalid KNN query syntax`. Key on the canonical `[KNN` bracket instead:
prose containing "knn" is text; `*=>[KNN ...]` stays non-text and routes to the
vector engine. The one unit assertion encoding the old over-aggressive
detection (`!is_text_query(b"knn 10")`) is updated to the corrected semantics.

R3 (vector mis-routed to text, user-approved scope): is_text_query only sees
args[1], so a standalone `SPARSE @f $p` clause with a text-looking query string
took the text fast-path and silently dropped the sparse retriever. Add
`has_sparse_clause` and AND `!has_sparse_clause(args)` into all six text-route
gates (monoio/sharded multi-shard + single-shard re-check, single-threaded,
spsc) so any SPARSE-carrying query defers to ft_search. SPARSE-inside-HYBRID was
already deferred by parse_hybrid_modifier and is unchanged.

Tests: 3 new unit tests (is_text_query prose-knn is text, has_sparse_clause
detects standalone, the route predicate defers SPARSE); the canonical-KNN and
bare-`*` non-text cases are retained. search_field no-expect/unwrap confirmed by
source audit; output-identity by the full FT regression. Wire probe:
`FT.SEARCH idx "knn"` -> 2, `"knn tutorial"` -> 1 (AND), neither errors as KNN;
canonical `*=>[KNN ...]` still routes to the vector engine. Full lib regression
3597 passed / 0 failed / 1 ignored; clippy clean on default and
runtime-tokio,jemalloc.

ADD: v3-1-fts-hardening / fts-query-routing-robustness — verify gate PASS.

author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Three FT.SEARCH improvements are shipped together: (1) routing robustness — is_text_query narrowed to [KNN bracket form, new has_sparse_clause guard added to all handler routing sites, and search_field posting lookups made panic-safe; (2) count semantics — eval_query_counted exposes pre-truncation match totals propagated through build_text_response_with_total and multi-shard merge_text_results; (3) incremental upsert — PostingStore gains a doc_terms reverse map, replacing the O(V) remove_doc scan with an O(terms-in-doc) traversal.

Changes

FT.SEARCH routing robustness, count semantics, and incremental upsert

Layer / File(s) Summary
PostingStore doc_terms reverse map and rewritten remove_doc
src/text/posting.rs, src/text/mod.rs, .add/tasks/fts-upsert-incremental/TASK.md
PostingStore adds a doc_terms: HashMap<u32, SmallVec<[u32;8]>> reverse index populated in add_term_occurrence; remove_doc is rewritten to consult doc_terms instead of scanning all postings, with defensive stale-entry skipping. Test helpers and six new unit tests cover uniqueness, removal, no-op on absent doc, stale skip, reclamation, and posting-state identity.
eval_query_counted and module re-export
src/text/query/eval.rs, src/text/query/mod.rs
eval_query_counted is introduced to return (results, total_matched) where total_matched is captured before top_k truncation; eval_query becomes a thin wrapper. The function is re-exported from the query module.
True matched-count propagation through response builder and multi-shard merge
src/command/vector_search/ft_text_search.rs, .add/tasks/fts-search-count-semantics/TASK.md
run_text_query_on_index switches to eval_query_counted and passes the total to the new build_text_response_with_total, which writes total_matched into reply[0]. merge_text_results now sums per-shard items[0] counts, treating errored frames as 0, replacing the previous all_results.len() assignment.
is_text_query fix, has_sparse_clause helper, and panic-safe search_field
src/command/vector_search/ft_text_search.rs, src/command/vector_search/mod.rs, src/text/store.rs, .add/tasks/fts-query-routing-robustness/TASK.md
is_text_query is narrowed to treat only the [KNN bracket form as non-text. A new pub fn has_sparse_clause(args: &[Frame]) -> bool is added and re-exported. TextIndex::search_field replaces .expect calls on posting list lookups with Option-based handling that returns empty results or skips BM25 contributions instead of panicking.
has_sparse_clause guard propagated across all FT.SEARCH handler sites
src/server/conn/handler_monoio/ft.rs, src/server/conn/handler_sharded/ft.rs, src/server/conn/handler_single.rs, src/shard/spsc_handler.rs
All four FT.SEARCH routing sites add !has_sparse_clause(cmd_args) to the text-query fast-path condition, preventing sparse or hybrid queries from entering the text-only execution branches.
Unit and e2e tests, changelog, and task state
src/command/vector_search/ft_text_search.rs, tests/fts_query_eval_e2e.rs, CHANGELOG.md, .add/state.json
Unit tests cover updated is_text_query KNN routing, has_sparse_clause detection, build_text_response_with_total total reporting, and merge_text_results shard-sum behavior. E2e tests assert eval_query_counted returns a pre-truncation total and run_text_query sets reply[0] to the true matched count. CHANGELOG.md and .add/state.json are updated to reflect the three shipped improvements.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  rect rgba(70, 130, 180, 0.5)
    note over Client,merge_text_results: Multi-shard FT.SEARCH text query
  end
  participant handler as handler_sharded/handler_monoio
  participant spsc as spsc_handler
  participant eval as eval_query_counted
  participant builder as build_text_response_with_total
  participant merge as merge_text_results

  Client->>handler: FT.SEARCH index query [LIMIT ...]
  handler->>handler: is_text_query(query) && !has_sparse_clause(args)
  handler->>spsc: scatter text search to each shard
  spsc->>eval: eval_query_counted(idx, node, top_k)
  eval-->>spsc: (results[0..top_k], total_matched)
  spsc->>builder: build_text_response_with_total(results, total_matched, offset, count)
  builder-->>spsc: Frame reply[0]=total_matched
  spsc-->>handler: per-shard Frame
  handler->>merge: merge_text_results(shard_frames)
  merge->>merge: sum shard items[0] (errored shards = 0)
  merge-->>Client: Frame reply[0]=sum_total_matched
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • pilotspace/moon#190: Directly precedes this PR in the same FT.SEARCH text-query hardening pipeline, touching the same centralized text query parse/eval/dispatch and routing/count semantics code paths.

Poem

🐇 A rabbit named Rusty once typed a search query,
knn tutorial — text, not a vector in a hurry!
Sparse clauses now skip the wrong fast lane,
Counts don't lie behind a paginate chain,
remove_doc no longer scans every term in vain.
Three fixes, one PR — the postings all cheer,
No .expect left to panic in fear! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly and concisely summarizes three completed FTS hardening tasks (upsert, count, routing) with meaningful technical context.
Description check ✅ Passed Description contains all required template sections with comprehensive technical details; summary, verification, and notes sections are well-documented.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v3-1-fts-remaining

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 and usage tips.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/command/vector_search/ft_text_search.rs (1)

1886-1917: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

build_text_response_with_total keeps forbidden clone-heavy construction on the command dispatch path.

This new path still performs clone()-based response assembly in src/command/, which violates the dispatch allocation policy and preserves avoidable hot-path overhead. Please switch this builder to an ownership/move-based path (or equivalent zero/low-copy strategy) for keys/fields.

As per coding guidelines, No Box::new(), Vec::new(), String::new(), Arc::new(), clone(), format!(), or to_string() in command dispatch (src/command/), protocol parsing, shard event loops, or I/O drivers.

🤖 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/command/vector_search/ft_text_search.rs` around lines 1886 - 1917, The
build_text_response_with_total function violates the dispatch allocation policy
by using clone()-based construction (specifically cloning r.key with .clone())
and heap allocations (String::with_capacity, vec! macro) in the command dispatch
path. Refactor this function to use ownership/move-based semantics when building
Frame responses for keys and fields, eliminating the r.key.clone() call and
avoiding unnecessary String and Vec allocations. Consider restructuring how the
score buffer and fields array are constructed to minimize heap allocations on
this hot path while maintaining the same response format.

Source: Coding guidelines

🤖 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 @.add/tasks/fts-query-routing-robustness/TASK.md:
- Line 151: Fix the markdownlint violations in the TASK.md file at two
locations. At line 151, add a language identifier (text) to the opening fence of
the code block by changing the opening triple backticks from ``` to ```text to
satisfy the fenced code language requirement. At line 302, remove any spaces
that appear inside the inline code span delimiters to satisfy MD038, ensuring
that backticks directly wrap the code content without intervening whitespace.

In @.add/tasks/fts-upsert-incremental/TASK.md:
- Around line 141-176: The markdown fenced code block in the PostingStore
contract specification is missing a language identifier, which violates
markdownlint rules. Add a language specifier (for example, text) to the opening
fence marks (```text instead of ```) to satisfy the MD040 rule. Apply the same
fence-style fix to the analogous contract block in the related task file that
also contains unspecified fenced code blocks.

In `@src/text/posting.rs`:
- Around line 92-97: The estimated_bytes() method in PostingStore is not
accounting for memory used by the newly added doc_terms HashMap field, causing
memory telemetry and limits to be systematically under-reported. Update the
estimated_bytes() method to include memory estimates for the doc_terms field in
addition to the existing postings estimate. The estimate should account for both
the HashMap structure overhead and the SmallVec contents it stores for each
document.

---

Outside diff comments:
In `@src/command/vector_search/ft_text_search.rs`:
- Around line 1886-1917: The build_text_response_with_total function violates
the dispatch allocation policy by using clone()-based construction (specifically
cloning r.key with .clone()) and heap allocations (String::with_capacity, vec!
macro) in the command dispatch path. Refactor this function to use
ownership/move-based semantics when building Frame responses for keys and
fields, eliminating the r.key.clone() call and avoiding unnecessary String and
Vec allocations. Consider restructuring how the score buffer and fields array
are constructed to minimize heap allocations on this hot path while maintaining
the same response format.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ffa54eef-0912-40b0-a58b-d9b052ae1550

📥 Commits

Reviewing files that changed from the base of the PR and between 2329834 and 4de1d6c.

📒 Files selected for processing (17)
  • .add/state.json
  • .add/tasks/fts-query-routing-robustness/TASK.md
  • .add/tasks/fts-search-count-semantics/TASK.md
  • .add/tasks/fts-upsert-incremental/TASK.md
  • CHANGELOG.md
  • src/command/vector_search/ft_text_search.rs
  • src/command/vector_search/mod.rs
  • src/server/conn/handler_monoio/ft.rs
  • src/server/conn/handler_sharded/ft.rs
  • src/server/conn/handler_single.rs
  • src/shard/spsc_handler.rs
  • src/text/mod.rs
  • src/text/posting.rs
  • src/text/query/eval.rs
  • src/text/query/mod.rs
  • src/text/store.rs
  • tests/fts_query_eval_e2e.rs


## 3 · CONTRACT — freeze the shape ▸ docs/05-step-3-contract.md

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix markdownlint violations to keep task docs lint-clean.

Line 151 should declare a fenced code language (e.g., text), and Line 302 should remove spaces inside the inline code span to satisfy MD038.

Suggested patch
-```
+```text
 FT.SEARCH ROUTING + BM25-AND panic-hardening. No wire/RESP shape change; one NEW pub helper.
 ...
-```
+```
-      AND-ed into all SIX routing gates: handler_monoio/ft.rs:68 + :442, handler_sharded/ft.rs:68 + :342,
-      handler_single.rs:1338, spsc_handler.rs:1845. `is_text_query` keys on `[KNN` (the bare `KNN ` scan is
+      AND-ed into all SIX routing gates: handler_monoio/ft.rs:68 + :442, handler_sharded/ft.rs:68 + :342,
+      handler_single.rs:1338, spsc_handler.rs:1845. `is_text_query` keys on `[KNN` (the bare `KNN` scan is
       gone). All 3 `expect("posting exists")` removed from search_field (grep: none). Confirmed via grep +
       green build.

Also applies to: 302-302

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 151-151: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 @.add/tasks/fts-query-routing-robustness/TASK.md at line 151, Fix the
markdownlint violations in the TASK.md file at two locations. At line 151, add a
language identifier (text) to the opening fence of the code block by changing
the opening triple backticks from ``` to ```text to satisfy the fenced code
language requirement. At line 302, remove any spaces that appear inside the
inline code span delimiters to satisfy MD038, ensuring that backticks directly
wrap the code content without intervening whitespace.

Source: Linters/SAST tools

Comment on lines +141 to +176
```
PostingStore (src/text/posting.rs) — INTERNAL data-structure change. No wire/RESP change.

STATE (added):
doc_terms: HashMap<u32 /*doc_id*/, SmallVec<[u32 /*term_id*/; 8]>>
INVARIANT: doc_terms[d] == { t : postings[t].doc_ids.contains(d) } (a set — no duplicates)

fn add_term_occurrence(&mut self, term_id: u32, doc_id: u32, positions: Option<Vec<u32>>)
SIGNATURE UNCHANGED. On the NEW-doc branch (`!posting.doc_ids.contains(doc_id)`) ALSO record
term_id into doc_terms[doc_id] (push; this branch fires once per (doc,term) so no dup). On the
existing-doc branch (tf increment) the reverse map is untouched. Rank-aligned tf/positions
insert is unchanged.

fn remove_doc(&mut self, doc_id: u32) -> Vec<(u32 /*term_id*/, u32 /*old_tf*/)>
SIGNATURE + RETURN SEMANTICS UNCHANGED (callers sum it for stats; order is unspecified). New body:
let Some(terms) = self.doc_terms.remove(&doc_id) else { return Vec::new() }; // absent_doc_noop
for term_id in terms:
let Some(posting) = self.postings.get_mut(&term_id) else { continue }; // stale_reverse_entry_skip
if !posting.doc_ids.contains(doc_id) { continue }; // stale_reverse_entry_skip
let idx = posting.rank_index(doc_id); // BEFORE bitmap removal (rank-aligned)
old_tf = posting.term_freqs.remove(idx); posting.doc_ids.remove(doc_id);
if let Some(pos) = &mut posting.positions { if idx < pos.len() { pos.remove(idx); } }
removed.push((term_id, old_tf));
Visits EXACTLY |terms| postings — NOT all of self.postings. O(terms-in-doc × rank).

REJECT responses (internal — no error frames; these are defensive control-flow outcomes):
absent_doc_noop -> doc_terms.remove == None -> return Vec::new(); postings + stats untouched.
stale_reverse_entry_skip -> get_mut == None OR !doc_ids.contains -> `continue`; never unwrap/expect/panic.

INHERITED & PRESERVED (fts-posting-rank-tf frozen contract — UNCHANGED):
term_freqs/positions kept sorted-doc_id (rank) aligned with doc_ids; tf()/positions_for() via rank.
RESULT-AFFECTING OUTPUTS UNCHANGED: doc_freq, get_posting, store num_docs/avgdl, FT.SEARCH output.

Schema/access: IN-MEMORY ONLY. No on-disk format change — postings (and the reverse map) are
rebuilt from AOF/WAL replay at load, so NO migration. Per-shard; no cross-shard state.
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Specify a language for this fenced code block (MD040).

Add a fence language (for example, text) to satisfy markdownlint and keep docs lint-clean. The same fence-style fix should also be applied to the analogous contract block in .add/tasks/fts-search-count-semantics/TASK.md.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 141-141: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 @.add/tasks/fts-upsert-incremental/TASK.md around lines 141 - 176, The
markdown fenced code block in the PostingStore contract specification is missing
a language identifier, which violates markdownlint rules. Add a language
specifier (for example, text) to the opening fence marks (```text instead of
```) to satisfy the MD040 rule. Apply the same fence-style fix to the analogous
contract block in the related task file that also contains unspecified fenced
code blocks.

Source: Linters/SAST tools

Comment thread src/text/posting.rs
Comment on lines +92 to +97
/// Reverse index: doc_id -> the term_ids that document contributed (a set, no duplicates).
/// Lets `remove_doc` visit only a document's own terms instead of scanning every posting,
/// making per-doc removal O(terms-in-doc) instead of O(total vocabulary) — the upsert/bulk
/// re-index cliff (fts-upsert-incremental). Kept in sync with `postings`: `add_term_occurrence`
/// records the edge on the new-doc branch; `remove_doc` erases the doc's entry.
doc_terms: HashMap<u32, SmallVec<[u32; 8]>>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

estimated_bytes() now under-reports memory after introducing doc_terms.

PostingStore gained a reverse doc_terms map, but the memory estimator still accounts only for postings. That makes memory telemetry/limits systematically low after this change.

🤖 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/text/posting.rs` around lines 92 - 97, The estimated_bytes() method in
PostingStore is not accounting for memory used by the newly added doc_terms
HashMap field, causing memory telemetry and limits to be systematically
under-reported. Update the estimated_bytes() method to include memory estimates
for the doc_terms field in addition to the existing postings estimate. The
estimate should account for both the HashMap structure overhead and the SmallVec
contents it stores for each document.

@pilotspacex-byte pilotspacex-byte merged commit adb0bca into main Jun 16, 2026
14 checks passed
@TinDang97 TinDang97 deleted the feat/v3-1-fts-remaining branch June 16, 2026 14:14
TinDang97 pushed a commit that referenced this pull request Jun 16, 2026
Close the ADD loop for the v3-1-fts-hardening milestone (6/6 tasks PASS, PR #192
merged to main). Consolidate the 9 confirmed competency deltas into the versioned
foundation and mark the milestone done.

Foundation (bump foundation-version 4 → 5):
- PROJECT.md §Spec (SDD ×2): re-derive task scope from the code in Specify — a
  "perf-only" framing hid a correctness bug, and routing's real defects were
  unlike its original framing; a cross-shard payload that cannot REPRESENT the
  contract's algebra is a freeze-time design smell.
- CONVENTIONS.md (TDD ×3): distinct/asymmetric index fixtures; assert a nonzero
  per-suite ran-count (test selection must execute, not just compile); a stale
  #[ignore]'d test can guard nothing.
- CONVENTIONS.md (ADD ×4): A/B a tuning knee on each target arch (x86_64 breached
  the aarch64-green 5% bound → knee coarsened 512→1024); risk:high ⇒ conservative
  human gate is load-bearing; split-parser/dispatch, gate-the-pair; deferred-
  cleanup honesty.
- PROJECT.md §Key Decisions: one CLOSE + fold row.

Bookkeeping:
- All 9 deltas flipped open → folded (fts-posting-rank-tf ×3,
  fts-query-eval-dispatch ×5, ft-yield-costfree-monoio ×1).
- MILESTONE.md: 5 exit criteria + 6 task boxes checked.
- state.json: milestone status → done; RETRO.md exit report written.

Carried follow-ups (candidates for the next milestone, not blockers): un-ignore
the stale numeric consistency test, remove the dead scatter_text_search_filter
path, tighten the multi-field-OR IDF magnitude approximation.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants