All findings · Lucene finding · LFV-088 · latlonpoint-nearest-totalhits-counts-deleted-docs

LatLonPoint.nearest reports an exact total hit count that includes deleted documents

LatLonPoint.nearest returns the right hits, but its totalHits still counts deleted documents and is labelled exact (EQUAL_TO): 3 documents with one deleted give 2 hits and a total of exactly "3 hits". Every 10.x release and main have it.

Summary

Priority
Low, rank 344The returned hits are correct; only the EQUAL_TO total of the niche LatLonPoint#nearest API also counts deleted documents.
Impact
silent wrong results
Since
10.0.0 (moved to core in 2022; sandbox code since 2016)introduced 2022-06-13 · about 4 years
Affected
10.0.0 to 10.5.2 (all 12 releases), 10.6.0 (by reading) and main
Status
documented
Found by
code reading (audit of exact TotalHits producers)

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Only the total is wrong; the hits, their order and their distances are correct.

Diagram of the repro: a segment with three documents that each have a point, one of them deleted. LatLonPoint.nearest returns the two live documents as hits, filtered through the live docs, but its total comes from the segment's point statistic getDocCount = 3, which still counts the deleted document, and is labelled EQUAL_TO: "3 hits" while the index has 2 live documents.
The repro on one picture: the hits are filtered by the live docs, the total is read from a statistic that still counts the deleted document. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 3 documents with one LatLonPoint each, commit, and delete one. Until a merge, the segment still holds the deleted copy.
  2. Call LatLonPoint.nearest(searcher, "point", 40, 50, 10). You expect 2 hits and totalHits = 2 with relation EQUAL_TO, which means exact.
  3. You get the 2 right hits, but totalHits = "3 hits", EQUAL_TO: more than the 2 live documents in the whole index.
  4. Default settings show it too: 10 documents and one updateDocument give 10 hits and "11 hits". With 100 unmerged updates of 1,000 documents the total is "1100 hits".
  5. Why: the total is the sum of each segment's PointValues.getDocCount(), "documents that have indexed at least one point", which ignores deletions. The live docs are used only to filter the hits.

Who is affected

Anyone who calls the public LatLonPoint.nearest and reads totalHits (an "N places found" count, paging) on an index with deleted or updated documents not yet merged away. Default settings; each deleted or replaced copy with the field adds one, usually until a merge. The hits are never wrong. Code search found no caller in Elasticsearch, OpenSearch or Solr.

Mechanism, terms and numbers427 words

LatLonPoint.nearest(searcher, field, lat, lon, n) finds the n indexed points closest to a location. It skips deleted documents when it picks the hits, but it takes its total from PointValues#getDocCount(), an index statistic that still counts deleted documents, and labels that total TotalHits.Relation.EQUAL_TO (exact). After an ordinary delete or update, and until a merge drops the old copy, the hits are right but the "exact" total is too high by the number of non-live documents that carry the field; it can exceed reader.numDocs(). Core has had it since nearest moved there in 10.0.0: all 12 10.x releases up to 10.5.2, 10.6.0 (by reading) and main ea57360912ed.

Terms

  • LatLonPoint.nearest: a public static method that walks the BKD tree of a LatLonPoint field and returns the n closest points as TopFieldDocs. Its javadoc says it is "functionally equivalent to running MatchAllDocsQuery with a LatLonDocValuesField#newDistanceSort", but faster.
  • TotalHits: the value plus a relation. EQUAL_TO means the value is the exact hit count; GREATER_THAN_OR_EQUAL_TO means it is a lower bound.
  • Deleted but not merged: a delete, or the old copy left by updateDocument or softUpdateDocument, only clears the document's live bit. The document stays in its segment, and in the segment's statistics, until a merge rewrites the segment without it.

The bug (main ea57360912ed)

  1. LatLonPoint.java:421-430 loops over the segments. For each one it adds points.getDocCount() to the total (:425): "the total number of documents that have indexed at least one point" (PointValues.java:495-496), deletions not subtracted.
  2. The same loop collects each segment's live docs (:428), but only for NearestNeighbor, whose visitor skips every document whose live bit is clear (NearestNeighbor.java:156). So the hits are right.
  3. :442 returns new TotalHits(totalHits, TotalHits.Relation.EQUAL_TO).

