# Changelog All notable changes to pg_fts are documented here. ## 1.17.0 - 2026-11-09 A minor release: BM25's corpus statistics follow deletions (the document count and the average length every ranked score is normalised by), PostgreSQL 19 support, a new `fts_index_segments()` function that shows each segment's tombstones, and sparsemap 5.8.5. Scores change after deletes: they now equal what a REINDEX of the same rows gives. No on-disk format change, no REINDEX (`ALTER EXTENSION pg_fts UPDATE TO '1.17.0'`); an index that deleted rows under an earlier release is repaired by one `fts_vacuum` or `fts_merge` that leaves it with no tombstones (or by a REINDEX). ### Fixed - **avgdl kept the lengths of deleted documents until a REINDEX** (every release; report of 2026-11-04). VACUUM's bulk delete lowered the document count as it tombstoned rows but left their lengths in the corpus total, a merge carried each input's whole total into the merged segment, and the 1.16.0 `fts_vacuum` rewrite kept it too. Every ranked query normalised lengths against an average that included rows that were gone. The report's reproduction (200,000 rows, even ids 10 words and odd ids 100, delete the odd ones, VACUUM), on PG 17, with `doclen_sidecar` on and off alike: | step | ndocs | avgdl 1.16.0 | avgdl 1.17.0 | true | |---|---:|---:|---:|---:| | built | 200,000 | 55.000 | 55.000 | 55.000 | | long rows deleted, VACUUM | 100,000 | 110.000 | 10.000 | 10.000 | | then `fts_vacuum` | 100,000 | 110.000 | 10.000 | 10.000 | | then `fts_merge` | 100,000 | 110.000 | 10.000 | 10.000 | | REINDEX | 100,000 | 10.000 | 10.000 | 10.000 | - The bulk delete subtracts each newly tombstoned document's length in the same metapage record that writes its tombstone, and recomputes N there too, so a cancelled VACUUM leaves the two consistent. An inline-length segment (`doclen_sidecar = off`) has each exact length: a posting walk that stops once it has seen every dead document. A sidecar segment keeps one byte per document, a length to 4 significant bits, which bounds each length to an interval (the bounds were checked for every length up to 2^29); the dead documents' share of the segment's exact total is taken from those intervals, exact when all or none of the segment's inexact documents die (the report's case). - A merge writes its surviving documents' count and length total, not its inputs'. - `fts_vacuum` and `fts_merge` recompute the corpus totals from the segments when no segment carries tombstones and nothing is pending. That repairs an index that deleted rows under an earlier release: the report's state (avgdl 137.5 for a true 10.0, built and churned on 1.16.0) reads 10.000 after one `fts_vacuum` on 1.17.0. - N is now counted, for each segment the bulk delete walks, from the segment's docid universe minus its tombstones: exact whatever the segment's history. Measured after deleting half of 40,000 rows of two length mixes (PG 17, five ranked queries, top 20, compared with a REINDEX of the same rows): 1.16.0 shared 87 of the 100 result ids and had the same order for 1 of the 5 queries, with scores up to 82% off; 1.17.0 has the same ids in the same order for all 5, with scores within 0.7% (`doclen_sidecar = on`, the estimate) and identical (`off`). On a randomised churn (20,000 rows of lengths 1-400, four rounds of 20% deletes and 3,000 inserts, then VACUUMs, a merge, 30% more deletes and `fts_vacuum`; three seeds) N matched the table at every step, avgdl matched exactly with `doclen_sidecar = off` and to within 0.15% with `on` (`sql/corpus_stats.sql` holds a smaller version; it fails on 1.16.0). Cost, on c6id.metal with PG 18.6, at 1.9M rows with 1/80 deleted (23,750 scattered tombstones on one packed segment), 1.16.0 vs 1.17.0 (`bench/RESULTS_170_2026-11-09.md`): - correctness first: the top-10/200 ids of three ranked queries are identical on both releases and on a REINDEX of the same rows; - VACUUM takes 3.9% longer with `doclen_sidecar = off` (18.36 -> 19.07 s, median of 3). That is the second posting walk, which finds the dead documents' lengths. It took 8-10% longer (20.9 -> 22.5-22.8 s, one run each) in the churn report's own script, with 23,313 moves over 1.88M rows; - with `on` it takes 3.2% longer (0.791 -> 0.817 s); - ranked top-10 and top-200 latency is +0.6% to +0.9%, against an A/A of 0.05% to 0.3%. That compares the whole release, sparsemap 5.8.5 included (below), and the cause is not isolated. - **A build that met recently deleted rows left N negative after the next VACUUM** (1.3.0 to 1.16.0). A row deleted while another snapshot can still see it is indexed by CREATE INDEX (that snapshot may reach it through the index), and 1.3.0 left it out of the document count. The VACUUM that later removed it tombstoned it and subtracted it again: with a snapshot held across `DELETE` of 900 of 1,000 rows and `CREATE INDEX`, the next VACUUM left `fts_index_stats().ndocs` at -800 for 100 live rows (`specs/bm25_recently_dead.spec`; it fails on 1.16.0). Every indexed document is now counted until the VACUUM that tombstones it, like any deleted row. An index built this way by an earlier release is counted from its docid universe at the next VACUUM, and the rows that build left out subtract no length (they are the first to be tombstoned). - **Deleting a document with no terms never lowered N** (every release). An empty document (`''`, or only stop words) was counted but has no posting, so no VACUUM could tombstone it: 100 empty rows deleted left `ndocs` 1,002 for 901 rows with a term. N now counts documents with at least one term, as Lucene's `docCount` does, so a table with many empty rows scores with a slightly higher average length than before. - **pg_fts did not build against PostgreSQL 19** (report of 2026-10-30, against 19rc1: `tuplestore_putvalues` undeclared, `IndexAmRoutine` incomplete, and the planner hook's new `ExplainState *` argument). The reporters' patch is applied as sent. On 19rc1 the regression suite then differed only in plan text, now version-neutral: InitPlans are named `expr_N`, `count()` of a NOT NULL column becomes `count(*)` (answered by the FtsCount pushdown, which the test meant to avoid), and the two index paths are costed differently. ### Added - **`fts_index_segments(regclass)`**: one row per segment with its documents (`ndocs`, tombstoned ones included), tombstoned documents (`ndeleted`), `nterms`, `doclen_sidecar` and `tombstone_bytes`. A ranked or counting scan reads past every tombstoned document of its segments until a merge, `fts_vacuum` or REINDEX drops them; through 1.16.0 nothing in SQL showed them (the third suggestion of the churn report of 2026-11-02, ROADMAP I25). ### Changed - **CI gates on PostgreSQL 19.** The 19 leg tested nothing (see Retracted): apt could not install `libpq-dev` 19 from the pgdg-testing repository without `-t`, and every step was `continue-on-error`. It now installs from pgdg-testing and fails like the 17 and 18 legs: 33/33 regression, 5/5 isolation and 12 TAP files on 19rc1. - **sparsemap 5.8.4 -> 5.8.5** (`vendor/sm.c`, `vendor/sm.h`): the sparsemap maintainers' fix for the read-cursor defect pg_fts reported (ROADMAP I29), a drop-in source swap (no API, ABI or wire-format change; `sm_cursor_t` is still 24 bytes). In 5.8.4 a lookup in the gap before the cursor's cached chunk resumed at the cursor's `prev_offset` hint, which nothing verified was a chunk start; a hint on a payload word started the walk mid-chunk and answered wrong. 5.8.5 never starts a walk at the hint: a gap lookup caches the left neighbour the walk itself verified, so the next ascending lookup resumes there. `sm_next_member` now checks its cursor's start word too (it resumed unchecked since at least 5.7.0). pg_fts could reach neither: its maps are read-only while a cursor is live, and its bulk delete resets its cursor after the last insert. Verified here (`bench/RESULTS_SM585_2026-11-05.md`): - the reproducer and pg_fts's random reachability harnesses report no wrong answer on 5.8.5 (the reproducer fails on 5.8.4); - `sm_next_member` through mispositioned cursors: 1,614,722 wrong of 2,307,793 on 5.8.4, 1,170 on 5.8.5, every one a cursor whose offset holds a word equal to its recorded start (the case sm.h now documents as undetectable without an ABI change); - CBMC (`test/cbmc/smcursor_harness.c`, all four layouts): C1-C3, C3x (any `prev_offset`, which failed on 5.8.4) and a new stale-offset property C4 verify; C4 fails on a copy without the start-word check; - ranked queries at the scattered-tombstone state (1.9M rows, 23.7k tombstones on one packed segment, PG 18.6, c6id.4xlarge, median of 11 per sample, 12 interleaved samples per build, an A/A pair): identical results. The top-10 is 1.2% slower (1.719 -> 1.739 ms; 5.8.5 slower in all 6 rounds, A/A +0.06%). The cause is not known: in isolation 5.8.5's ascending gap lookups are faster than 5.8.4's (28.9 vs 31.2 ns) and member lookups are equal (54.4 vs 54.5 ns), so it is not more cursor steps; code layout in the new `.so` is a plausible cause and is unmeasured. The top-200 queries and the first query of a session are unchanged (A/A 0.1-0.7%). | ranked query | 5.8.4 ms | 5.8.5 ms | diff | A/A | |---|---|---|---|---| | `patch`, top-10 | 1.719 | 1.739 | +1.16% | +0.06% | | `vacuum`, top-200 | 10.578 | 10.634 | +0.54% | +0.14% | | `remove`, top-200 | 51.736 | 51.725 | -0.02% | +0.11% | | `patch`, top-10, first query of a session | 5.849 | 5.835 | -0.23% | -0.68% | ### Retracted - **"PostgreSQL 19/`master`-devel builds and is exercised best-effort in CI"** (README, since 1.0.0). It did not build against 19rc1 (1.15.1, 1.16.0: the errors above), and the CI leg that was to show it did not install PostgreSQL 19 in any run whose log survives (2026-08-28 to 2026-11-05, 8 sampled: apt exited 100 on `libpq-dev`). Every later step was `continue-on-error`, ran against no server and passed, so the job was green. It produced runs that tested nothing. 19 builds and gates from 1.17.0. ### Known issues - **A pure negation does not match a document with no terms through the index** (every release). The index answers a top-level NOT against the documents that have a posting, and an empty document has none: ```sql CREATE TABLE e (id int, body text); INSERT INTO e SELECT g, CASE WHEN g <= 100 THEN '' ELSE 'alpha w' || g END FROM generate_series(1, 1000) g; CREATE INDEX ON e USING fts (to_ftsdoc('simple', body)); SELECT fts_count('e_to_ftsdoc_idx', '!zzz'); -- 900 SELECT count(*) FROM e WHERE fts_match(to_ftsdoc('simple', body), '!zzz'); -- 1000 ``` A fix indexes empty documents, a design change (ROADMAP I32). - **With `doclen_sidecar = on`, avgdl after deletes is an estimate.** The sidecar keeps each length to 4 significant bits, so the dead documents' share of a segment's exact total is estimated from the byte intervals: within 0.15% in the churn above, exact again after a REINDEX. With `doclen_sidecar = off` it is exact. Turning `doclen_sidecar` off on a live index stores the sidecar-era documents' lengths inline at their lower bounds when they are merged, and later deletes of them subtract those bounds: avgdl read 1.3% high after a third of such an index was deleted. ## 1.16.0 - 2026-11-04 A minor release: correctness fixes on the on-page decode path, found by the first formal proofs of pg_fts's code (CBMC, `test/cbmc`), and the two changes held back from 1.15.2 because they change execution (the collect-path skip for an operand of plain-term readings, Codeberg #6) and maintenance (`fts_vacuum` now rewrites a single packed segment that carries tombstones). Also a per-backend memo that takes most of the per-scan cost out of ranked queries on tombstoned segments, and sparsemap 5.8.4. No SQL object changes, no on-disk format change, no REINDEX (`ALTER EXTENSION pg_fts UPDATE TO '1.16.0'`). ### Fixed - **A corrupt FOR width byte in a posting block made a ranked query read past the block** (every release; found by the new CBMC proofs, `test/cbmc`). The loader under every ranked traversal (`wand_load_block`) checked that a block's declared lengths fit the page, then decoded columns whose sizes come from their own width bytes, never compared with the block's length. `count(*)` (`bm25_decode_term`) had checked this since 0.3.5. Reproduced on PG 17.10 by setting the docid width byte of a full block from 8 to 9 or 64 with the server down (`t/003_corruption.pl`): width 9 decoded garbage docids from the block's own bytes, so the ranked answer disagreed with `count(*)` (an assert-enabled ASan build aborted on an invalid TID instead); width 64 read 879 bytes past the copy and failed with "could not read blocks" (12 and 20 failed the same way). The block now ends the cursor's chain, as the page-fit check already did, and both paths agree. The same class, fixed the same way: - the doclen sidecar readers (`bm25_doclen_build_slots`, `bm25_doclen_cursor_load_page`, since 1.5.0) checked that a block fits the page but not that its gap column fits its declared `gapbytes`; - `bm25_decode_term`'s own column check read the tf column's width byte one byte past the block when the docid column filled it exactly; - five dictionary walks (the posting-chain head lookup when a segment is freed, the tombstone rebuild in VACUUM, both `fts_anomalous_docs` walks, the trigram-ordinal walk) did not call `bm25_dict_entry_fits`, and the trigram walk read `pd_lower` without `bm25_page_data_end`. One shared check (`bm25_for_cols_fit` in `pg_fts_for.h`) now sizes every column from its width byte before any decode, and reads each width byte only after the bytes before it are proved to fit. On every layout the writer produces (all counts 1-128 and all column widths, 35.7M layouts) it accepts the block and detects the inline doclen column exactly as the old check did. The ranked loader now also detects that column per block, as `bm25_decode_term` always did: a two-column block in a segment with no doclen sidecar (merged by 1.5.0-1.5.2, CHANGELOG 1.5.3) used to read a "doclen" past the block; it now scores each posting with the block's minimum length, the length its block bound assumes, so pruning stays sound. 1.5.3's advice to REINDEX such segments stands. Also: the trigram directory walks size each entry and the trigram query walk validates `pd_lower`; the fuzzy dead-prefix lookahead checks the next dictionary entry fits before comparing it. - **`fts_vacuum` now drops the tombstones of an index that is one packed segment** (ROADMAP I25; the 1.15.2 known issue, and the 2026-11-02 report's steps E and F). It treated "one front-packed segment" as fully compacted, so after a bulk DELETE + VACUUM it returned false, rewrote nothing, and the tombstones stayed until a REINDEX. Under `fts_vacuum`'s AccessExclusiveLock, a segment that carries tombstones now gets one rewrite, which drops every tombstoned document, and `fts_vacuum` returns true. VACUUM's own cleanup does not do this: it would rewrite a whole segment for a few deletes on every cycle. `fts_merge` is unchanged. Measured on c6id.metal, PG 18.6: - the report's repro (1.88M rows, 23,313 moves; this run also had the memo below): `fts_vacuum` returned t in 69 s, against 136 s for REINDEX; the index went from 735 to 728 MB; top-200 21.7 -> 13.9 ms (fresh 13.9), `bestfirst = off` 9.6 -> 2.0 ms (2.1); - the churn harness (1.9M rows, 23,750 moves): `fts_vacuum` 88.6 s, against 183.7 s for REINDEX; top-200 63.8 -> 53.6 ms (fresh 54.1), top-10 3.54 -> 0.34 ms (0.34). `ranked_tombstones` checks that it returns t on such an index and f on a second call (f on 1.15.2), and that the ranked results match the docid-order walk with no deleted row ranked. - **Page guards no longer form a pointer past the page from an on-disk integer.** `stream + bytelen + posbytelen > pend`, `t + termlen <= end` and `p + sizeof(header) <= end` after a MAXALIGN step are undefined behaviour once the integer is large, even when the comparison would reject it (AGENTS.md rule 16). They now compare lengths (`bm25_fits`, `bm25_dict_entry_fits`). Found by CBMC's pointer-overflow check; no misbehaviour was observed. - **The block-max WAND bound could be one ulp below the score it bounds** (every release). `wand_block_max_contrib` computed `idf * mtf * (k1 + 1)` where the score is `(idf * (k1 + 1)) * tf`; floating-point multiplication is not associative, so for a block's own (max tf, min length) posting the bound was one ulp low in about 0.02% of random (corpus, block) shapes (462 of 3M), found while writing `test/cbmc/bound_harness.c`. A skip needs the threshold to sit exactly on that bound, and no such tie was found in 20,000 random corpora, so no wrong top-k has been observed; the bound now uses the score's operation order, and the term-wide bound too. The best-first bound already did. ### Added - **CBMC proofs** (`test/cbmc`, `bash test/cbmc/run.sh`): bounded model checking of the real code over every input within a bound, each with a planted historical bug that must be caught. See `test/cbmc/README.md` for what each proves and its bound: `fts_search_bmw` (copied verbatim) returns the brute-force top-k (`fts_search_maxscore` is harnessed, and its 1.10.0 bug is caught, but the shipped code had no verdict after 2 hours); BM25 block bounds are >= every score they cover in IEEE doubles (an exhaustive native sweep; the symbolic proof is opt-in); the on-page guards keep every later read in bounds; the query-shape shortcuts (I19a/b, 1.14.3) never drop a match; the FOR codec and doclen byte round-trip; the sparsemap read cursor never changes an answer; the shared-doclen slot protocol has no use after free, double unpin or leak. `check_verbatim.py` fails the run when a copied function drifts from the source. - `t/003_corruption.pl`: the corrupt-width-byte case above (fails on 1.15.2), and a two-column segment without a sidecar (the 1.5.0-1.5.2 merge layout): ranked finds every match. ### Changed - **sparsemap 5.8.2 -> 5.8.4** (`vendor/sm.c`, `vendor/sm.h`), a drop-in source swap (no API, ABI or wire-format change; serialized maps are byte-identical). The vendored copy already carried the 5.8.3 `sm_validate` / `sm_open` bounds fixes and the 5.8.4 cursor gap resume (ROADMAP I25); what is new here: `sm_add_range` no longer reads 8 bytes past its scratch buffer (5.8.4), `sm_union` / `sm_intersection` emit chunk-aligned RLE and no longer re-emit a sparse chunk's tail (5.8.3), and small-set maps and the SIMD kernels are alignment-safe on a misaligned buffer (5.8.3). pg_fts calls neither set operation nor `sm_add_range`. Its bulk insert (`sm_add_many_grow`) reaches the coalescing code the 5.8.4 read fix is in, but builds into a buffer with slack, and the over-read needed an exactly full one, so no pg_fts effect is known. One unused helper the old copy carried is gone. The `__pg_bm25_` symbol prefix is unchanged. - **Tombstoned segments: a per-backend memo of blob validation and seek checkpoints** (ROADMAP I25). After 1.15.2, most of a ranked top-10 on a segment with 23k scattered tombstones was per-scan setup: validating the tombstone blob and building its checkpoints. Both depend only on the stored blob, which does not change while the metapage generation stands, so a backend now does that work once per (index, generation, segment). Each scan still reads its own copy of the blob. Measured on c6id.metal, PG 18.6, 1.9M rows, 23,750 scattered tombstones on one segment, 12 interleaved samples per build against an A/A pair (`bench/RESULTS_CHURN_2026-11-02.md` section 5): | query | 1.15.2 | with the memo | A/A | |---|---|---|---| | top-10, term in 0.5% of rows | 3.490 ms | 2.020 ms (-42%) | +0.01% | | top-200, 1% term | 14.41 ms | 12.95 ms (-10%) | -0.08% | | top-200, 6% term | 63.7 ms | 62.2 ms (-2.4%) | -0.07% | The results are identical. The first query in a backend pays the setup as before. - **The collect path skips a segment when an AND operand of plain-term readings is all absent there** (ROADMAP I19b, Codeberg #6). 1.15.0 skipped a segment only for a single plain required term with no posting in it; an operand that is an OR of plain terms, the shape an analyzer gives a word with two lemmas (`(zzzq | zzzx) & kur:*`), still built the other operands. The reporter measured 17.7 ms for that against 0.35 ms for `zzzq & kur:*` on 1.15.1. Now an operand made only of plain terms joined by OR is empty in a segment when every term is absent; the lookups stop at the first present term. Measured on c6id.metal, ranked top-10, warm, 12 samples per build (`bench/RESULTS_I23_2026-11-01.md`): `(zzzq | zzzx) & (apple | ban:*)` on the reporter's 400k-row corpus 3.792 -> 0.076 ms (PG 17.10) and 3.794 -> 0.078 ms (PG 18.6); the same shape on a 640k-row, 60-token-per-row index 618.3 -> 0.076 ms (buffers 3,695 -> 22), results identical. Not changed: an operand with a present reading (`(zzzq | build) & ...`, 621 ms), which still builds the group. Regression checks in `ranked_driven_and` compare index buffers with the single-term form and check results against the seq-scan oracle, including a reading present only in later segments and pending rows, and two shapes (a NOT, a prefix) the skip must not take. ### Not reproduced - **A pure OR 5-8% slower than on 1.14.3** (Codeberg #6: `build | (10 terms)` on a 640k-row corpus, 519-547 ms on 1.14.3 and 557-582 ms on 1.15.1). On c6id.metal, with the `.so` swapped under one index as the reporter did, a 10-term OR differs from 1.14.3 by +0.04% (400k rows, PG 17.10), +0.25% (PG 18.6), -0.45% and +0.28% (640k rows, 98 and 41 ms queries, PG 18.6), against an A/A spread (the same tree built twice) of up to 0.21%; index buffers on the 400k corpus are 1,779 on both (`bench/RESULTS_I23_2026-11-01.md`). 1.15.x adds no per-posting work to a pure OR (the I19a check needs a gate, which a pure OR does not build). ROADMAP I23 stays open for the reporter's environment. ## 1.15.2 - 2026-11-03 Two fixes for ranked queries. One is a reported 60-300x slowdown after a bulk delete (ROADMAP I25). The other is a wrong top-k from the block-max WAND path that every release since 1.3.0 has had (ROADMAP I26), found and fixed by Jim Dawdy in the jimdawdy-hub/pg_fts fork. **No on-disk format change; no REINDEX; no new or changed SQL objects** (`pg_fts--1.15.1--1.15.2.sql` is a no-op). Measured on PostgreSQL 18.6 and gated on 17.10 and 18.6 (`bench/RESULTS_CHURN_2026-11-02.md`, `bench/RESULTS_152_2026-11-03.md`). ### Fixed - **Ranked top-k became 60-300x slower after deletes scattered over a large heap, until a REINDEX** (reported 2026-11-02 from a production index, 1.88M rows, after moving 23,313 rows and an `fts_vacuum`). The heap VACUUM that follows tombstones the deleted rows on the packed segment, and `fts_vacuum` keeps a single packed segment as it is, so the tombstones stay until a merge rewrites it. Each scored posting is checked against the segment's tombstone set, a sparsemap that holds about one chunk per tombstone when the deleted rows are spread across the heap. Two lookup costs grew with the chunk count: - The sparsemap cursor ignored a cached chunk that starts after the looked-up index, which is where a lookup lands when it falls in the gap before that chunk -- nearly every live docid. Every such lookup walked the chunk list from the start (fixed in the vendored `sm.c`; the cursor now resumes from the chunk before, after checking it is that chunk's neighbour). - The best-first paths visit posting blocks out of docid order and reset the cursor per block, so each visited block walked the chunk list from the start. They now seek from per-scan checkpoints: up to 1,024 tombstones spaced evenly by docid, each with a cursor on it. - `fts_anomalous_docs` checked tombstones with no cursor at all (one walk per posting); it now keeps one per term. Measured on c6id.metal, PG 18.6, a 1.9M-row `doclen_sidecar = off` index (about 150 words a row) after moving every 80th row and the same fts_vacuum / VACUUM order (`bench/RESULTS_CHURN_2026-11-02.md`): ranked top-200 of a term in 6% of rows 3,640 -> 64 ms (fresh build 54 ms), top-200 of a 1% term 238 -> 14.5 ms (10.3), top-10 of a 0.5% term 62 -> 3.5 ms (0.35); the top-200 ids of the common term are identical across builds at every step. What is left of the top-10 cost is per scan: validating the tombstone blob (25% of its profile) and building the checkpoints (about a third). The reporter's own repro script (1.88M rows, 23,313 moves, the same order), run unchanged on c6id.metal: `fts_search(..., 'remov', 200)` after the VACUUM 7,719 -> 23 ms (fresh 14 ms), `'vacuum'` 3,308 -> 13 ms (7.0), with `pg_fts.bestfirst = off` 7,552 -> 10 ms (2.1), and with three segments and no pack 7,942 -> 23 ms; a 4-term OR top-200 through the executor 111.6 s -> 162 ms. Workaround on 1.15.1 and earlier: REINDEX after a bulk delete (measured). Known issue in this release too: `fts_vacuum` and `fts_merge` return false and rewrite nothing when the index is one packed segment, so its tombstones stay until a REINDEX or a later merge (ROADMAP I25); they now cost about 1.7x a fresh build rather than 300x. New regression test `ranked_tombstones`: scattered deletes, the freed slots reused by rows without the query terms, ranked single-term / AND / OR shapes against a seq-scan oracle and the docid-order walk (6 of its 12 shapes fail when the WAND paths skip the tombstone check). - **Ranked queries could return the wrong top-k from the block-max WAND path** (since 1.3.0). When the current blocks of the cursors at or before the pivot could not beat the threshold, the skip moved them past the pivot: 1.3.0-1.10.0 jumped to `pivot + 1`, and 1.11.0 jumped to the earliest current block end but clamped that to `pivot + 1` "to make progress". When a cursor's current block ends before the pivot, a later block of that term can score the pivot higher than the current bound, so the pivot was skipped unscored. The jump now stops at that block end; the cursor sits at or below it, so it still moves. Found and fixed by Jim Dawdy in the jimdawdy-hub/pg_fts fork (e4bba08, never submitted upstream); his regression test `bmw_block_end` returns id 1 as the top-1 of `aye | bee` (the exact answer is 5000) on 1.3.0, 1.10.0, 1.11.0, 1.13.0 and 1.15.1, and passes here (PG 17.10, 18.6). The path is taken by a ranked query whose WAND walk has two or three cursors (one per term per segment) and that takes none of the best-first or driven paths: for example a two- or three-term OR on one segment, or such a query with a phrase / prefix / fuzzy / regex filter. On a 640k-row index, pure 2- and 3-term ORs are 0.3-2.4% slower (A/A up to 1.7%); filtered queries unmeasured. ### Retracted - 1.13.0 said of its block-max WAND fixes: "No query found a wrong top-k on 1.12.1, whose best-first paths already avoid both cases." The docid-order block-max WAND path still skipped a top-k document in the `safe_until` case (above): 1.3.0 through 1.15.1 return a wrong top-1 for `bmw_block_end`. 1.3.0 described the skip that introduced it as "removing a provably-unsound (though not field-reachable) over-skip". ## 1.15.1 - 2026-10-29 One fix, for a defect in 1.15.0's new configured-analyzer path (ROADMAP I9a), found the day 1.15.0 shipped by comparing its output with an unchunked parser. **No on-disk format change; no new or changed SQL objects** (`pg_fts--1.15.0--1.15.1.sql` is a no-op). Probe comparison on PostgreSQL 17.10; gate on 17.10 and 18.6 (`bench/RESULTS_151_2026-10-29.md`). ### Fixed - **The chunked re-parse split HTML markup at chunk boundaries** (ROADMAP I9a). Affects `to_ftsdoc(config, text)` on documents over 16,383 positions, since 1.15.0. The re-parse cut the text into chunks of at most 16,383 bytes, each after a whitespace, so a tag, comment or `