All findings · Lucene finding · LFV-701 · latlon-docvalues-contains-multipoint-any-not-all

Doc-values CONTAINS with several points matches documents that hold only one of them

Ask for the documents that contain both point P1 and point P2: LatLonPoint.newGeometryQuery returns only those, but its documented doc-values partner LatLonDocValuesField.newSlowGeometryQuery returns every document holding P1 or P2 ([A, B, C] instead of [C]). So IndexOrDocValuesQuery answers depend on which side runs, in every release since 8.8.0.

Summary

Priority
Medium, rank 198The core LatLonDocValuesField CONTAINS query over two or more points silently computes OR instead of AND since 8.8.0, but multi-point CONTAINS on a point field is niche and no user report was found, so it follows the spatial3d wrong-result row LFV-247 and precedes the spatial-extras rows.
Impact
silent wrong results
Since
8.8.0 (LUCENE-9641, 2021), with the featureintroduced 2021-01-08 · about 5 years
Affected
8.8.0 to 10.5.2 and main
Status
documented
Found by
differential check of spatial queries against an exact geometry reference

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

CONTAINS with several points should match only documents that hold all of them. The points query does that with one MUST clause per point; the doc-values query accepts a document as soon as one of its points equals one query point.

Summary card: LatLon doc-values CONTAINS with several points: one point is enough (OR), not all (AND). Four documents, A holds P1, B holds P2, C holds P1 and P2, D holds P3, each indexed as LatLonPoint and LatLonDocValuesField. Query CONTAINS(P1, P2), the documents that hold both points. Points side, +CONTAINS(P1) +CONTAINS(P2), an AND: C, correct. Doc-values side, one answer for all pairs, an OR: A, B, C, with A and B extra. Code from LatLonDocValuesQuery.contains(): line 252 answer = DISJOINT, lines 261-262 answer = relation for any non-DISJOINT pair, line 266 return answer == CANDIDATE. IndexOrDocValuesQuery runs it under a cheap lead and in explain(), so explain() can report a match that search() does not return. Results executed on main, expected versus actual: LatLonPoint CONTAINS(P1, P2) C and C; LatLonDocValuesField CONTAINS(P1, P2) C but A, B, C; the same on a doc-values-only field C but A, B, C; doc values CONTAINS(P1, P2, P3) none but A, B, C, D; id:A plus IndexOrDocValuesQuery, on 2,004 documents (the four plus 2,000 one-point documents), none but A; explain of IndexOrDocValuesQuery for A no match but match; control, doc values CONTAINS(P1), A, C and A, C. A fix sketch that keeps one flag per query point makes every row match. Since 8.8.0: LUCENE-9641 (2021) added both sides.
The repro's four documents: the points query keeps only C, the doc-values query keeps A, B and C, because one answer for all (document point, query point) pairs is set by any match. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index four documents with LatLonPoint and LatLonDocValuesField on one field: A = {P1}, B = {P2}, C = {P1, P2}, D = {P3}.
  2. Ask for CONTAINS(P1, P2), the documents whose points include both P1 and P2. You expect [C], and LatLonPoint.newGeometryQuery returns [C].
  3. LatLonDocValuesField.newSlowGeometryQuery, which its javadoc tells you to pair with that query, returns [A, B, C]: every document holding P1 or P2, exactly what INTERSECTS(P1, P2) returns.
  4. So the documented pair IndexOrDocValuesQuery(points, docValues) depends on which side runs: with 2,000 more one-point documents it returns [C] on its own, but id:A AND it returns A, which holds only P1, and explain() says A matches.
  5. Why: the doc-values CONTAINS keeps one flag per document and sets it as soon as any document point equals any query point; nothing checks that every query point was found. The points query adds one MUST clause per point.

Who is affected

Anyone running CONTAINS with two or more points on a LatLonDocValuesField: on its own (for example a doc-values-only field), through the documented IndexOrDocValuesQuery pair under a selective clause, or via that pair's explain(). Default configuration, any index size; extra hits only. Rare: a niche query; no user report was found since 2021.

Mechanism, terms and numbers587 words

