All findings · Lucene finding · LFV-852 · monitor-dismax-decomposition-sums-disjunct-scores

Monitor scores a DisjunctionMaxQuery as the sum of its matching disjuncts

The Monitor splits a registered DisjunctionMaxQuery into one query per field and its scoring matchers add the parts back up, so a two-field match gets the sum of the field scores (0.3105659) instead of the best one (0.1798013). Matching is right; scores are wrong in every release since 8.2.0.

Summary

Priority
Low, rank 367Score-only, in the optional monitor module's scoring matchers with a DisjunctionMaxQuery tie-breaker below 1.
Impact
silent wrong results
Since
8.2.0 (2019), the module's first releaseintroduced 2019-06-13 · about 7 years
Affected
8.2.0 to 10.6.0 and main (6 releases and 10.6.0 run)
Status
documented
Found by
bounded-exhaustive comparison with IndexSearcher

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-03.

The finding in 30 seconds

The Monitor splits a stored query into parts so it can pick candidate queries fast, then adds up the scores of the parts that match. That is right for a BooleanQuery disjunction and wrong for a DisjunctionMaxQuery, which scores its best part.

Three panels. 1: the query (title:apple | body:apple) at tie 0 on the document title "apple apple", body "apple banana cherry date" is split into title:apple (0.1798013) and body:apple (0.13076457); the Monitor's ScoringMatch reports 0.3105659 = their sum, IndexSearcher 0.1798013. 2: bars for tie 0, 0.5 and 1: IndexSearcher 0.1798013, 0.24518359, 0.3105659; Monitor 0.3105659 each time. 3: the javadoc's albino-elephant batch: IndexSearcher A 0.31506687, B 0.3979403 (B above A); Monitor A = B = 0.3979403.
The repro's example: the decomposer splits (title:apple | body:apple) into two parts, the Monitor adds their scores (0.3105659) where IndexSearcher takes the max (0.1798013), the tie-breaker has no effect, and the javadoc's albino-elephant ranking becomes a tie. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Register (title:apple | body:apple) with tie-breaker 0 in a default Monitor, and match the document {title: "apple apple", body: "apple banana cherry date"} with ScoringMatch.DEFAULT_MATCHER (BM25).
  2. You expect the query's own score. Here title:apple scores 0.1798013 and body:apple 0.13076457, and IndexSearcher gives the DisjunctionMaxQuery 0.1798013: the best field, "not the sum of the field scores", as its javadoc says.
  3. You get 0.3105659 = 0.1798013 + 0.13076457. The tie-breaker has no effect (tie 0.5 should give 0.24518359), and ExplainingMatch shows the same value as "sum of:". No error.
  4. Why: the default QueryDecomposer splits the query into one part per field and never reads the tie-breaker. Both parts carry the query's id, and ScoringMatch.resolve adds their scores.
  5. The javadoc's own example breaks: for "albino elephant", a document with albino and elephant should outrank one with albino in both fields (IndexSearcher: 0.3979403 vs 0.31506687). The Monitor gives both 0.3979403.

Who is affected

Applications that use lucene-monitor and read scores through ScoringMatch (also via ParallelMatcher or PartitionMatcher) or ExplainingMatch, with a registered DisjunctionMaxQuery whose tie-breaker is below 1. Default Monitor configuration, any scale: every document on which two or more distinct disjuncts score above 0 is over-scored. Matching and highlighting are unaffected.

Mechanism, terms and numbers600 words

The Monitor (lucene-monitor) matches documents against stored queries. To select candidate queries quickly, its default QueryDecomposer splits a registered DisjunctionMaxQuery into one query per disjunct and drops the tie-breaker. When several parts of one query match a document, ScoringMatch and ExplainingMatch merge them by adding their scores. A DisjunctionMaxQuery, however, scores the best disjunct plus tie-breaker times the others, "not the sum of the field scores (as BooleanQuery would give)". So for every registered DisjunctionMaxQuery with a tie-breaker below 1 and every document on which two or more distinct disjuncts of it score above 0, the Monitor reports the sum: (title:apple | body:apple) at tie 0 gets 0.3105659 instead of 0.1798013, the javadoc's albino-elephant ranking becomes a tie, and the tie-breaker has no effect. Which queries match is unchanged. It is in every release since the module was imported (LUCENE-8766, 8.2.0). Keeping a DisjunctionMaxQuery whose tie-breaker is not 1 whole in the decomposer (+5 lines, two decomposer tests updated) fixes it, and the whole monitor module passes with that change.