The numbers (executed on stock main)

indexmaxDoc / numDocshitstotalHits
3 docs, no deletion (control)3 / 33"3 hits" (right)
3 docs, 1 deleted3 / 22"3 hits" EQUAL_TO
default IndexWriterConfig, 10 docs, 1 updateDocument11 / 1010"11 hits" EQUAL_TO
1,000 docs, 100 updateDocument calls, n = 1001100 / 1000100"1100 hits" EQUAL_TO
6 docs, 4 with a point, one of those deleted6 / 53"4 hits" EQUAL_TO

softUpdateDocument with a soft-deletes field does the same (maxDoc 4, numDocs 3, "4 hits"), and so does a reader that hides documents through getLiveDocs(). For a fixed field and reader, the wrong number does not depend on lat, lon or n: the total is summed before the search runs.

Exposure and likelihood276 words
  • Who is hit. Callers of the public, non-experimental static LatLonPoint.nearest that read TopFieldDocs.totalHits: a displayed result count, a page count, or any logic that trusts an EQUAL_TO total.
  • Default, not opt-in. No setting is needed: the default IndexWriterConfig, codec, merge policy and IndexSearcher show it after one updateDocument. Tests use NoMergePolicy only to keep the deleted document in place.
  • Size of the error. The total is too high by the number of non-live documents that carry the field in the searched segments. An update deletes (or soft-deletes) every document that matches its term, so with unique ids it adds one: 100 updates of 1,000 documents give "1100 hits" (executed). The old copy counts as long as a segment holds it: usually until a merge, longer under a SoftDeletesRetentionMergePolicy. The default TieredMergePolicy targets at most 20% deleted documents (TieredMergePolicy.java:121-124); that is a goal for choosing merges, not a hard cap. For a dense field at 20% deleted the total is about 25% above the live count, and for a sparse field the relative error can be larger. This is arithmetic, not measured.
  • What the damage looks like. A total that is too high and claims to be exact. Possible application effects (hypothetical): a count larger than the results that can be served, or a loop that pages until it has totalHits results.
  • Reach. In-tree, the only non-test caller is the JMH benchmark (NearestNeighborBenchmark.java:109). A code search of the default branches of Elasticsearch, OpenSearch and Solr (2026-10-25, repeated 2026-10-26) found no call; code search has limits, and private applications are unknown. So it mainly affects applications that use Lucene directly.

Minimal repro

The test shows that LatLonPoint.nearest returns an EQUAL_TO TotalHits that counts a deleted (unmerged) document.