LatLonDocValuesField.newSlowGeometryQuery(field, CONTAINS, p1, p2, ...) should match the documents whose points include every query point, as LatLonPoint.newGeometryQuery(field, CONTAINS, p1, p2, ...) does with one MUST clause per point. Its javadoc says to wrap it in an IndexOrDocValuesQuery alongside exactly that query, and IndexOrDocValuesQuery requires that "Both provided queries must match the same documents". Instead LatLonDocValuesQuery.contains() keeps a single flag per document and sets it as soon as any document point equals any query point, so it computes an OR: on four documents A = {P1}, B = {P2}, C = {P1, P2}, D = {P3}, CONTAINS(P1, P2) returns [C] on the points path and [A, B, C] on the doc-values path, the INTERSECTS hit set. The wrapper's answer then depends on which side runs: with 2,000 more one-point documents it returns [C] alone, A under id:A AND, and its explain() reports a match for A. No error; default configuration; extra hits only. LUCENE-9641 (14009f4424d, 8.8.0) added the AND and the OR in the same commit; every release since is affected, and a one-method fix that requires every query point passes the repro and the closest existing suites.

Terms

  • Point fields: LatLonPoint indexes a location in a points (BKD) index; LatLonDocValuesField stores it as doc values, read one document at a time. A document may hold several points on one field.
  • CONTAINS on points: newGeometryQuery(field, CONTAINS, p1, ..., pk) and newSlowGeometryQuery(field, CONTAINS, p1, ..., pk) accept only Point geometries for CONTAINS (others give MatchNoDocsQuery, LatLonDocValuesField.java:255-262). A query point is contained when a document point equals it on the encoding grid: the query point is rounded up to the grid (Point2D.java:168-181) and compared with exact == (Point2D.java:56-57).
  • IndexOrDocValuesQuery: wraps a points query and an equivalent doc-values query and picks a side per segment: the doc-values one only when the points cost shifted right by 3 (divided by 8, rounded down) is greater than the lead clause's cost, cost() >>> 3 > leadCost (IndexOrDocValuesQuery.java:183-193), otherwise the points one; explain() and matches() always use the doc-values one (:153-163). "Both provided queries must match the same documents" (:61-62).

The bug

  1. LatLonDocValuesQuery.contains() (LatLonDocValuesQuery.java:244-273) builds one component per query point (:245-248), because a multi-component tree cannot answer withinPoint (ComponentTree.java:284-289).
  2. Per document it starts with answer = DISJOINT (:252) and, for every (document point, query point) pair, sets answer to any relation other than DISJOINT (:261-262); it returns answer == CANDIDATE (:266).
  3. A point component answers only CANDIDATE or DISJOINT (Point2D.java:126-127), so the NOTWITHIN exit never fires and the document matches as soon as one of its points equals one query point: an OR.
  4. LatLonPoint.newGeometryQuery sends CONTAINS to makeContainsGeometryQuery, one MUST LatLonPointQuery(CONTAINS, point) per point (LatLonPoint.java:337-354): an AND. LatLonShape and XYShape also AND one clause per geometry.

The numbers

P1 = 22.5° N 45° E, P2 = 45° N 135° E, P3 = 22.5° S 90° W (exact on the grid); A = {P1}, B = {P2}, C = {P1, P2}, D = {P3}. CONTAINS(P1): [A, C] on both paths. CONTAINS(P1, P2): points [C], doc values [A, B, C], the same as INTERSECTS(P1, P2). With 2,000 more one-point documents (alternately at P1 and P2): points and the wrapper alone [C], doc values 2,003 hits instead of 1, FILTER(id:A) + FILTER(wrapper) [A], wrapper explain(A).isMatch() true. The same on main and 10.5.2 (assertions on and off), 10.0.0 and 9.12.3.