Terms

  • Monitor (lucene-monitor, org.apache.lucene.monitor): stores queries and matches incoming documents against them. A matcher chosen per match call decides what is reported: SIMPLE_MATCHER (query ids), ScoringMatch (ids and scores), ExplainingMatch (ids and an Explanation), HighlightsMatch (matching terms).
  • QueryDecomposer: splits a registered query into parts that are indexed and run separately, so the presearcher can select only the parts whose terms occur in the document. Each part keeps the registered query's id.
  • DisjunctionMaxQuery (a | b)~tie: scores a document with its best disjunct plus tie times the other matching disjuncts (tie in [0, 1]).

The bug

  1. QueryDecomposer.decompose replaces a DisjunctionMaxQuery by the decompositions of its disjuncts and never reads getTieBreakerMultiplier() (QueryDecomposer.java:46-52).
  2. QueryCacheEntry.decompose stores each part under the parent's query id (QueryCacheEntry.java:51-60), and the Monitor runs every part the presearcher selects on its own (Monitor.java:294-299).
  3. CandidateMatcher.addMatch merges two matches of one id with resolve() (CandidateMatcher.java:75-85). ScoringMatch.resolve returns match1.getScore() + match2.getScore() (ScoringMatch.java:42-44); ExplainingMatch.resolve builds a "sum of:" explanation (ExplainingMatch.java:44-53).
  4. So the reported score is the sum of the matching disjuncts, max + others, instead of max + tie * others (DisjunctionMaxQuery.java:238-244). It is too high by (1 - tie) * others when the tie-breaker is below 1 and two or more distinct disjuncts score above 0; with two equal disjuncts at tie 0 it is doubled.

The sum is right for a BooleanQuery disjunction (it scores the sum of its matching SHOULD clauses), for tie 1, and when only one disjunct scores above 0. A BooleanQuery that the decomposer keeps whole (a MUST or FILTER clause next to other clauses, or minimumShouldMatch > 1) is scored right too. Matching is unchanged: a part is kept only with a score above 0, and the sum is above 0 exactly when some disjunct scores above 0, which is when the whole query does.

The numbers

Document {title: "apple apple", body: "apple banana cherry date"}, BM25, IndexSearcher on a one-document index vs ScoringMatch.DEFAULT_MATCHER:

queryIndexSearcherMonitor
title:apple alone0.17980130.1798013
body:apple alone0.130764570.13076457
DisjunctionMaxQuery of both, tie 00.17980130.3105659
same, tie 0.10.192877750.3105659
same, tie 0.50.245183590.3105659
same, tie 10.31056590.3105659
title:apple body:apple (BooleanQuery)0.31056590.3105659
the tie-0 query plus SHOULD body:banana (BooleanQuery)0.31056590.44133046

The javadoc's example, one batch of two documents, (title:albino | body:albino) (title:elephant | body:elephant) at tie 0: A {albino, albino} and B {albino, elephant}. IndexSearcher: A = 0.31506687, B = 0.3979403 (B ranks first). Monitor: A = 0.082873434 + 0.31506687 = 0.3979403 and B = 0.3979403, a tie.