TestNearestTotalHitsDeletedDoc.java (JUnit test)56 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.LatLonPoint;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNearestTotalHitsDeletedDoc extends LuceneTestCase {

  public void testDeletedDocNotCounted() throws Exception {
    IndexWriterConfig iwc = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int i = 0; i < 3; i++) {
        Document doc = new Document();
        doc.add(new StringField("id", Integer.toString(i), Field.Store.NO));
        doc.add(new LatLonPoint("point", 0, 0));
        w.addDocument(doc);
      }
      w.deleteDocuments(new Term("id", "0"));
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        TotalHits total = LatLonPoint.nearest(searcher, "point", 0, 0, 10).totalHits;
        boolean exact = total.relation() == TotalHits.Relation.EQUAL_TO;
        // 2 live documents: the deleted one must not be counted; actual EQUAL_TO 3
        assertTrue(
            "expected exactly 2 hits (or a lower bound <= 2), got " + total,
            exact ? total.value() == 2 : total.value() <= 2);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-088/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.TestNearestTotalHitsDeletedDoc
Expected vs actual
Expected, with 3 documents and 1 deleted: exactly 2 hits (or a lower bound <= 2). Actual (-ea and -da): EQUAL_TO 3 (got 3 hits).

All files of this repro: repro/LFV-088/

More files, environment, full output42 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected exactly 2 hits (or a lower bound <= 2), got 3 hits
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.search.TestNearestTotalHitsDeletedDoc.testDeletedDocNotCounted(TestNearestTotalHitsDeletedDoc.java:50)

Why it looks unintended

  • TotalHits defines EQUAL_TO as "The total hit count is equal to value"; nearest itself drops deleted documents from its hits.
  • The "exact" total can exceed reader.numDocs(). Lucene's count shortcuts trust such statistics only without deletions or when every document has the field.
  • No javadoc, test or review calls the total approximate; EQUAL_TO was added mechanically in 2018 (LUCENE-8430).
The full argument327 words
  • Contract. TotalHits.java:30-32: the relation says "Whether value is the exact hit count, in which case relation is equal to EQUAL_TO, or a lower bound"; :38 "The total hit count is equal to value". TopDocs.java:29: "The total number of hits for the query." Weight.java:182: an exact count "Counts the number of live documents that match".
  • Deleted documents are not hits. nearest filters them out of the hits (NearestNeighbor.java:156), so a total above reader.numDocs() is wrong under any definition of a hit.
  • Lucene's own count shortcuts. TermQuery.java:256, PointRangeQuery.java:353 and FieldExistsQuery.java:247-251 ("No deletions: we can use points or terms doc count as a proxy") use the statistic as a count only when hasDeletions() == false; FieldExistsQuery.java:261-270 also accepts the case where every document has the field ("All docs in the leaf (live or deleted) have the field. Return the count of live docs."). Among these count paths nearest is the outlier. They are precedents, not a universal rule: PointValues.getDocCount(IndexReader, field) (PointValues.java:132-147) computes the same deletion-blind sum, but as a documented statistic, not as a hit count.
  • Tests. TestNearest is the only core test that calls nearest; its deletion tests (:43, :76) check the hits only. A sandbox test (TestFloatPointNearestNeighbor.java:69) calls it once and reads scoreDocs only. No test reads its totalHits.
  • History. The getDocCount sum came with the method in 2016 (LUCENE-7069, sandbox), when totalHits was a plain number documented as the total number of hits; EQUAL_TO came from LUCENE-8430 (2018), which converted every TopDocs producer; LUCENE-10266 (#897, 2022) moved it to core with the counting loop and the EQUAL_TO return unchanged. No review thread, CHANGES or MIGRATE entry discusses the total.
  • Framing for the issue. Base it on the EQUAL_TO contract and on "exact total > reader.numDocs()", not only on the MatchAllDocsQuery equivalence (see the devil's advocate). The multi-valued oddity (documented duplicates can make scoreDocs.length exceed totalHits) is a side issue.
The devil's-advocate check231 words

The strongest "works as designed" case (built by the works-as-designed review and the devil's-advocate verifier; the cross-model reviews rate it moderate): nearest is a specialized BKD walk, not a Query, and the TopDocs javadoc scopes that class to hits "returned by IndexSearcher#search(Query,int)" (TopDocs.java:26); its own javadoc never says what totalHits holds; the total can be read as the field's physical cardinality, the deletion-blind sum that PointValues.getDocCount(IndexReader, field) also returns; the cheap O(segments) total has been there since 2016; an exact live count could need a pass over the points; LeafReader#searchNearestVectors documents its TotalHits as "the number of documents visited during the search" (LeafReader.java:249-252); and the MatchAllDocsQuery equivalence never pinned the total (sparse field, no deletions: nearest says 3, the equivalent 4).

Why it fails: nearest itself builds a TotalHits and labels it EQUAL_TO, which that record defines as the exact hit count, with no exception for callers outside IndexSearcher; nothing redefines TotalHits for LatLonPoint#nearest, and its javadoc presents it as functionally equivalent to a query; the physical-cardinality reading already has a documented home (PointValues.getDocCount) that does not claim to be a hit count; a total above the number of live documents is wrong under both readings (all live documents, or live documents with the field); and a lower bound marked GREATER_THAN_OR_EQUAL_TO keeps the O(segments) cost. Verdict: not intended, a defect.

Suggested fix

Recommended Exact when the field is dense, lower bound otherwise (recommended)

In the per-segment loop, keep getDocCount() when the segment has no deletions; if every document has the field (docCount == maxDoc) use numDocs(), which is exact; otherwise add the lower bound docCount - numDeletedDocs and report GREATER_THAN_OR_EQUAL_TO. It adds a few reader calls per segment. Validated on 28 test classes (255 tests, 0 failures), with assertions on and off.

int docCount = points.getDocCount(); // index statistic: ignores deletions
if (reader.hasDeletions() == false) {
  totalHits += docCount;
} else if (docCount == reader.maxDoc()) {
  // every document has the field, so the live documents that have it are exactly numDocs
  totalHits += reader.numDocs();
} else {
  // only a lower bound is cheap to compute
  totalHits += Math.max(0, docCount - reader.numDeletedDocs());
  exactTotalHits = false;
}
// after the loop:
// relation = exactTotalHits ? EQUAL_TO : GREATER_THAN_OR_EQUAL_TO
All options and trade-offs4 options ยท 695 words

Two fixes were built and checked; the recommended one keeps exact totals in the common case where every document has a location. Counting exactly in every case is possible but costs a pass over the points.

Exact when the field is dense, lower bound otherwise (recommended) (validated by the checks)

fix-dense-exact.diff, +19/-4 in LatLonPoint.java (with the two tests: fix-dense-exact-with-tests.diff). If every document of the segment has a point, the live documents with a point are exactly numDocs(); FieldExistsQuery.java:261-270 applies the same rule (and :233-234 for norms). Otherwise at most numDeletedDocs of the docCount documents are deleted, so docCount - numDeletedDocs is a valid lower bound.

int docCount = points.getDocCount(); // index statistic: ignores deletions
if (reader.hasDeletions() == false) {
  totalHits += docCount;
} else if (docCount == reader.maxDoc()) {
  // every document has the field, so the live documents that have it are exactly numDocs
  totalHits += reader.numDocs();
} else {
  // only a lower bound is cheap to compute
  totalHits += Math.max(0, docCount - reader.numDeletedDocs());
  exactTotalHits = false;
}
// after the loop:
// relation = exactTotalHits ? EQUAL_TO : GREATER_THAN_OR_EQUAL_TO

Trade-offs

  • A sparse field with deletions gets a lower bound ("3+ hits" in the sparse case, where 3 is the true count) instead of an exact number. The bound can be loose: it subtracts every deleted document, also those without a point, so it can be 0 when most deleted documents have no point.
  • It assumes a reader whose numDocs(), hasDeletions() and getLiveDocs() agree, as FilterLeafReader.java:37-38 asks of wrappers; an inconsistent wrapper is not repaired.
  • Not benchmarked; the added work is a few integer reads per segment for standard segment readers, which cache numDocs() (IndexReader#numDocs() may be O(maxDoc) in other readers).
  • Open draft #16847 edits the same lines (it widens int totalHits to long), so a PR may need a rebase.

What the checks validated

  • Effect on the probe cases: "2 hits", "10 hits" and "1000 hits", all EQUAL_TO; the no-deletion control is unchanged.
  • Gradle, clean checkout of ea57360912ed with the patch, seed DEADBEEF: the repro, the edge test, the finder's, the independent reproduction's, the default-config and the hidden-docs tests, TestNearest and every TestLatLon* suite in document and search: 28 classes, 255 tests, 0 failures, 19 skipped (18 nightly-only, 1 @Ignore).
  • Assertions on and off, recorded: the same 28 classes through the repro harness on the ea57360912ed jars with the patched class, -ea and -da: 236 passed, 0 failed, the same 19 skipped. (A second gradle run meant for assertions off gave the same counts, but its log does not record the flag.)
  • ./gradlew :lucene:core:check -x test (google-java-format, ecj lint including javadoc lint, forbidden APIs, licenses; error-prone skipped) passes with the patch and the two new tests. The same repro failed 2 of 2 in that checkout without the patch.

Lower bound for every segment with deletions (the hunt's fix sketch) (validated by the checks)

For segments with deletions add max(0, getDocCount - numDeletedDocs) and report GREATER_THAN_OR_EQUAL_TO. The simplest change.

Trade-offs

Any deletion turns the total into a lower bound, even where the exact number is free: "2+ hits", "10+ hits", "1000+ hits" in the probe cases.

What the checks validated

With the harness's shadow classes: the finder's, the independent reproduction's (4 run, 0 failed), the cross-model reviewers' and the default-config repros pass, and so does TestNearest (5 run, 0 failed); it also fixes 10.0.0's own file and the hidden-docs probe. The new repro passes; the edge test's two-segment case fails as designed (it expects EQUAL_TO).

Count exactly (not tested)

For sparse segments with deletions, count the live documents with the field, for example with an IntersectVisitor over all points that checks the live docs. On a multi-valued field the visitor sees a document once per value, so the count must de-duplicate doc ids.

Trade-offs

Exact in every case, but O(points) per such segment and call, which works against the method's purpose.

Document the current number (not a fix)

Say that totalHits counts documents that have the field, including deleted but unmerged ones.

Trade-offs

Keeps an EQUAL_TO label on a number that is not the hit count, which contradicts TotalHits.

How it was found

Code reading: an AI tool audited every Lucene method that returns a TotalHits labelled EQUAL_TO and flagged nearest, then wrote a JUnit test that fails on the stock main jar. No formal model was involved. Cross-model reviews and more AI verifiers reproduced it with their own tests, and an adjudication re-ran one of them with a new seed.

The method in full141 words

Method: code reading (the hunt's non-formal tier). The hunt unit on counts and totalHits made a grep inventory of every Weight#count override and every TotalHits(..., EQUAL_TO) producer, checking each against the contract "TotalHits never claims EQUAL_TO for a wrong count". LatLonPoint.nearest stood out because it labels an index statistic exact.

  • The finder turned it into a JUnit test with a no-deletion control and ran it on the stock main jar with assertions on and off, and on 10.0.0 and 10.5.2.
  • Before the candidate was filed, an independent reviewer from another model family (OpenAI GPT-5.6) tried to refute it, wrote its own committed-reader test, and voted real.
  • Then an independent reproducer with two oracles, a second GPT-5.6 review with a works-as-designed lens, a devil's advocate for intended behaviour and an adjudication, who re-ran the independent reproduction's test with a new seed.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-25/26): 166 tool queries over GitHub, four mailing lists, the JIRA archive and the web, 9 manual searches, the review threads of the code's history and the files of all 412 open PRs. Open draft #16847 edits the same lines but keeps the over-count.

Searches5 searches

Verdict new, confidence moderately high, not certain: GitHub search does not index PR diffs.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene); downstream repositories2026-10-25/264 runs of the hunt's prior-art search tool (166 queries in total, with the mailing-list, JIRA and web queries below), for example LatLonPoint nearest totalHits, NearestNeighbor deleted, getDocCount deleted totalHits, TotalHits EQUAL_TO deleted documents; 9 manual searchesno report of this bug
Mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web2026-10-25/26the tool's list, JIRA and web queries; two java-user@ threads on nearest read in fullnothing about the total
Review threads of the code's history2026-10-25/26#8125 (LUCENE-7069), #9023 (LUCENE-7974), LUCENE-8430, LUCENE-9820 (#7), #11302 / #897 (LUCENE-10266), #16154, #16847none discusses how the total is computed or treats deletions
Open PRs2026-10-25file lists of all 412 open apache/lucene PRs#16847 (draft) edits the same lines, widens int to long, keeps the over-count; #12162 does not touch nearest
Downstream code2026-10-25LatLonPoint.nearest in Elasticsearch, OpenSearch and Solrno call, no report

History

In core since LUCENE-10266 (#897, fcd98fd3370b, 2022-06-13), first released in 10.0.0, which copied the 2016 sandbox counting loop unchanged; EQUAL_TO dates from LUCENE-8430 (2018). All 12 10.x releases up to 10.5.2, 10.6.0 (by reading) and main are affected; not fixed upstream.

Versions tested5 rows

Not a regression. The getDocCount sum came with the method in the sandbox (LUCENE-7069, f6c7fc7a2658, 2016-04-14, released in 6.1.0 through its branch_6x backport 5bbb5e7748f1); LUCENE-8430 (9ca053712af0, 2018-07-30) labelled it EQUAL_TO when totalHits became a TotalHits object; LUCENE-10266 (#897, fcd98fd3370b, 2022-06-13, author shahrs87, merged by jpountz) moved it to core; the counting loop and the EQUAL_TO return were copied unchanged, only the javadoc and the exception contract were edited. Core LatLonPoint.java is the same blob in all twelve 10.x releases up to 10.5.2; branch_10_6 and branch_10x carry the same file, so the released 10.6.0 has it too.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedfails, assertions on and off; the recommended fix passes
10.0.0 to 10.5.2 (all 12 releases) affectedthe finder's JUnit fails on all 12 (also with assertions off on 10.0.0 and 10.5.2); the new repro fails on 10.0.0 and 10.5.2
6.1.0 to 9.12.3 sandbox onlyno nearest() in core; the sandbox copy gives the same wrong total (out of scope)
6.0.0, 6.0.1 no APIno nearest() at all
recommended fix on ea57360912ed not affectedrepro passes; 255 tests, 0 failures

Independent checks

The finder plus independent checks, all "real": a cross-model refute review, an independent reproducer with two oracles, a works-as-designed review, a devil's advocate and an adjudication who re-ran it with a new seed. The recommended fix was validated separately with gradle.

Checks and verdicts472 words

Every check ran on main ea57360912ed with assertions on and off unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateRepro 2 run, 1 failed with -ea and -da; the no-deletion control passes; also fails on 10.0.0 and 10.5.2.
Cross-model refute review real, highOpenAI GPT-5.6 wrote its own committed-reader test (EQUAL_TO 4 vs exact 3), agreed on the root cause, and confirmed the hunt's fix sketch.
Independent reproducer realOwn test with two oracles (MatchAllDocsQuery plus distance sort, and count(FieldExistsQuery)); hard delete, updateDocument and softUpdateDocument all inflate the total; 30 random-codec iterations: all 90 deletion runs fail, all 30 controls pass.
Works-as-designed review real, highOpenAI GPT-5.6 built the strongest intended case (cheap field-population metadata since 2016), rated it moderate and rejected it; for the same two live hits the total drops from 3 to 2 once deletions are expunged.
Devil's advocate realCommitted reader: EQUAL_TO total 3 with numDocs 2. Advised framing the issue on the EQUAL_TO contract, not the MatchAllDocsQuery equivalence.
Adjudication confirmed, highRe-ran the independent reproduction's test with seed 1A2B3C4D: 4 run, 3 failed with -ea and -da, control passes.
Security screen noneNo deleted or hidden document is returned; for a given field and reader the number does not depend on the query arguments.
Fix validation (this doc) recommended fix validatedGradle in a clean checkout of ea57360912ed: 28 classes, 255 tests, 0 failures; :lucene:core:check -x test passes. The same 28 classes through the harness with assertions on and off: 236 passed, 0 failed (checks/runs/suite28-fixB-*).
Independent fact check real; 6 wording fixes, appliedA separate AI tool checked every file:line, commit, count and quote, and re-ran the repro (main -ea and -da, a second seed, 10.0.0, 10.5.2), the edge test, the probe, the hidden-docs probe and both fixes. It found one factual slip (a sandbox test also calls nearest) and five attribution or wording nits; none affects whether the bug is valid.
Cross-model fact check factually sound; 17 items, appliedAn independent reviewer from another model family (OpenAI GPT-5.6) checked the brief against the code and the history: 4 errors (the total also depends on the field; an update can delete several documents; the independent reproduction equivalence number; the sandbox test), 6 overclaims (the 20% "bound", constant cost, "unchanged", "wherever", the assertions-off log, "no denial of service") and 7 missing caveats. It rated the intended-behaviour case moderate.
Diagram check passEvery value, API name, line number and the order of events in the PNG matched the code and the probe, which it re-ran. Its wording suggestions (when the live bit is cleared, the local total before td exists, the equivalence on the same documents, how long an old copy counts) were applied and the diagram re-rendered.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,062 words

Finding record

LFV-088, snapshot of 2026-11-06

Registry title
LatLonPoint#nearest reports an EQUAL_TO TotalHits that counts deleted documents
Area
document / LatLonPoint#nearest
Priority
Low, rank 344: The returned hits are correct; only the EQUAL_TO total of the niche LatLonPoint#nearest API also counts deleted documents.
Impact class
silent wrong results
Impact note
Callers of the public static LatLonPoint#nearest that read TopFieldDocs.totalHits on a reader with deleted-but-unmerged documents carrying the point field (every deleteDocuments, updateDocument or softUpdateDocument leaves one until a merge); default IndexWriterConfig, codec, merge policy and searcher; ordinary scale; the returned hits are correct, only the total is too high and labelled exact; released since 10.0.0.
Summary
You index documents with a LatLonPoint field, delete or update some of them, and call LatLonPoint.nearest(searcher, field, lat, lon, n) with n larger than the index. You expect TopFieldDocs.totalHits, which is labelled TotalHits.Relation.EQUAL_TO (exact), to count only documents that can be hits, here the hits returned. Instead the total is the per-segment sum of PointValues#getDocCount, an index statistic that still counts deleted documents, so 3 documents with one deleted return 2 hits but report exactly '3 hits', more than reader.numDocs().
Introduced
fcd98fd3370b (#897, LUCENE-10266 'Move nearest-neighbor search on points to core'), which copied the sandbox code into core unchanged [read: git show and git blame; exec: no 9.x release has core nearest(), 10.0.0 fails]. Not a regression: the getDocCount sum dates from f6c7fc7a2658 (LUCENE-7069, 2016, sandbox) and the EQUAL_TO label from 9ca053712af0 (LUCENE-8430, 2018, a mechanical change when totalHits became a TotalHits object) [read] (2022-06-13 (in core; sandbox origin 2016-04-14, EQUAL_TO label 2018-07-30))
First affected version
10.0.0 (first release with nearest() in core; the same expression shipped in the sandbox from 6.1.0 to 9.12.3, out of scope)
Affected versions
every 10.x release before 10.6.0, 10.0.0 to 10.5.2 (12 releases), and main ea57360912ed [exec: the finder's JUnit on all 12 releases and main with -ea, and on 10.0.0, 10.5.2 and main with -da; the independent reproduction's own test on 10.0.0, 10.5.2 and main]; no 9.x release has nearest() in core [exec: the JUnit does not compile on 9.2.0, 9.3.0 and 9.12.3]; branch_10_6 and branch_10x carry the 10.5.2 file unchanged, so the released 10.6.0 has it too [read, 2026-10-25]; not fixed upstream [read]; the sandbox predecessors 6.1.0-9.12.3 give the same wrong total [exec: cross-version main() probe; out of scope]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-25/26: a scripted prior-report search, 4 runs with 166 queries over GitHub issues/PRs and commits, downstream repos, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 9 manual read-only gh searches on apache/lucene PRs and on Elasticsearch, OpenSearch and Solr; the review threads of the issues and PRs that created, changed or moved the code; file lists of all 412 open apache/lucene PRs): no report, no upstream fix, no open PR fixes it; confidence moderately high, not certain (GitHub search does not index PR diffs). Related prior items, none of them this bug: #8125 (LUCENE-7069, origin of the code), LUCENE-8430 (added the EQUAL_TO label), #11302 / #897 (LUCENE-10266, move to core), #9023 (LUCENE-7974, same pattern copied into the sandbox FloatPointNearestNeighbor), open draft #16847 (edits the same lines, widens int to long, keeps the over-count), #11078 / #239 (LUCENE-10040, deletions in kNN vector search, a different API)
Lucene commit tested
main ea57360912ed (2026-10-25/26), assertions on and off; the Maven Central jars of all 12 releases from 10.0.0 to 10.5.2 (assertions on; 10.0.0 and 10.5.2 also off); a cross-version probe over 92 releases from 6.0.0 on (the sandbox method before 10.0.0); the recommended fix in a clean checkout of ea57360912ed with gradle, and through the repro harness with assertions on and off (2026-10-26).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:379-403 (nearest javadoc; :383 'functionally equivalent to running MatchAllDocsQuery with a LatLonDocValuesField#newDistanceSort'), :421-430 (per-leaf loop; :425 'totalHits += points.getDocCount();', live docs collected only for NearestNeighbor), :442 (new TotalHits(totalHits, TotalHits.Relation.EQUAL_TO))
  • lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:156 (the visitor skips documents whose live bit is clear)
  • lucene/core/src/java/org/apache/lucene/search/TotalHits.java:28-41 (relation: EQUAL_TO = exact hit count, GREATER_THAN_OR_EQUAL_TO = lower bound)
  • lucene/core/src/java/org/apache/lucene/search/TopDocs.java:29 ('The total number of hits for the query.')
  • lucene/core/src/java/org/apache/lucene/index/PointValues.java:495-496 (getDocCount: 'the total number of documents that have indexed at least one point')
  • lucene/core/src/java/org/apache/lucene/search/Weight.java:182 ('Counts the number of live documents that match')
  • lucene/core/src/java/org/apache/lucene/search/TermQuery.java:256, PointRangeQuery.java:353, FieldExistsQuery.java:247 (docFreq / getDocCount used as a count only when the leaf has no deletions)
  • lucene/sandbox/src/java/org/apache/lucene/sandbox/document/FloatPointNearestNeighbor.java:317, :333 (same pattern in the sandbox; read only)
  • lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/NearestNeighborBenchmark.java (the only in-tree non-test caller of LatLonPoint.nearest)
  • SECURITY.md:28, 50 (threat model)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:379-403, :421-430, :442
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:156
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/PointValues.java:495-496
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHits.java:28-41
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:121-124 (deletesPctAllowed, "Default value is 20.")
  • Lucene main ea57360912ed: lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/NearestNeighborBenchmark.java:109
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1901-1948 (updateDocument / softUpdateDocument act on every document matching the term)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:364-365, :520-524 (deletesPctAllowed used to choose merges)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SoftDeletesRetentionMergePolicy.java:36-42 (carries soft-deleted documents across merges)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHits.java:28-41; search/TopDocs.java:29; search/Weight.java:182
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TermQuery.java:256; search/PointRangeQuery.java:353; search/FieldExistsQuery.java:247-251
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/LeafReader.java:244-252 (searchNearestVectors TotalHits)
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestNearest.java:43, :76
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TopDocs.java:26 (class javadoc: hits returned by IndexSearcher#search(Query,int))
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/PointValues.java:132-147 (static getDocCount(IndexReader, field): the same deletion-blind sum)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FieldExistsQuery.java:261-270 (count == maxDoc returns numDocs; count trusted only without deletions)
  • Lucene main ea57360912ed: lucene/sandbox/src/test/org/apache/lucene/sandbox/document/TestFloatPointNearestNeighbor.java:69 (the only other test call of LatLonPoint.nearest; reads scoreDocs only)
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FieldExistsQuery.java:233-234 (dense shortcut precedent)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FieldExistsQuery.java:261-270 (the same rule in count())
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/FilterLeafReader.java:37-38 (override numDocs() together with getLiveDocs())
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexReader.java:292-297; index/SegmentReader.java:234-236 (numDocs() cached in segment readers)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

An independent diagram check compared every value, API name, line number and the order of events on screen, and this alt text, with the code at ea57360912ed and the probe output; its wording suggestions were applied and the diagram re-rendered.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.