Exposure and likelihood303 words
  • Who is exposed. Applications that call newSlowGeometryQuery(field, CONTAINS, p1, p2, ...) directly (for example on a field with doc values but no points index), or the documented pair IndexOrDocValuesQuery(LatLonPoint.newGeometryQuery(...), newSlowGeometryQuery(...)) when a selective lead clause makes it run the doc-values side, or explain()/matches() of that pair, which always use the doc-values side, so explain() can report a match that search() does not return.
  • Default, not opt-in. Default IndexWriterConfig, codec and searcher; both factories are public and not deprecated (IndexOrDocValuesQuery is @lucene.experimental). Four documents suffice for the doc-values query alone; the wrapper runs the doc-values side only under a lead clause more than 8 times cheaper than the points query (exactly: cost() >>> 3 > leadCost).
  • What you see. Extra hits, silently: documents that hold some but not all of the query points. The doc-values CONTAINS(P1, ..., Pk) returns the INTERSECTS hits. Documents that hold all points still match.
  • Query cache. A cached entry is built from the points side (LRUQueryCache.java:609 with IndexOrDocValuesQuery.java:196-201), so where a query cache is in use the same query can change its answer once it is cached (read from the code, not executed). That needs a cache configured explicitly (main's IndexSearcher has none by default), a segment the cache accepts (the default LRUQueryCache skips segments under 10,000 documents) and a query used often enough for the caching policy; neither repro index qualifies.
  • Not affected. Single-point CONTAINS, the other relations, the points-only query; XYDocValuesField has no relation-taking factory.
  • How likely. Rare: "documents that hold all of these locations" is a niche query, and the searches of 2026-10-27 found no user report since 8.8.0. A downstream remark (code reading only, not executed): one downstream search engine builds exactly this pair for its geo-point field and uses the doc-values query alone for doc-values-only fields.

Minimal repro

The test shows that LatLonDocValuesField.newSlowGeometryQuery(CONTAINS, (0, 0), (10, 10)) matches a document whose only point is (0, 0), so the doc-values CONTAINS is an OR over the query points, while its documented IndexOrDocValuesQuery partner LatLonPoint.newGeometryQuery requires every point.

TestLatLonDocValuesContainsTwoPoints.java (JUnit test)48 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.document;

import java.io.IOException;
import org.apache.lucene.document.ShapeField.QueryRelation;
import org.apache.lucene.geo.Point;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestLatLonDocValuesContainsTwoPoints extends LuceneTestCase {

  public void testDocWithOnlyOneOfTwoPointsDoesNotMatch() throws IOException {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new LatLonDocValuesField("p", 0, 0));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Point[] both = {new Point(0, 0), new Point(10, 10)};
        Query q = LatLonDocValuesField.newSlowGeometryQuery("p", QueryRelation.CONTAINS, both);
        // expected 0 (doc lacks (10, 10); LatLonPoint.newGeometryQuery gives 0), actual 1
        assertEquals(0, new IndexSearcher(reader).count(q));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-701/, 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.document.TestLatLonDocValuesContainsTwoPoints
Expected vs actual
Expected: 0 hits (the document lacks (10, 10)). Actual on main with -ea and with -da: 1 hit (expected:<0> but was:<1>).

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

More files, environment, full output34 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:<0> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.document.TestLatLonDocValuesContainsTwoPoints.testDocWithOnlyOneOfTwoPointsDoesNotMatch(TestLatLonDocValuesContainsTwoPoints.java:44)

Why it looks unintended

  • The javadoc pairs it with LatLonPoint#newGeometryQuery in an IndexOrDocValuesQuery, which requires: "Both provided queries must match the same documents".
  • LatLonPoint, LatLonShape and XYShape all add one MUST clause per CONTAINS geometry; the commit that added both paths says "Equivalent to LatLonShape".
  • Read as OR, the doc-values CONTAINS is just INTERSECTS; no test sends more than one CONTAINS point.
The full argument193 words
  • The pairing is documented. LatLonDocValuesField.java:232-238: "It is best used wrapped in an {@link IndexOrDocValuesQuery} alongside a {@link LatLonPoint#newGeometryQuery(String, ShapeField.QueryRelation, LatLonGeometry...)}". IndexOrDocValuesQuery.java:61-62: "Both provided queries must match the same documents and give the same scores." The code relies on it: "Both index and dv query should return the same values" (:212-213).
  • CONTAINS over several geometries is an AND elsewhere. LatLonPoint.java:343-354, LatLonShape.java:354-370 and XYShape.java:263-267 add one MUST clause per geometry.
  • One commit added both. 14009f4424d (LUCENE-9641, 2021-01-08): "Equivalent to LatLonShape, LatLonPoint can be queried now using spatial relationships." It added the AND and the OR together, with no comment on a difference; issue #10680 states the goal that LatLonShape and LatLonPoint return the same results. contains() is unchanged since.
  • The author's intent elsewhere. The LUCENE-9641 author's downstream test expects 0 hits for CONTAINS(MULTIPOINT(a, b)) on a document holding only a (it runs the points side). Their open PR #12162 documents CONTAINS for newSlowGeometryQuery as "one or more points of the field matches all the provided LatLonGeometry".
  • The test gap. BaseSpatialTestCase.java:562-565 sends only queryPoints[0] for CONTAINS, so the two paths were never compared with several points. No test asserts the OR.
The devil's-advocate check233 words

The strongest case for "intended": LatLonPoint.newGeometryQuery's javadoc says "@return query matching points within at least one geometry" (LatLonPoint.java:322), which reads like OR; the pairing is only "best used"; a committer has said relations make little sense for points; and no javadoc spells out multi-point CONTAINS. Independent reviews by another model family (OpenAI GPT-5.6, refute lens and intended lens) rated this moderate; the devil's advocate rejected it point by point; the adjudication called it weak. A maintainer may also cite the IndexOrDocValuesQuery class note ("only works well with point range/exact queries and their equivalent doc values queries", IndexOrDocValuesQuery.java:51-52).

Why it fails: that @return line dates from LUCENE-9552, before relations existed, and the same method builds an AND for CONTAINS, so it does not follow it either. The javadoc names the exact counterpart with the same arguments, and the wrapper forbids operands that disagree. The committer's remark is about API design; both factories are public and shipped since 8.8.0. Under OR, CONTAINS would duplicate INTERSECTS. The IndexOrDocValuesQuery class note is about performance ("works well") and does not relax "must match the same documents"; newSlowGeometryQuery's own javadoc recommends this wrapping. #12162's wording is not literally the per-point AND (read literally, one document point equal to every query point, it would reject C too), but neither reading admits A or B, which hold only one of the two points. Verdict: a defect.

Suggested fix

Recommended Require every query point in contains() (validated)

In LatLonDocValuesQuery.contains(), remember for each query point whether some document point equals it, and match only when all are found: one reused boolean[] and a counter, +11/-6. The new test passes 2 of 2, and 31 existing LatLon, XY and IndexOrDocValuesQuery suites (224 tests) pass with assertions on and off.

final boolean[] contained = new boolean[component2Ds.size()];
// in matches():
Arrays.fill(contained, false);
int numContained = 0;
// for each document point (lat, lon) and each query component c:
Component2D.WithinRelation relation = component2Ds.get(c).withinPoint(lon, lat);
if (relation == Component2D.WithinRelation.NOTWITHIN) {
  return false;
} else if (relation == Component2D.WithinRelation.CANDIDATE && contained[c] == false) {
  contained[c] = true;
  numContained++;
}
// after the loop:
return numContained == contained.length;
All options and trade-offs4 options · 557 words

One fix was built and validated through gradle; the hunt validated a variant of it; two alternatives were not built.

Require every query point in contains() (validated) (recommended)

In LatLonDocValuesQuery.contains(): keep one flag per query component in a boolean[] allocated once per iterator, reset it per document, count the components found, and match only when all are found; NOTWITHIN still rejects at once. 1 file, +11/-6.

final boolean[] contained = new boolean[component2Ds.size()];
// in matches():
Arrays.fill(contained, false);
int numContained = 0;
// for each document point (lat, lon) and each query component c:
Component2D.WithinRelation relation = component2Ds.get(c).withinPoint(lon, lat);
if (relation == Component2D.WithinRelation.NOTWITHIN) {
  return false;
} else if (relation == Component2D.WithinRelation.CANDIDATE && contained[c] == false) {
  contained[c] = true;
  numContained++;
}
// after the loop:
return numContained == contained.length;

Trade-offs

Same loop and cost as today plus an Arrays.fill of k booleans per document (k = number of query points); no allocation per document. The array is created once per scorer supplier (per segment or intra-segment partition, and also when IndexOrDocValuesQuery then runs the points side, since it creates both suppliers first); the stock code already allocates a list and one Component2D per query point there. One-point CONTAINS behaves as before. The diff keeps the full loop; it could return true as soon as every point is found, since only Point geometries reach contains() and they never return NOTWITHIN. No benchmark was run.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test fails 2 of 2 with assertions on and off; + fix it passes 2 of 2 with assertions on and off.
  • With the fix, the existing TestLatLon*, TestXY* and TestIndexOrDocValuesQuery: 31 suites, 224 tests, 0 failures, 25 skipped (24 @Nightly), assertions on and off (gradle's totals of 32 suites and 226 tests include a first version of the new test); the two doc-values point suites with -Ptests.nightly=true: 10 tests, 0 failures; :lucene:core:check -x test passes.
  • Standalone runner with the fix: the finder's test, the independent reproducer's 4 tests, the devil's advocate's probe and the cross-model reviewers' own tests pass.
  • Not run: the full Lucene test suite, and any benchmark.

The same logic with a boolean[] per matches() call (the hunt's fix) (validated by the checks)

Functionally the same as the recommended option, but allocates the array for every candidate document.

Trade-offs

One small allocation per candidate document; the recommended option avoids it.

What the checks validated

Through the standalone runner: the finder's, the independent reproducer's, the devil's advocate's and the cross-model reviewers' tests pass, and 49 of 49 tests of four existing doc-values and point suites.

Build the doc-values CONTAINS like the points one (not built)

In newSlowGeometryQuery, for CONTAINS with several points, return a ConstantScoreQuery over one MUST LatLonDocValuesQuery(CONTAINS, point) per point, mirroring makeContainsGeometryQuery.

Trade-offs

Symmetric and obviously equivalent, but each clause reads the document's doc values again (k reads per document), the query's structure changes (toString, equals), and contains() stays an OR for any other caller.

Document OR semantics (not recommended)

Declare that CONTAINS with several points on the doc-values path matches any of them.

Trade-offs

Makes it identical to INTERSECTS and still breaks the IndexOrDocValuesQuery contract unless the points CONTAINS changes too, which would break parity with LatLonShape, the goal of LUCENE-9641.

Downstream note

An application that uses a multi-point CONTAINS on a point field as a restriction ("only documents that hold all of these locations") also gets documents that hold just one of them whenever the doc-values side runs.

How it was found

A bounded-exhaustive differential check: the same grid-exact documents were indexed as shapes, as points and as doc-values points, queried with all four relations, and compared with an exact geometry reference (JTS). Only the doc-values point path disagreed, on multi-point CONTAINS; the cause was then read from the code.

The method in full183 words

Method: bounded-exhaustive differential testing against an exact reference (the hunt's semi-formal tier).

  • The hunt unit compared Lucene's shape and point relation queries with JTS 1.20 DE-9IM relations on the decoded coordinates, with a strict and a permissive boundary band per relation, so a boundary convention cannot show up as a bug.
  • Inputs: lattices of 6 x 6 grid-exact coordinates in nine families (around the origin, the poles and the antimeridian, and XY), every point, segment and small polygon of the lattice plus random ones, multi-part and multi-point documents, and 2-3-point CONTAINS queries, each document indexed as LatLonShape, LatLonPoint and LatLonDocValuesField.
  • At scale 1 the doc-values point path disagreed with the reference and with both index paths in 606 (document, query) pairs in six of the seven LatLon families (490 with a multi-point document, 116 with a one-point document) and 585 in the dateline family, all multi-point CONTAINS; the index paths agreed with the reference. The root cause was read from LatLonDocValuesQuery.contains() and reduced to a public-API JUnit.
  • The same harness also explained every other mismatch by known bugs or documented conventions.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-27, re-checked the same day): about 140 queries over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream repositories and the web, plus the changed files of all 409 open Lucene PRs. Nearest: open PR #12162, which documents an all-geometries CONTAINS and pairs both queries without changing the code.

Searches4 searches

No issue, PR, JIRA ticket, list thread, downstream report or web page describes the doc-values CONTAINS matching documents that hold only some of the query points, or the two paths disagreeing.

Closest, not this bug: #12162 (open since 2023-02, last human comment 2023-03; adds the all-geometries CONTAINS javadoc and a LatLonField that wraps both factories; leaves contains() alone, so the new field would inherit the bug), #10680 (LUCENE-9641, the introducing change), #12154 (closed unmerged; moved contains() unchanged), #10635 (LUCENE-9595, a different withinPoint defect) and #16847 (open; touches the file only for maxDoc).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, commits, downstream repositories, mailing lists, JIRA archive, web2026-10-27two scripted prior-report search runs (52 and 45 queries; 436 and 398 distinct hits, the 3 rated high match by term overlap only); the finder's run (51 queries, 6 web queries left pending; 35 hits)only the related items above
GitHub (apache/lucene, apache/lucene-solr and downstream repositories)2026-10-2741 manual issue and PR searches and 4 code searches (read-only)no report
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRsonly #16847 touches LatLonDocValuesQuery.java (maxDoc only); #12162 touches the two field classes
GitHub, re-check for this doc2026-10-276 read-only searches by class, factory and symptom; every issue and PR created since 2026-10-26nothing new

History

Introduced with the feature in 8.8.0 by LUCENE-9641 (14009f4424d, 2021-01-08), which made the points CONTAINS an AND and the doc-values CONTAINS an OR in the same commit. A version-portable main() fails on all 18 releases tried from 8.8.0 to 10.5.2 and on main; contains() is unchanged since.

Versions tested8 rows

Introduced by LUCENE-9641 "Support for spatial relationships in LatLonPoint" (@iverase, 14009f4424d, 2021-01-08; apache/lucene-solr#2155, issue #10680), backported to branch_8x as f73f6b11f0e (apache/lucene-solr#2188) and released in 8.8.0 (CHANGES: "LatLonPoint query support for spatial relationships"). Before it, 8.7.0 had no relation-taking factories. git log -L and git blame attribute every line of contains() to that commit; later commits to the file are scorer plumbing. Not fixed upstream.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 2 of 2 fail (gradle and the standalone runner, assertions on and off); the main() port fails (-ea and -da)
10.5.2, 9.12.3 affectedthis doc's test 2 of 2 fail (10.5.2 with assertions on and off)
10.2.2, 10.3.0, 10.4.0 affectedmain() port: points 0, doc values 1000, wrapper under a lead 1
10.0.0, 10.1.0, 10.2.0 affectedmain() port: doc values 1000; the MUST-lead wrapper form returned 0 because a separate, fixed planner bug (#14542) ran the points side; the FILTER-lead form returns 1; this doc's test (FILTER lead) 2 of 2 fail on 10.0.0
9.0.0, 9.1.0, 9.11.1, 9.12.0 affectedmain() port: points 0, doc values 1000, wrapper under a lead 1
8.8.0, 8.8.1, 8.8.2, 8.9.0, 8.10.1, 8.11.4 affectedmain() port: points 0, doc values 1000, wrapper under a lead 1
8.7.0 API absentthe relation-taking factories do not exist; the port does not compile
main + fix not affected0 everywhere; this doc's test 2 of 2 pass

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6, each running its own tests) call it real; the adjudication re-ran the tests and confirmed it with high confidence; a registry re-run and an audit reproduced it again.

Checks and verdicts387 words

Every check ran tests on the stock jars, with assertions on and off. The cross-model reviews executed their own tests on main ea57360912ed (no geo, LatLon, XY, spatial or IndexOrDocValuesQuery source differs from a9779ef3c193); This doc's own runs are on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateDifferential check against JTS (606 mismatched pairs in six LatLon families and 585 in the dateline family, all multi-point CONTAINS on the doc-values path), then a public-API JUnit that fails on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix.
cross-model review, refute lens real (high); intended argument moderateOpenAI GPT-5.6, executed: own parity test (indexed 1, doc values 3) and own wrapper test (target matched through the wrapper) fail on main with three seeds; the fix passes both and 49 of 49 existing tests.
Independent reproducer realOwn 4-test JUnit outside the Lucene package: 3 of 4 fail on main (-ea, -da, two seeds), 10.5.2 and 9.12.3 (101 documents instead of 1; explain() disagrees with search()); 4 of 4 pass with the fix.
Cross-model review, intended lens real (high); intended argument moderateOpenAI GPT-5.6, executed: traced the "at least one geometry" wording to LUCENE-9552; own test (indexed 1, doc values 257) fails on main and 10.5.2, passes with the fix.
Intended-behaviour devil's advocate real (high)No javadoc, test, CHANGES entry or discussion endorses the OR; the doc-values CONTAINS hit set equals INTERSECTS on main, 10.5.2 and 9.12.3; passes with the fix.
adjudication confirmed (high)Re-ran the independent reproduction's test with a third seed (3 of 4 fail, -ea and -da; 4 of 4 with the fix) and the finder's repro.
registry re-run and audit keepThe finder's JUnit re-run fails with seed 5EEDF00D (-ea and -da) and A0D17F00 (-ea) and passes with the fix (5EEDF00D, -ea); an independent probe gives the same hit sets and the explain() mismatch with -ea and -da.
this doc reproduced; fix validatedNew 2-test JUnit on the diagram's four documents fails 2 of 2 on main (gradle and standalone, -ea and -da), 10.5.2, 10.0.0 and 9.12.3; passes with the fix, as do the earlier reproducers and the closest existing suites.

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,327 words

Finding record

LFV-701, snapshot of 2026-11-06

Registry title
LatLonDocValuesField.newSlowGeometryQuery with CONTAINS and two or more Point geometries matches documents that hold only some of the query points, while its documented IndexOrDocValuesQuery partner LatLonPoint.newGeometryQuery requires all of them
Area
core / document: LatLon point geometry queries (LatLonDocValuesQuery CONTAINS on the doc-values path vs LatLonPoint.newGeometryQuery, and their IndexOrDocValuesQuery pairing)
Priority
Medium, rank 198: The core LatLonDocValuesField CONTAINS query over two or more points silently computes OR instead of AND since 8.8.0, but multi-point CONTAINS on a point field is niche and no user report was found, so it follows the spatial3d wrong-result row LFV-247 and precedes the spatial-extras rows.
Impact class
silent wrong results
Impact note
Applications that run a CONTAINS geometry query with two or more Point geometries against a LatLonDocValuesField (default IndexWriterConfig, codec and searcher, any index size, -ea and -da, every release from 8.8.0 to 10.5.2 and main) get extra hits, documents that hold some but not all of the query points, whenever the doc-values side runs: newSlowGeometryQuery on its own (for example on a doc-values-only field), IndexOrDocValuesQuery(LatLonPoint.newGeometryQuery, newSlowGeometryQuery) under a selective lead clause, and IndexOrDocValuesQuery's explain(); the points-only query is correct, documents that hold all of the points still match, and single-point CONTAINS and the other relations are not affected.
Summary
You index points with LatLonPoint and LatLonDocValuesField and ask for the documents that contain both P1 and P2 (CONTAINS with two Point geometries); a document that holds only P1 must not match, and LatLonPoint.newGeometryQuery, which builds one MUST clause per query point, indeed matches none. LatLonDocValuesField.newSlowGeometryQuery, which its javadoc says to pair with that query in an IndexOrDocValuesQuery (whose two queries 'must match the same documents'), accepts a document as soon as any one of its points equals any one query point, so it computes an OR instead of an AND. In the repro (1000 one-point documents, half at P1 and half at P2) the points query returns 0 hits, the doc-values query returns all 1000, and 'id:7 AND IndexOrDocValuesQuery(points, docValues)' returns document 7, with no error.
Introduced
14009f4424d847ce84fa23a71d8066dfe6c70da0 (LUCENE-9641 'Support for spatial relationships in LatLonPoint', apache/lucene-solr#2155, issue now apache/lucene#10680; author iverase; branch_8x backport f73f6b11f0ed4bcbfce445152d813adb3327d249, apache/lucene-solr#2188, shipped in 8.8.0) [read: git log -L and git blame attribute every line of LatLonDocValuesQuery.contains() to it; its parent has no relation-taking factories, so the defect cannot be expressed there; exec: the repro does not compile against 8.7.0 because the API is absent]. The same commit made LatLonPoint.newGeometryQuery(CONTAINS) a conjunction (makeContainsGeometryQuery; commit message 'Equivalent to LatLonShape'), so the bug is as old as the feature. (2021-01-08)
First affected version
8.8.0 [exec] (the relation-taking factories first appear there; 8.7.0 lacks them [exec: compile error])
Affected versions
Every release that has the API: 8.8.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2, and main a9779ef3c193; never fixed. [exec] the version-portable main() port fails on 8.8.0, 8.8.1, 8.8.2, 8.9.0, 8.10.1, 8.11.4, 9.0.0, 9.1.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.2.2, 10.3.0, 10.4.0, 10.5.2 and main (-ea and -da), with LatLonPoint CONTAINS(P1,P2) = 0 and LatLonDocValuesField CONTAINS(P1,P2) = 1000 (expected 0) on every one; the finder's JUnit fails on main (-ea, -da), 10.5.2, 10.0.0, 9.12.3 and 9.1.0; the independent reproduction's and the intended-behaviour check's own tests fail on main, 10.5.2 and 9.12.3. On 10.0.0-10.2.0 the MUST(id)+FILTER(IndexOrDocValuesQuery) form returned 0 because a separate, fixed planner bug (apache/lucene#14542, fixed by #14543 in 10.2.1 and 10.3.0) made IndexOrDocValuesQuery run the points side; the doc-values query alone and the FILTER(id)+FILTER(IndexOrDocValuesQuery) form still fail there. The LatLonDocValuesQuery.contains() source is identical in the 35 local release tags from 8.8.0 to 10.5.2 and on main, and the LatLonDocValuesQuery class files of the 8.8.0 and 8.11.4 jars are byte-identical [read]. All runs with JDK 25 and SHA-1-checked release jars.
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
semi-formal: exhaustive check
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-27, read-only: two scripted prior-report searches with 52 and 45 queries over GitHub issues, PRs and commits, downstream repositories, the mailing lists, JIRA and the web (436 and 398 distinct hits; the 3 rated high match by term overlap only); 41 more GitHub issue and PR searches in apache/lucene, apache/lucene-solr and downstream repositories, 4 code searches, the file lists of all 409 open apache/lucene PRs, and the full history of LatLonDocValuesQuery.java; the finder's unit-level run (51 queries, 6 web queries left pending; 35 hits, 0 high) and the independent reproduction's and the intended-behaviour check's searches agree). Nearest items, none of them this bug: open PR apache/lucene#12162 (iverase, 'Add LatLonField class to index both LatLonPoint and LatLonDocValues', open since 2023-02-20, last human comment 2023-03-28, stale-bot labels since) adds CONTAINS javadoc 'Matches a documents if one or more points of the field matches all the provided LatLonGeometry' (an all-geometries wording) and pairs both factories in a new LatLonField without changing contains(), so the new field would inherit the path-dependent result; #10680 (LUCENE-9641) introduced both behaviours with the stated goal that LatLonShape and LatLonPoint return the same results; #12154 (closed unmerged) moved contains() unchanged; #16847 (open) touches the file only for maxDoc; #10635 (LUCENE-9595) is a different withinPoint defect.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars. Earlier checks: a version-portable main() on 18 releases from 8.8.0 to 10.5.2 and on main (follow-up); the cross-model reviews ran on main ea57360912ed, where none of the geo, LatLon, XY, spatial or IndexOrDocValuesQuery sources differ from a9779ef3c193. The fix was validated in the scratch worktree: the new test with assertions on and off, 31 of the closest existing suites (224 tests) with assertions on and off, the two doc-values point suites in nightly mode, and :lucene:core:check -x test.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesQuery.java:244-273 (contains(): a single 'answer' set to any non-DISJOINT relation of any (doc value, query point) pair; line 266 'return answer == Component2D.WithinRelation.CANDIDATE')
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:322 (generic '@return query matching points within at least one geometry'), :337-338 (CONTAINS goes to makeContainsGeometryQuery) and :343-354 (one MUST LatLonPointQuery(CONTAINS, point) per point in a ConstantScoreQuery)
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java:232-247 (newSlowGeometryQuery javadoc: 'It is best used wrapped in an IndexOrDocValuesQuery alongside a LatLonPoint#newGeometryQuery(String, ShapeField.QueryRelation, LatLonGeometry...)') and :248-264 (CONTAINS accepts Point geometries only, else MatchNoDocsQuery)
  • lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:61-62 ('Both provided queries must match the same documents and give the same scores.')
  • lucene/core/src/java/org/apache/lucene/geo/Point2D.java:126-127 (withinPoint returns only CANDIDATE or DISJOINT, so the NOTWITHIN early exit never fires for points)
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:337 and :354-366, XYShape.java:263-267 (the shape factories also AND one CONTAINS clause per geometry)
  • lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java:563-564 (the randomized test sends only queryPoints[0] for CONTAINS)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesQuery.java:244-273 (contains(); :252, :261-262, :266)
  • lucene/core/src/java/org/apache/lucene/geo/Point2D.java:56-57, :126-127 (exact equality; withinPoint returns CANDIDATE or DISJOINT)
  • lucene/core/src/java/org/apache/lucene/geo/ComponentTree.java:284-289 (withinPoint throws for more than one component)
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:337-354 (CONTAINS -> makeContainsGeometryQuery, one MUST clause per point)
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java:232-263 (javadoc pairing; CONTAINS accepts Point geometries only)
  • lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:61-62, :153-163, :183-193
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:54, :153-163, :183-193, :196-201
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:609 (cache built from scorerSupplier.bulkScorer())
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84 (no default query cache); LRUQueryCache.java:252 (MinSegmentSizePredicate(10000))
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java:232-238, :244
  • lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:61-62, :212-213
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:322, :343-354; LatLonShape.java:354-370; XYShape.java:263-267
  • lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java:562-565
  • lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java:51-52 (class note)
History
  • lucene/CHANGES.txt:5448 (8.8.0 New Features)

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

Repro files

About the diagrams

Made in parallel with this doc; every element was checked against main a9779ef3c193 and an executed diagram probe on the same four documents, plus 2,000 one-point documents for the IndexOrDocValuesQuery lead case (assertions on and off). Its fix step shows the hunt's variant of the fix (a boolean[] per call), with a note to avoid that allocation.

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.