Exposure and likelihood214 words
  • Who. lucene-monitor users with the default QueryDecomposer (MonitorConfiguration.java:38) who read scores through ScoringMatch (directly or through ParallelMatcher / PartitionMatcher, which report the same sums) or ExplainingMatch, and register a DisjunctionMaxQuery with tie < 1: on its own, in a BooleanQuery SHOULD clause (or as its only MUST clause), or under a BoostQuery.
  • Default vs opt-in. The Monitor configuration and the decomposer are the defaults; lucene-monitor is an optional module, and the matcher is chosen per match call. DisjunctionMaxQuery is built in code or by the XML query parser; the classic, multi-field and standard parsers build BooleanQuery.
  • Scale. None needed: one query and one document show it, deterministically, with assertions on and off.
  • Effect. Too-high scores for multi-field matches, which can reorder or tie stored queries and documents (the javadoc's albino-elephant ranking is lost). Applications that rank or threshold on Monitor scores see it. The set of matching queries is right; nothing fails or is corrupted, and the values look plausible.
  • How likely. Rare: it needs the scoring matchers together with DisjunctionMaxQuery; the one public Monitor integration found (an open Solr pull request) uses constant scores, and no public code combining the two was found. The lack of reports since 2019 is weak evidence, because the wrong values look reasonable.

Minimal repro

The test shows that Lucene Monitor's ScoringMatch reports a registered DisjunctionMaxQuery with tie-breaker 0 as the sum of its matching disjuncts, because the default QueryDecomposer splits it and the scoring matcher adds the parts.

TestMonitorDisMaxScore.java (JUnit test)53 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.monitor;

import java.io.IOException;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.Term;
import org.apache.lucene.index.memory.MemoryIndex;
import org.apache.lucene.search.DisjunctionMaxQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMonitorDisMaxScore extends LuceneTestCase {

  public void testDisMaxTieZero() throws IOException {
    Analyzer analyzer = new StandardAnalyzer();
    Document doc = new Document();
    doc.add(new TextField("title", "apple apple", Field.Store.NO));
    doc.add(new TextField("body", "apple", Field.Store.NO));
    Query dismax =
        new DisjunctionMaxQuery(
            List.of(
                new TermQuery(new Term("title", "apple")), new TermQuery(new Term("body", "apple"))),
            0f);
    float expected = MemoryIndex.fromDocument(doc, analyzer).search(dismax);
    try (Monitor monitor = new Monitor(analyzer)) {
      monitor.register(new MonitorQuery("q", dismax));
      ScoringMatch match = monitor.match(doc, ScoringMatch.DEFAULT_MATCHER).matches("q");
      // expected the query's own score (max of the two field scores); actual: their sum
      assertEquals(expected, match.getScore(), 0f);
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-852/, 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:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/monitor/build/libs/lucene-monitor-$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.monitor.TestMonitorDisMaxScore
Expected vs actual
Expected the query's own score on the document, max of the two field scores (0.1798013); actual: their sum (0.3105659 = 0.1798013 + 0.1307646), with -ea and -da.

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

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.1798013> but was:<0.3105659>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:577)
       org.junit.Assert.assertEquals(Assert.java:701)
       org.apache.lucene.monitor.TestMonitorDisMaxScore.testDisMaxTieZero(TestMonitorDisMaxScore.java:50)

Why it looks unintended

  • DisjunctionMaxQuery's javadoc wants the best field's score, "not the sum of the field scores", and its albino-elephant example is lost.
  • The Monitor javadoc reports "the score that each one records against a Document"; nothing defines sums of parts.
  • The only upstream test pinning a score value pins the whole query's score; the split tests check shape only.
The full argument283 words
  • Monitor contract. package-info.java:40-45: ScoringMatch.matchWithSimilarity "returns the set of matching queries, with the score that each one records against a Document"; ExplainingMatch is "similar to ScoringMatch, but include the full Explanation". The natural reading is the query's own score; no aggregate of decomposed parts is mentioned.
  • DisjunctionMaxQuery contract. DisjunctionMaxQuery.java:29-43: the maximum score "plus a tie breaking increment for any additional matching subqueries ... We want the primary score to be the one associated with the highest boost, not the sum of the field scores (as BooleanQuery would give)", followed by the albino-elephant example that the Monitor erases.
  • No sum-of-parts semantic is documented. QueryDecomposer's javadoc says only "Split a disjunction query into its constituent parts, so that they can be indexed and run separately"; CandidateMatcher.resolve says "combine them". No CHANGES or MIGRATE entry discusses Monitor scores of decomposed queries.
  • The only test that pins a score value. ConcurrentMatcherTestBase.testMatchesAreDisambiguated says each decomposed BooleanQuery "ends up with the sum of the scores for the 'test' and 'doc' parts" and asserts 1.4874471; the devil's-advocate probe showed that this is IndexSearcher's score for the whole BooleanQuery. The sum was meant to reproduce the query's own score. The only other score assertion, TestSimilarities.testNonStandardSimilarity, compares two similarities on a one-term query, which is not decomposed.
  • The split tests. TestQueryDecomposer.testDisjunctionMaxDecomposition and testNestedDisjunctionMaxDecomposition assert that a tie-0.1 DisjunctionMaxQuery is split: its shape, which serves the presearcher. No test asserts its score.
  • History. The predecessor library (Luwak) already split DisjunctionMaxQuery but kept the larger score in resolve; the 2019 import (LUCENE-8766) changed resolve to a sum, with no recorded discussion. Each rule is right for one query family and wrong for the other, because the split discards the tie-breaker.
The devil's-advocate check188 words

The strongest works-as-designed case (the devil's advocate, the cross-model reviewer, and the finder's earlier reading-only cross-model review with the intended-behaviour lens, which ran no code and judged the bug real): both halves are deliberate and tested. TestQueryDecomposer asserts the split, the import deliberately changed max to sum, resolve's javadoc mentions "two branches of a disjunction", and the decomposer is a public customization point, so a maintainer may say Monitor scores are aggregates of the executed parts and that exact DisjunctionMaxQuery scores are available with a decomposer that keeps it whole. Monitor scores come from a tiny per-batch index anyway.

Why it fails: nothing documents a sum-of-parts semantic, the package javadoc most naturally reads as the query's own score, and the only upstream test that pins a score value pins the whole query's score. DisjunctionMaxQuery's javadoc rejects exactly this value. The split tests check matching shape, not scores, and a workaround is not a contract. Every repro's reference uses the same documents, so the gap is only how the parts are recombined. Verdict: not intended; a defect since the import. At worst upstream answers with a javadoc change.

Suggested fix

Recommended B: do not split a DisjunctionMaxQuery whose tie-breaker is not 1 (QueryDecomposer)

In QueryDecomposer.decompose, return a DisjunctionMaxQuery whose tie-breaker is not 1 whole instead of splitting it (+5 lines), and update the two decomposer tests that assert the split to tie 1. The whole monitor module passes (201 tests), the repros included, with assertions on and off, and its static checks pass.

if (q instanceof DisjunctionMaxQuery dmq) {
  // The scoring matchers add up the scores of a query's parts, but a DisjunctionMaxQuery
  // scores max + tieBreakerMultiplier * others, which is a sum only for a tie-breaker of 1
  if (dmq.getTieBreakerMultiplier() != 1f) {
    return Collections.singleton(q);
  }
  Set<Query> subqueries = new LinkedHashSet<>();
  for (Query subq : dmq.getDisjuncts()) {
    subqueries.addAll(decompose(subq));
  }
  return subqueries;
}
All options and trade-offs5 options · 726 words

Two fixes were built and run in a scratch worktree at a9779ef3c193. B changes only the DisjunctionMaxQuery branch of the decomposer and is recommended; A keeps the split for the presearcher and runs the registered query whole, which also fixes the two sibling decomposition bugs.

B: do not split a DisjunctionMaxQuery whose tie-breaker is not 1 (QueryDecomposer) (validated by the checks) (recommended)

The finder's one-condition fix with a reworded comment, plus a test update: the two split tests in TestQueryDecomposer use tie 1.0 (where splitting is exact), and a new test asserts that a tie-0.1 DisjunctionMaxQuery stays whole, also inside a BooleanQuery next to a decomposed clause with an exclusion. +5 lines in QueryDecomposer.java, +20/-2 in the test.

if (q instanceof DisjunctionMaxQuery dmq) {
  // The scoring matchers add up the scores of a query's parts, but a DisjunctionMaxQuery
  // scores max + tieBreakerMultiplier * others, which is a sum only for a tie-breaker of 1
  if (dmq.getTieBreakerMultiplier() != 1f) {
    return Collections.singleton(q);
  }
  Set<Query> subqueries = new LinkedHashSet<>();
  for (Query subq : dmq.getDisjuncts()) {
    subqueries.addAll(decompose(subq));
  }
  return subqueries;
}

Trade-offs

  • A DisjunctionMaxQuery with tie < 1 becomes one presearcher entry instead of one per disjunct and runs whole whenever it is selected. Matching is unchanged (the module's matching and presearcher tests pass); the cost was not benchmarked. The public entry counts change too: Monitor.getDisjunctCount() and the QueryCacheStats counts drop for such queries.
  • Inside a BooleanQuery or under a BoostQuery it is still a part of its own, so the tested pure-SHOULD cases add up right (the nested and albino-elephant cases pass).
  • It changes the decomposition that two upstream tests assert at tie 0.1; without the test update exactly those 2 of the module's tests fail.
  • It fixes only this bug, not the two sibling decomposition bugs (a FILTER clause scored, repeated sub-queries collapsed).

What the checks validated

Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the whole :lucene:monitor:test with the 3 repro classes added, 35 classes, 201 tests, 0 failures, 1 skipped (a @Nightly test), with assertions on and off; all 11 repro tests that fail on stock pass. :lucene:monitor:check -x test with the fix and the primary test (google-java-format, ecj lint, forbidden APIs): BUILD SUCCESSFUL. The finder's condition alone, without the test update: 200 tests, exactly the 2 split tests fail.

A: keep the split for the presearcher, run the registered query whole (Monitor, QueryCacheEntry) (validated by the checks)

Each QueryCacheEntry also keeps its parent MonitorQuery's query, and Monitor.StandardQueryCollector runs that whole query once per query id when the presearcher selects any of its parts. +17/-3 lines in QueryCacheEntry.java and Monitor.java; written as a sketch.

Trade-offs

  • Also fixes the two sibling bugs: their repros fail 3 of 4 each on stock main and pass with this change.
  • It changes every matcher, not only the scoring ones: the simple and highlighting matchers also run the whole query.
  • getQueriesRun() still counts the selected parts (kept so the upstream count of 20 in ConcurrentMatcherTestBase holds), so it no longer equals the number of queries executed.
  • A large disjunction runs whole when one of its parts is selected, every cache entry keeps a reference to its parent's whole query (memory), and each match run keeps a set of the ids already run. Not benchmarked; a design change for maintainers to weigh.

What the checks validated

Gradle with assertions on, the whole :lucene:monitor:test with the 3 repro classes and the 2 sibling repros: 37 classes, 208 tests, 0 failures, 1 skipped. Not run with assertions off or through check.

Document that Monitor scores are sums of the decomposed parts

A note in the package javadoc and on ScoringMatch / ExplainingMatch.

Trade-offs

A legitimate maintainer choice, but the albino-elephant ranking stays broken and the tie-breaker keeps having no effect. Not run.

Teach resolve() the DisjunctionMaxQuery rule

Combine matches with max + tie * others when they come from a DisjunctionMaxQuery.

Trade-offs

resolve sees only two matches, not which query they came from, so it cannot tell a BooleanQuery's parts from a DisjunctionMaxQuery's without new plumbing. Not run.

Workaround today: a decomposer that keeps DisjunctionMaxQuery whole

A QueryDecomposer subclass whose decompose returns a DisjunctionMaxQuery as a singleton, set with new MonitorConfiguration().setQueryDecomposer(...). No Lucene change.

Trade-offs

Gives IndexSearcher's exact score (the independent reproducer's control and the devil's-advocate probe); every application has to know about it.

Downstream note

Only scores change, never which queries match, so products that route or alert on Monitor matches are unaffected, while products that rank or threshold on ScoringMatch scores see inflated values for multi-field DisjunctionMaxQuery matches.

How it was found

A planning probe flagged suspect ScoringMatch scores on decomposed queries. The finder compared six Monitor matchers with IndexSearcher over every small query and document in a bounded space plus 3,000 random larger queries (6.5 million checks). Of several mismatch families, this one vanished when the decomposer kept a tie < 1 DisjunctionMaxQuery whole; a Z3 model confirmed the condition.

The method in full242 words

Method: bounded-exhaustive translation validation against a reference semantics (the hunt's "semi-formal" tier).

  • The hunt's planning probe for this area compared ScoringMatch with IndexSearcher on decomposed queries and gave the first signal; the hunt unit re-ran and reduced it.
  • The finder's harness ran six matchers (simple, scoring, explaining, highlighting, parallel, partition) on the stock decomposer, and three of them on six decomposer variants, against IndexSearcher on a one-document index: every depth-1 query (420) on 55 documents, every depth-2 query (7,685) on 19 documents, and 3,000 random depth-3 queries on 34 documents; 6,506,760 checks with assertions on and the same with them off.
  • Root attribution with public decomposer subclasses: the variant that keeps a DisjunctionMaxQuery whole when its tie-breaker is not 1 (fix B's rule) was the only single change that removed 272, 2,760 and 3,409 score-mismatch rows in the three tiers, for the scoring and the explaining matcher each; further rows were removed by it and by a sibling variant alike. Other variants explain the other families: the two sibling bugs (a FILTER clause, collapsed repeated parts) and a separately registered minimumShouldMatch bug.
  • A Z3 model of the score algebra (19 obligations over the reals) confirmed that the split preserves the score under sum-resolve only for tie 1 or one matching disjunct, and that with tie < 1 and two matching disjuncts that both score above 0 it always differs.
  • The finder then wrote the public-API JUnit test, and independent checkers reproduced it.

Prior reports, history and checks

Prior reports

No prior report found (140 recorded queries over GitHub issues and PRs, every open PR's file list, the mailing lists, the JIRA archive, the predecessor library's tracker, downstream projects and the web, 2026-10-28). The nearest item is the 2019 import itself (#9811), whose discussion never mentions scores.

Searches3 searches

Registry verdict new: nobody else has reported or discussed this bug. Nearest items, none a report: #9811 / LUCENE-8766 (the import that introduced the sum; no discussion of scores), #14886 / #14885 (decomposer iteration order, matching only), #13109 / #13632 (matcher visibility), #16350 (bulk scoring disabled in the Monitor, scores unchanged), apache/solr#2382 (an open Monitor integration that uses constant scores), Luwak #112 (2016: made the existing DisjunctionMaxQuery split recursive; no discussion). Open PRs #12295, #14660 and #16847 touch the same files but change neither the split nor resolve. Limits: keyword search only; one GitHub rate-limit hit was retried; the Luwak tracker was searched by keyword; GitHub search does not index diffs (open PRs were checked by file list).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; downstream trackers; web2026-10-28a scripted prior-report search, two runs, 96 queries (ScoringMatch, ExplainingMatch, QueryDecomposer with DisjunctionMaxQuery, monitor score, luwak score, resolve sum)no report; top hits #13109, #16350, #14885, #15659, LUCENE-8766
apache/lucene PRs and issues2026-10-28the file lists of all 411 open PRs; 58 PRs closed or updated since 2026-10-10; the last 48 h; all 50 module:monitor items; 27 monitor/luwak JIRA issuesno report; #12295, #14660, #16847 touch the files but not the split or resolve
Luwak (the predecessor library), Solr, Monitor wrapper projects, GitHub code search2026-10-28keyword searches of the Luwak tracker and code; apache/solr#2382; two wrapper projects; code search for decomposer subclassesno report; Luwak kept the max in resolve; the Solr PR uses constant scores

History

In the code since the Monitor was imported from the Luwak library (LUCENE-8766, 251dbe7cea8 and the 8.x backport b8773d13ad8, 2019-06-13); first released in 8.2.0. The finder's test fails with identical values on every executed release from 8.2.0 to 10.5.2, on 10.6.0 (branch_10_6 4ae946e5a9b) and on main; 8.1.1 has no Monitor module.

Versions tested7 rows

Introduced by LUCENE-8766 "Add monitor subproject" (#9811; the import of the Luwak monitoring library), by @romseygeek, author date 2019-06-13: 251dbe7cea8126cb7ecadc921ba444c63d06c618 on the main line (in 9.0.0 and later) and b8773d13ad8e249a045862249bf476c30a444461 on branch_8x (byte-identical files under test; first released in 8.2.0). git blame puts the split, both resolve bodies and addMatch on that commit; later edits are refactors (#14885 HashSet to LinkedHashSet, an instanceof pattern, an unnamed lambda parameter). Luwak already split DisjunctionMaxQuery but kept the larger score in resolve; the import changed it to a sum (read, not executed). Upstream main c5ab3e57b9b (2026-10-28), branch_10_6 and branch_10x have the same logic (the branches differ only in syntax and a javadoc typo); nothing has fixed it. The history clone lacks the 8.9.0-8.11.3 tags; those were read on the GitHub release tags.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe 3 repro classes fail through gradle (11 of 30), assertions on and off
branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29; GitHub head on 2026-10-28) affectedthe finder's test fails 2 of 4 (10.6.0-SNAPSHOT jars built from that commit)
10.5.2, 9.12.3 affectedthe primary test fails 4 of 5; the finder's and the independent reproducer's tests fail, -ea and -da
10.0.0, 9.0.0, 8.11.4 affectedthe finder's test fails 2 of 4 with the same values
8.2.0 first releasethe finder's test fails 2 of 4, -ea and -da
8.1.1 no modulelucene-monitor does not exist yet (no artifact; the test does not compile)
fix B on a9779ef3c193 not affectedthe whole monitor module, repros included: 201 tests, 0 failures, assertions on and off

Independent checks

An independent reproducer, the test of the AI tool that drove the cross-model review, a devil's-advocate probe set and an adjudication all reproduced it on main with assertions on and off; the finder's and the reproducer's tests pass with the fix. The other-family reviewer itself could only read code and returned its procedural default.

Checks and verdicts317 words

Each check ran or read on its own; the registration check re-ran the tests with fresh seeds, and this doc re-ran them through gradle on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder (bounded-exhaustive comparison, Z3 algebra, JUnit) candidateJUnit fails on main (-ea, -da), branch_10_6, 10.5.2, 9.12.3 and 8.11.4; passes with the one-condition fix; the sibling fixes leave it failing.
independent reproducer realOwn test, own package, own reference index: shows the value is the sum (0.1798013 + 0.13076457), widens it to tie 0.5, ExplainingMatch, a nested DisjunctionMaxQuery and the albino-elephant batch; 5 controls pass. Fails on main, 10.5.2 and 9.12.3 (-ea, -da).
cross-model review, refute lens not real, low (procedural); root cause agreesAn independent reviewer from another model family (OpenAI GPT-5.6) could only read code and voted not real on procedure only; its objections were the fix design and a works-as-designed case. The AI tool that drove the review wrote and ran its own test (three fields, a boosted DisjunctionMaxQuery, ClassicSimilarity), which fails on main and 10.5.2: 1.9999999 expected, 4.568914 reported.
intended-behaviour devil's advocate real, medium-highBuilt the works-as-designed case and rejected it with four probes: the one upstream score constant is the whole query's score; ExplainingMatch says "sum of:" where IndexSearcher says "max of:"; the albino-elephant ranking is lost; a keep-whole decomposer is exact.
adjudication confirmed, mediumRe-ran the independent reproducer's test with a new seed (-ea and -da): 5 of 10 fail, all controls pass. Medium because a maintainer may call it a limitation and fix the javadoc.
this doc's gradle runs stock fails, fixes passStock main: 11 of 30 repro tests fail with assertions on and off, every control passes. Fix B: the whole monitor module, 201 tests, 0 failures, on and off; static checks pass. Fix A: 208 tests, 0 failures (assertions on), the sibling repros included.

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

Finding record

LFV-852, snapshot of 2026-11-03

Registry title
Lucene Monitor's ScoringMatch and ExplainingMatch score a registered DisjunctionMaxQuery with a tie-breaker below 1 as the sum of its matching disjuncts instead of max + tieBreaker * others
Area
monitor / query decomposition and match scoring (QueryDecomposer DisjunctionMaxQuery split, ScoringMatch/ExplainingMatch resolve)
Priority
Low, rank 367: Score-only, in the optional monitor module's scoring matchers with a DisjunctionMaxQuery tie-breaker below 1.
Impact class
silent wrong results
Impact note
Applications that use lucene-monitor with its default QueryDecomposer and read match scores through ScoringMatch (directly or through ParallelMatcher/PartitionMatcher) or ExplainingMatch get, for every registered DisjunctionMaxQuery with tieBreakerMultiplier < 1 (also nested in a BooleanQuery SHOULD clause or a BoostQuery) and every document on which two or more of its disjuncts match, the sum of the disjunct scores instead of the query's own score (0.3105659 instead of 0.1798013 at tie 0 in the independent reproduction's test), which can erase the ranking DisjunctionMaxQuery exists to give; which queries match is unchanged (SIMPLE_MATCHER and HighlightsMatch are not affected), no scale is needed, and it is in every release since 8.2.0.
Summary
You register a DisjunctionMaxQuery such as (title:apple | body:apple) with tie-breaker 0 in a Monitor and match a document that has apple in both fields with ScoringMatch or ExplainingMatch, expecting the score the query itself gives that document: the best field's score plus tie times the others, which is what IndexSearcher returns (0.1798013 in the independent reproduction's test). Instead the Monitor reports the sum of both field scores (0.3105659), because the default QueryDecomposer splits the DisjunctionMaxQuery into one query per disjunct and the scoring matchers add up the scores of the parts. In the 'albino elephant' example of DisjunctionMaxQuery's own javadoc, this makes the document with 'albino' in both fields tie with the document that matches both words, which the query is designed to rank higher.
Introduced
251dbe7cea8126cb7ecadc921ba444c63d06c618 on the main line and b8773d13ad8e249a045862249bf476c30a444461 on branch_8x (its files under test are byte-identical), both 'LUCENE-8766: Add monitor subproject' by romseygeek (JIRA LUCENE-8766, now GitHub #9811; PR apache/lucene-solr#648, closed without a GitHub merge, code committed directly) [read: git blame -w with .git-blame-ignore-revs on the history clone: the DisjunctionMaxQuery split in QueryDecomposer.decompose, ScoringMatch.resolve (sum), ExplainingMatch.resolve ('sum of:') and CandidateMatcher.addMatch all blame to 251dbe7cea8; later commits to these lines are refactors that keep the behaviour (b59848c7049 instanceof pattern, 2527992f266 / #14885 HashSet to LinkedHashSet, ab453b9541a unnamed lambda parameter)]. Not a regression: present since the module's first release. Origin outside Lucene [read, not executed]: the predecessor library Luwak already split DisjunctionMaxQuery (Luwak PR #112, 2016) but resolved matches of one query id by keeping the larger score; the import changed resolve to a sum, which made Boolean disjunctions right and DisjunctionMaxQuery with tie < 1 over-scored (2019-06-13 (251dbe7cea8 and b8773d13ad8, author date 2019-06-13T09:40:57+01:00); the Luwak DisjunctionMaxQuery split (Luwak PR #112) dates from 2016-12)
First affected version
8.2.0 [exec] (first release with lucene-monitor; 8.1.1, the release before, has no lucene-monitor artifact on Maven Central (HTTP 404) and the repro does not compile against its core, so no earlier release is unaffected: the API did not exist)
Affected versions
every release that ships lucene-monitor: 8.2.0 to 8.11.4, 9.0.0 to 9.12.3, 10.0.0 to 10.5.2, plus the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193. [exec] the finder's JUnit fails 2 of 4 with identical values (tie 0: 0.26152915 instead of 0.13076457; tie 0.1: 0.26152915 instead of 0.14384103) on 8.2.0 (-ea and -da), 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da), branch_10_6 4ae946e5a9b (monitor jar compiled from the branch sources, the other jars from the hunt cache) and main (-ea and -da); the independent reproduction's JUnit fails 5 of 10 on main, 10.5.2 and 9.12.3 (-ea and -da); a separate test fails on main (-ea and -da) and 10.5.2; the intended-behaviour check ran the finder JUnit on main (-ea and -da), 10.5.2 and 9.12.3. [read] the other releases: a per-tag grep on every 8.2.0-8.8.1, 9.x and 10.x tag in the history clone finds the sum, 'sum of:' and the split in each, and QueryDecomposer never reads the tie-breaker (8.9.0-8.11.4 tags are not in the clone; 8.11.4 was executed). Not fixed: on upstream main 20ced71c1e6 the five files have the same blobs as a9779ef3c193, branch_10_6 and branch_10x differ only in cast style, and no open PR changes the split or resolve (2026-10-28).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (2026-10-28, the prior-report record, verdict new, 140 recorded queries: two scripted prior-report searches (96 queries over apache/lucene issues and PRs, commits, downstream trackers, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and 6 web searches); the file lists of all 411 open apache/lucene PRs; the 58 PRs closed or updated since 2026-10-10; issues and PRs from the last 48 h; all 50 module:monitor items and 27 monitor/luwak JIRA issues; the Luwak tracker and code; the Solr Monitor integration apache/solr#2382; Monitor wrapper projects; GitHub code search; the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's earlier searches found nothing either; refreshed 2026-10-28 with read-only gh searches: no apache/lucene PR or issue created since 2026-10-26 (#16907-#16926) is about lucene/monitor by title, and 'ScoringMatch', 'QueryDecomposer DisjunctionMaxQuery' and 'monitor dismax score' return only #13109). Not fixed upstream: the five files have the same blobs on main 20ced71c1e6; the three open PRs that touch them (#12295 instanceof refactor, #14660 another package-info paragraph, #16847 Math.toIntExact) change neither the split nor resolve. Related items, not reports of this bug: #9811 / LUCENE-8766 (the import that introduced the sum; its comments do not discuss scores), #14886 / #14885 (decomposer iteration order across JVMs, matching only), #13109 / #13632 (matcher visibility), #16350 (bulk scoring disabled in the monitor, scores unchanged), apache/solr#2382 (Monitor integration with constant scores), Luwak #112 (the DisjunctionMaxQuery split, no discussion), Luwak #64 / #175 (scorer reuse, presearcher term weighting)
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: the 3 repro classes on stock with assertions on and off; fix B on the whole monitor module with assertions on and off, and the module's static checks; fix A on the whole module with assertions on; the repro runner: the finder's test, the independent reproducer's, the test written and run by the AI tool that drove the cross-model review (the other-family model ran no code), the devil's-advocate probes, the adjudication's and the registry re-runs, several seeds, assertions on and off); the release jars 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2, and 8.1.1 (no monitor module); branch_10_6 head 4ae946e5a9b (10.6.0-SNAPSHOT jars built from it). JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:46-52 (DisjunctionMaxQuery replaced by the union of its decomposed disjuncts; getTieBreakerMultiplier never read)
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryCacheEntry.java:51-59 (one entry per decomposed part under the parent id), CandidateMatcher.java:75-85 (addMatch merges matches of one id through resolve) and :87-95 (resolve javadoc 'combine them')
  • lucene/monitor/src/java/org/apache/lucene/monitor/ScoringMatch.java:42-44 (resolve returns match1.getScore() + match2.getScore()) and ExplainingMatch.java:44-52 (resolve builds a 'sum of:' Explanation)
  • lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:40-45 (ScoringMatch 'returns the set of matching queries, with the score that each one records against a Document'; ExplainingMatch 'similar to ScoringMatch, but include the full Explanation') and MonitorConfiguration.java:38 (default QueryDecomposer), :111 (setQueryDecomposer)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:29-42 (class javadoc: maximum plus a tie-breaking increment, 'not the sum of the field scores (as BooleanQuery would give)', the albino elephant example), :55-61 (tieBreakerMultiplier definition), :239 (explain: max + otherSum * tieBreakerMultiplier)
  • lucene/monitor/src/test/org/apache/lucene/monitor/TestQueryDecomposer.java:87-100 (assert the DisjunctionMaxQuery split shape) and ConcurrentMatcherTestBase.java:86 ('the sum of the scores for the test and doc parts', a BooleanQuery)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/DisjunctionMaxQueryBuilder.java (the XML query parser builds DisjunctionMaxQuery) and queryparser/classic/MultiFieldQueryParser.java:305 (only a TODO mentions DisjunctionMaxQuery; the classic parsers build BooleanQuery)
The finding in 30 seconds
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:46-52; QueryCacheEntry.java:51-60; Monitor.java:294-299; CandidateMatcher.java:75-95; ScoringMatch.java:36-44; ExplainingMatch.java:44-53; package-info.java:35-48
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:29-43, 57-61, 238-244
Who is affected
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/MonitorConfiguration.java:38, 111; package-info.java:35-51
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/DisjunctionMaxQueryBuilder.java; queryparser/classic/MultiFieldQueryParser.java:305 (only a TODO mentions DisjunctionMaxQuery)
Why it looks unintended
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:40-45; QueryDecomposer.java:30-33; CandidateMatcher.java:87-95
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:29-43
  • a Lucene main checkout lucene/monitor/src/test/org/apache/lucene/monitor/ConcurrentMatcherTestBase.java:60-91; TestQueryDecomposer.java:87-100
  • a Lucene main checkout lucene/monitor/src/test/org/apache/lucene/monitor/TestSimilarities.java:43-50 (the other score assertion: two similarities compared, no decomposition)
Suggested fix
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/CandidateMatcher.java:87-95 (resolve signature); MonitorConfiguration.java:111
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:114-126, 257-261 (QueryCacheStats, getDisjunctCount: counts after decomposition)

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

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc.

Built 2026-11-03 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.