The builder keeps your withFlags set, but the highlighter's constructor never reads it: it checks its own flags field, still null at that point, and falls back to the four boolean options.
UnifiedHighlightermarks query matches in stored text. Four flags steer how:PHRASES(match phrase queries strictly),MULTI_TERM_QUERY(highlight wildcard, prefix, fuzzy and similar queries),PASSAGE_RELEVANCY_OVER_SPEED, andWEIGHT_MATCHES(use theWeight.matchesAPI, which marks a whole phrase as one span).UnifiedHighlighter.Buildersets them in one of two ways: four boolean options, all true by default, or one call,withFlags(Set<HighlightFlag>), whose set "will override" the four.- Without
PHRASES, the terms of a phrase query are marked wherever they occur; with the defaults the phrase is marked as one match:<b>alpha beta</b>.
withFlags set, but the constructor's check reads the highlighter's own flags field, still null, so the four boolean defaults are used and the phrase is marked as <b>alpha beta</b>. Select the still to open it at full size. Generated with AI, checked against the code.- You build a highlighter with
UnifiedHighlighter.builderWithoutSearcher(analyzer).withFlags(EnumSet.of(MULTI_TERM_QUERY)). The javadoc says this set "will override" the four boolean builder options. - You highlight
PhraseQuery("body", "alpha", "beta")in the text "alpha beta alpha gamma". - You expect the flags you asked for. Without
PHRASESevery occurrence of each term is marked:<b>alpha</b> <b>beta</b> <b>alpha</b> gamma. The same flags chosen through the boolean options give exactly that. - You get
<b>alpha beta</b> alpha gamma, the output of a default highlighter. The constructor checks the highlighter's ownflagsfield, still null at that moment, instead of the builder's, so your set is never read and all four default flags apply. - No error, and search hits do not change. Every set is ignored, even the empty one, in every release since 9.1.0. The four boolean options work and are the workaround.
Only applications that call the opt-in Builder#withFlags; the default builder never calls it, and Solr, Elasticsearch and OpenSearch set flags another way. They silently get the default flags: different snippet markup, and costly multi-term highlighting cannot be turned off this way. Hits, scores and the index are unaffected; the boolean options work.
Mechanism, terms and numbers476 words
UnifiedHighlighter.Builder has two ways to choose the highlight flags: four boolean options (withHandleMultiTermQuery, withHighlightPhrasesStrictly, withPassageRelevancyOverSpeed, withWeightMatches), or withFlags(Set<HighlightFlag>), whose javadoc says the set "will override" the four. The second way does nothing. withFlags stores the set in Builder.flags, but the constructor's evaluateFlags(Builder uhBuilder) checks flags, which there means the highlighter's own field, still null while the constructor runs, and then derives the flags from the four booleans; Builder.flags is never read. With withFlags(EnumSet.of(MULTI_TERM_QUERY)), PhraseQuery("body", "alpha", "beta") on "alpha beta alpha gamma" gives the default strict markup <b>alpha beta</b> alpha gamma instead of <b>alpha</b> <b>beta</b> <b>alpha</b> gamma. Nothing throws, and hits, scores and the index are unaffected; only the highlighting configuration is wrong. It shipped with the Builder in 9.1.0 (#412), affects every release up to 10.6.0 and main, and the four boolean options are a working workaround. The fix makes evaluateFlags(Builder) use the builder's set.
private Set<HighlightFlag> flags; // :131 the highlighter's own field
...
public static class Builder {
private Set<HighlightFlag> flags; // :270 Builder.flags
public Builder withFlags(Set<HighlightFlag> values) { // :309
this.flags = values; // :310
return this;
}
}
public UnifiedHighlighter(Builder builder) {
...
this.flags = evaluateFlags(builder); // :468
protected Set<HighlightFlag> evaluateFlags(Builder uhBuilder) { // :539
if (flags != null) { // :540 this.flags, still null here
return flags;
}
return flags = evaluateFlags(uhBuilder.handleMultiTermQuery, uhBuilder.highlightPhrasesStrictly,
uhBuilder.passageRelevancyOverSpeed, uhBuilder.weightMatches); // :543-548
}- The set is stored.
withFlagswritesBuilder.flags(:309-312). OnlyBuilder#getFlags()(:430-432) ever reads it. - The check reads the wrong object. Inside
evaluateFlags(Builder uhBuilder)the unqualifiedflagsis the highlighter's private field (:131), notuhBuilder.flags. The field has no initializer and is written only at:468and, inside this method, at:543, so when the constructor calls the method the check is always false. - The booleans always win.
evaluateFlags(boolean, boolean, boolean, boolean)(:502-530) builds a freshEnumSetfrom the four options and addsWEIGHT_MATCHESonly when the other three are set (:519-528). The defaults (:108-111, all true) give all four flags. - The flags drive the highlighting.
getFlags(String)returns the stored set (:586-594).WEIGHT_MATCHESselects theWeight.matchespath, which marks the whole phrase (:1221-1224);PHRASESwithout it builds a strictPhraseHelper(:1225-1235);MULTI_TERM_QUERYdecides whether multi-term queries are highlighted at all (:1248-1250); two flags choose the offset strategy for re-analysis (:1294-1299).
PhraseQuery("body", "alpha", "beta") on "alpha beta alpha gamma", highlightWithoutSearcher, StandardAnalyzer: withFlags({MULTI_TERM_QUERY}) gives <b>alpha beta</b> alpha gamma, the same as a default builder (all four flags). The same {MULTI_TERM_QUERY} chosen through withHighlightPhrasesStrictly(false) and withPassageRelevancyOverSpeed(false) gives <b>alpha</b> <b>beta</b> <b>alpha</b> gamma. withFlags with the empty set still highlights a PrefixQuery (<b>Lucene</b>), while all four booleans false does not.
Exposure and likelihood310 words
- What goes wrong. The highlighter runs with the flags of the four booleans (by default all four) instead of the requested set: strict instead of loose phrases, whole-phrase instead of per-word markup, multi-term queries highlighted although left out, and possibly a different passage ranking and offset strategy. Nothing throws.
- What it does not touch. Search hits, scores and the index; which documents and fields are highlighted;
withFieldMatcher, masked fields andwithMaxLength, which the constructor copies separately (:469-472). On stock main, a sweep of 6,528 highlights over everywithFlagsargument, boolean combination, three offset sources and eight query types saw only flag sets the booleans can produce, and no text from another field. - A cost you cannot switch off this way. "MTQ highlighting can be expensive, particularly when using offsets in postings" (
withHandleMultiTermQueryjavadoc,:325-327). A caller who leavesMULTI_TERM_QUERYout of itswithFlagsset still pays for it, as with the default configuration. - Who is exposed. Applications that call
Builder#withFlags(opt-in; no production code in Lucene does). Read-only code search found no caller outside copies of Lucene's own files: Solr overridesgetFlags(String), and Elasticsearch and OpenSearch subclass the highlighter and edit or override thegetFlagsresult. Code search does not cover private code. - Scale. None needed: every stock
UnifiedHighlighterbuilt withwithFlagsis affected, deterministically, on the first call. A subclass that overridesevaluateFlags(Builder)sets its own flags, as Lucene's test helperoverriddenBuilderForTestsdoes (TestUnifiedHighlighter.java:139-157). - Workaround. The four boolean options can express 9 of the 16 flag sets, every set that meets the documented
WEIGHT_MATCHESrequirement (:344-346); agetFlags(String)override can express any set. - Lucene's own tests.
TestUnifiedHighlighter#overrideFieldMatcherForTestscopies a highlighter withwithFlags(original.getFlags(fieldName))(TestUnifiedHighlighter.java:133), so the threetestFieldMatcher*tests run their copies with the default flags instead of the randomized ones. No test checks whatwithFlagsdoes.
One JUnit class, one method, public API only: a highlighter built with withFlags({MULTI_TERM_QUERY}) and no index or searcher highlights a phrase query in a four-word text. The test body uses no random input; it fails with assertions on and off.
/*
* 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.uhighlight;
import java.util.EnumSet;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.search.uhighlight.UnifiedHighlighter.HighlightFlag;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestUnifiedHighlighterBuilderWithFlags extends LuceneTestCase {
public void testWithFlagsIsHonoured() throws Exception {
UnifiedHighlighter uh =
UnifiedHighlighter.builderWithoutSearcher(new StandardAnalyzer())
.withFlags(EnumSet.of(HighlightFlag.MULTI_TERM_QUERY))
.build();
PhraseQuery query = new PhraseQuery("body", "alpha", "beta");
// expected: no PHRASES flag, so every query term is highlighted; actual: strict phrase match
assertEquals(
"<b>alpha</b> <b>beta</b> <b>alpha</b> gamma",
uh.highlightWithoutSearcher("body", query, "alpha beta alpha gamma", 1));
}
}
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestUnifiedHighlighterBuilderWithFlags -Ptests.seed=DEADBEEF
- Expected
<b>alpha</b> <b>beta</b> <b>alpha</b> gamma: withoutPHRASES, every occurrence of each term is marked.- Observed
<b>alpha beta</b> alpha gamma: the default flags' strict phrase markup, because thewithFlagsset is never read.
More files, environment, full output255 words
The class sits in org.apache.lucene.search.uhighlight, next to TestUnifiedHighlighter, and uses only builderWithoutSearcher and highlightWithoutSearcher with a StandardAnalyzer: no Directory, no IndexWriter, and no random input in the test body (LuceneTestCase still seeds its usual randomization). The expected string is what the same flag set gives when chosen through the boolean options on stock main (a separate control run), and what the fixed highlighter gives. It compiles on 9.1.0 and later.
Regression test: this same test, as written. With the recommended fix it passes, together with the highlighter module's format, ECJ and forbidden-apis checks.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 (assertions on): fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; plain Java logic) |
cp $REPRO/TestUnifiedHighlighterBuilderWithFlags.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestUnifiedHighlighterBuilderWithFlags -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestUnifiedHighlighterBuilderWithFlags > testWithFlagsIsHonoured FAILED
org.junit.ComparisonFailure: expected:<<b>alpha[</b> <b>beta</b> <b>alpha</b>] gamma> but was:<<b>alpha[ beta</b> alpha] gamma>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:BCCD93156E411B7E]:0)
at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:117)
at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at app//org.apache.lucene.search.uhighlight.TestUnifiedHighlighterBuilderWithFlags.testWithFlagsIsHonoured(TestUnifiedHighlighterBuilderWithFlags.java:34)
# standalone runner, seed DEADBEEF: the same message on main (assertions on and off) and, with assertions on, on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0
# control on stock main (not the repro): the same flags through the boolean options
withHighlightPhrasesStrictly(false).withPassageRelevancyOverSpeed(false): getFlags = [MULTI_TERM_QUERY], '<b>alpha</b> <b>beta</b> <b>alpha</b> gamma'
default builder: getFlags = [PHRASES, MULTI_TERM_QUERY, PASSAGE_RELEVANCY_OVER_SPEED, WEIGHT_MATCHES], '<b>alpha beta</b> alpha gamma'- The
withFlagsjavadoc says its set "will override" the four boolean options, with no precondition. - The check that should read the builder's set is always false in the constructor; in the introducing PR it sat in the Builder and worked until a refactor moved it.
- Lucene's own test helper copies a highlighter with
withFlags(original.getFlags(field)), which needswithFlagsto work.
The full argument231 words
- The contract. "User-defined set of
HighlightFlagvalues which will override the flags set bywithHandleMultiTermQuery(boolean),withHighlightPhrasesStrictly(boolean),withPassageRelevancyOverSpeed(boolean)andwithWeightMatches(boolean). Here the user can either specify the set ofHighlightFlags to be applied or use the boolean flags to populate final list ofHighlightFlags." (:299-308). No javadoc, CHANGES or MIGRATE text qualifies it. - The code shows the intent. The guard
if (flags != null) return flags;(:540) is always false when the constructor calls the method; it is consistent with an intended check of the builder's set.Builder#getFlags()exposes the stored set (:430-432). - History. In #412, at revision
d65ee8ab42(2021-12-05),evaluateFlags()was aBuildermethod, so its check readBuilder.flags: by its source,withFlagswould have taken effect (revisions read, not built). Revisiona6f70412c9(2021-12-18, "Attempt at making UH with builder back-portable to v9.x") moved it intoUnifiedHighlighterasevaluateFlags(Builder uhBuilder)with the check unchanged, soflagsnow meant the highlighter's own field. No version ever readuhBuilder.flagsthere (git log -G). - The review. The PR author summarised a revision on 2021-10-30 as "if
flagsis initialized then boolean flags are ignored". On 2021-12-03 a reviewer (dsmiley) asked "Isn't empty valid?" about a check that ignored an empty set, so even an empty set was meant to count. - Lucene's own test helper relies on it (
TestUnifiedHighlighter.java:133).
The devil's-advocate check218 words
The strongest case for "intended" (the cross-model reviews, the intended-behaviour check and the adjudication built it): the class is @lucene.experimental (:94); on #412 a reviewer wrote "I'm dubious we even want this on the builder since it's more of an advanced case to customize these flags. Someone can always subclass the getter to customize" (2021-10-30, about a revision that had added a setFlags setter); and the back-port plan on #11234 said "the flags computation needs to be as it is already on 9x instead of moved off to the builder". So the booleans would be the real API and withFlags vestigial.
Why it fails: withFlags and its "will override" javadoc were kept and shipped in every release since 9.1.0, and an experimental tag lets an API change, not a public method do nothing; the back-port note is about where the flags are computed, and, by its source, the revision before the move read the set; the same reviewer later asked "Isn't empty valid?"; no won't-fix or by-design statement was found in the recorded searches. The cross-model reviews (OpenAI GPT-5.6) rated the case weak. Removing withFlags is a legitimate design choice for maintainers who want one way to set flags, but it is still a change, because the method has never worked as its javadoc says.
Recommended Honour withFlags with a private copy of the set (recommended, validated)
In evaluateFlags(Builder), return a private copy of uhBuilder.flags when it is set (+6 lines). Validated: the repro passes with assertions on and off, the whole highlighter module (723 tests) passes, testFieldMatcher* 50 times over, and the format, ECJ and forbidden-apis checks pass.
protected Set<HighlightFlag> evaluateFlags(Builder uhBuilder) {
if (flags != null) {
return flags;
}
if (uhBuilder.flags != null) {
// Builder#withFlags overrides the boolean options; keep a private, mutable copy
Set<HighlightFlag> copy = EnumSet.noneOf(HighlightFlag.class);
copy.addAll(uhBuilder.flags);
return flags = copy;
}
return flags = evaluateFlags(uhBuilder.handleMultiTermQuery, uhBuilder.highlightPhrasesStrictly,
uhBuilder.passageRelevancyOverSpeed, uhBuilder.weightMatches);
}All options and trade-offs4 options · 593 words
Two fixes were validated through gradle in a scratch worktree of 645d4fe14a20; the recommended one also keeps the highlighter's set private. Two further options were not built.
When the builder has a set, use a fresh EnumSet copy of it; otherwise derive the flags from the four booleans as today.
protected Set<HighlightFlag> evaluateFlags(Builder uhBuilder) {
if (flags != null) {
return flags;
}
if (uhBuilder.flags != null) {
// Builder#withFlags overrides the boolean options; keep a private, mutable copy
Set<HighlightFlag> copy = EnumSet.noneOf(HighlightFlag.class);
copy.addAll(uhBuilder.flags);
return flags = copy;
}
return flags = evaluateFlags(uhBuilder.handleMultiTermQuery, uhBuilder.highlightPhrasesStrictly,
uhBuilder.passageRelevancyOverSpeed, uhBuilder.weightMatches);
}Trade-offs
- Why the copy. Today the highlighter's set is always a fresh
EnumSetit owns, and downstream subclasses edit it (Elasticsearch callsgetFlags(field).remove(HighlightFlag.WEIGHT_MATCHES)in its constructor, by code reading). The copy keeps that true forwithFlagscallers, and it stops later changes to the caller's set from reaching the highlighter (both executed in the fix-design probe). EnumSet.noneOfplusaddAllalso accepts an empty set, whichEnumSet.copyOf(Collection)rejects for a non-EnumSetargument.- Cost: one small set copy per highlighter construction; nothing per highlight.
- Open point: the fix takes any explicit set as given, including
WEIGHT_MATCHESwithout its documented prerequisites (:344-346); whether such sets should be taken as given, normalized or rejected is a question for the maintainers (option C).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails. With the fix: the repro andTestUnifiedHighlighter(156 tests) pass with assertions on and off. :lucene:highlighter:test, the whole module with the repro in place: 35 classes, 723 tests, 0 failures, 0 skipped.- The three
testFieldMatcher*tests, whose copied highlighters now keep their randomized flags, with-Ptests.iters=50: 600 tests, 0 failures. checkGoogleJavaFormat,ecjLintMain,ecjLintTest(with the repro in place),forbiddenApisMain,forbiddenApisTest: pass.- A fix-design probe (not the repro) passes 2 of 2: an immutable caller set with a subclass that edits its flags, and a caller that changes its own set after
build().
Add if (uhBuilder.flags != null) { return flags = uhBuilder.flags; } after the existing check (+3 lines; the finder's and the independent reproduction's fix).
Trade-offs
The highlighter shares the caller's set. Executed with a fix-design probe: after withFlags(Set.of(MULTI_TERM_QUERY)), a subclass that calls getFlags(f).remove(WEIGHT_MATCHES) in its constructor throws UnsupportedOperationException, and adding PHRASES to the caller's mutable set after build() changes the highlighter's flags to [PHRASES, MULTI_TERM_QUERY].
What the checks validated
The repro and TestUnifiedHighlighter (156 tests) pass through gradle on 645d4fe14a20; earlier, every hunt repro passed with it, and a run of 476 highlighter tests changed only that repro's result.
Drop WEIGHT_MATCHES, or throw IllegalArgumentException, when the set lacks one of the other three flags, as the boolean path does (:519-528).
Trade-offs
Not needed for this bug, and inconsistent with the other way to set flags: a getFlags(String) override can return any set, and Lucene's own randomized tests already run sets such as {WEIGHT_MATCHES, MULTI_TERM_QUERY, PHRASES} without PASSAGE_RELEVANCY_OVER_SPEED (TestUnifiedHighlighter.java:82-95).
Document a getFlags(String) override as the way to pass an arbitrary set, rewrite the test helper to copy the booleans, and remove withFlags, for example with the 11.0 deprecation clean-up tracked in #16918.
Trade-offs
A legitimate design choice if maintainers want one way to set flags. Removes a documented option; until removal the method still does nothing.
Solr and OpenSearch choose highlight flags by overriding getFlags(String), and Elasticsearch edits the getFlags(field) result in its constructor; none of them calls withFlags, so by code reading they are not affected.
Code reading. While modelling how the highlighter chooses its offset strategy, for which the flag set is an input, the finder saw that the constructor never reads the builder's set, and confirmed it with a JUnit on the stock jars. The unit's model then had to set flags through the boolean options.
The method in full135 words
Method: non-formal (code reading, then JUnit tests).
- A hunt unit on highlighter offset sources was writing a bounded-exhaustive model of
getOptimizedOffsetSourceandgetOffsetStrategy, where the flag set is an input. ReadingUnifiedHighlighter, the finder saw thatevaluateFlags(Builder)tests the highlighter's own field. A JUnit on the stock main jars confirmed it (fails with assertions on and off, and on 10.5.2 and 9.12.3). No model or tool output produced the first signal; the model then sets its flag sets through the boolean options becausewithFlagshas no effect. - An independent reproducer, a devil's-advocate verifier (who traced the PR history), cross-model reviews (OpenAI GPT-5.6) and an adjudication checked it; a follow-up ran it from 9.0.0 to main, searched for prior reports and screened it for security; the repro was then minimized to one public-API test.
No prior report found in 215 recorded searches (2026-10-26: GitHub, commits, the mailing lists, JIRA, downstream projects and the web; GitHub rechecked 2026-10-31). Closest: the introducing PR #412 and its issue #11234, which never noticed the defect; #16918 plans 11.0 removals in the same class.
Searches2 searches
Verdict new: no issue, PR, commit, list thread, JIRA entry or web page describes withFlags being ignored or evaluateFlags(Builder) not reading the builder's set. Related, not reports: #412 and #11234 (LUCENE-10197, the introducing change; they discuss where the flags are computed); #16918 (open, 2026-10-27, "Deprecations slated for removal in 11.0"), which lists the deprecated UnifiedHighlighter(IndexSearcher, Analyzer) constructor, its setters and evaluateFlags for removal (the only deprecated overload is evaluateFlags(UnifiedHighlighter), :559-566) and does not mention withFlags; #12431, a different highlighter bug whose workaround overrides getFlags(String). Open PRs #12295 and #13165 touch other lines of the file. Upstream main 2f424fd428da (2026-10-30) still has the code.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, code and commits (apache/lucene and downstream), the open PRs' changed files, dev@, java-user@, issues@, commits@, builds@, JIRA, web | 2026-10-26 | 215 recorded queries (90 issue/PR, 18 code, 9 commit, 6 downstream, 60 mailing-list, 13 JIRA, 19 web); #412 read in full (12 comments, 56 review comments, 30 reviews), #11234 (16 comments), all 20 UnifiedHighlighter issues and PRs since 2021-12-01, the changed files of all 413 open PRs, the Solr, Elasticsearch and OpenSearch highlighter sources | no report; 23 hits read, none the same bug (closest #412, #11234, #12431) |
| GitHub (recheck) | 2026-10-31 | withFlags, evaluateFlags, "UnifiedHighlighter" flags ignored, "HighlightFlag" in apache/lucene issues and PRs; the file's commit history on upstream main | withFlags: 0 hits; evaluateFlags: #16918 only; the file last changed in 54283150a81 (2026-06-14, unrelated) |
Introduced with the Builder by #412 (b1b1a890d08, back-port a9f812c1cf9, 2021-12-19), first released in 9.1.0; a refactor inside the PR moved the check out of the Builder. Every release from 9.1.0 to 10.6.0 and main is affected; 9.0.0 has no Builder. Not fixed.
Versions tested6 rows
Introduced by #412, "LUCENE-10197: UnifiedHighlighter should use builders for thread-safety" (author apanimesh061, issue #11234; merge commit "LUCENE-10197: UnifiedHighlighter now has a Builder"): b1b1a890d08 on main and the 9.x back-port a9f812c1cf9 (committed by dsmiley), both 2021-12-19. git log -S over UnifiedHighlighter.java for withFlags( and evaluateFlags(Builder uhBuilder) finds only these two commits, git blame credits every line involved to them, and git log -G 'uhBuilder\.flags' is empty. Inside the PR, revision d65ee8ab42 (2021-12-05) still had the check in the Builder, where by its source it read the builder's set; a6f70412c9 (2021-12-18) moved it (revisions read, not built). The parent commits have no Builder, so no commit-level bisect is possible.
Releases not executed are inferred: the release tags contain the introducing commit, and the evaluateFlags(Builder) body is byte-identical in all 33 releases from 9.1.0 to 10.6.0 and on main (follow-up for 9.1.0 to 10.5.2; the fact check added 10.6.0). Upstream main 2f424fd428da (2026-10-30) is unchanged.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (strict markup instead of per-word) |
| 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 | affected | minimal repro fails, same message (assertions on) |
| 9.1.0 | first | first release with UnifiedHighlighter.Builder; the finder's and the independent reproduction's tests fail too |
main ea57360912ed, a9779ef3c193 | affected | the finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's tests fail |
| 9.0.0 | not affected | no Builder and no withFlags (reflection probe); the test does not compile |
with the recommended fix (645d4fe14a20) | not affected | repro passes; the whole highlighter module (723 tests) passes |
An independent reproducer, a devil's-advocate verifier and a cross-model review said real; a second cross-model review, which could not run code, returned a procedural not-real while agreeing with the root cause. The adjudication confirmed with high confidence, and the fix made every check's test pass.
Checks and verdicts302 words
Every executing check ran on stock builds of main (ea57360912ed, a9779ef3c193 or 645d4fe14a20, with no highlighter change between them) or on releases.
| Check | Verdict | What it found |
|---|---|---|
| Finder (JUnit) | candidate | Fails 1 of 1 on main with assertions on and off, 10.5.2 and 9.12.3 (getFlags returns the four defaults instead of [MULTI_TERM_QUERY]); its boolean-option control passes; passes with the one-line fix. |
| Cross-model review, refute lens (OpenAI GPT-5.6) | not real, low (procedural) | Read-only, could not run Java, so the refute review voted not real on procedure only. It agreed with the root cause, found the inputs in contract and rated the works-as-designed case weak; 6 of its 8 evidence items support real. |
| Independent reproducer | real | Own test: withFlags with the empty set still highlights a PrefixQuery, all booleans false does not; fails 2 of 2 on main (on and off), 10.5.2, 9.12.3 and 9.1.0; sanity checks pass; its fix passes. |
| Cross-model review, works-as-designed lens (OpenAI GPT-5.6) | real, medium | Works-as-designed case weak; root cause agrees. The reviewer ran no code; a separate run re-checked its claims with executed runs, including an output probe (PhraseQuery on a two-sentence text) that fails on main and passes with the fix. |
| Devil's advocate | real | Own test fails 3 of 5 on main (the 3 claim tests; the 2 fact tests pass), including the test helper's copy idiom; traced the defect to PR revision a6f70412c9. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's test (seed C0FFEE01): 2 of 2 fail with assertions on and off, 2 of 2 pass with the fix; impact class minor, security none. |
| Brief runs (2026-10-31) | reproduced, fix validated | Minimal repro on main and five releases; boolean-option control; gradle on 645d4fe14a20 with both fixes; fix-design probe. |
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.
- PNG 501 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,237 words
- Registry title
- UnifiedHighlighter.Builder#withFlags is silently ignored: the highlighter always takes its flags from the four boolean builder options
- Area
- highlighter / UnifiedHighlighter builder and highlight flags (UnifiedHighlighter.evaluateFlags(Builder) never reads Builder.flags)
- Priority
- Low, rank 874: Builder#withFlags is silently ignored for every caller, but no caller is known and the boolean options work around it.
- Impact class
- minor
- Impact note
- Applications on any release from 9.1.0 through 10.6.0 or on main that configure a UnifiedHighlighter with the opt-in Builder#withFlags (no production caller in Lucene; Solr, Elasticsearch and OpenSearch set flags by overriding getFlags(String) instead, by code reading) silently get the flags derived from the four boolean options, by default all four flags, instead of the set they passed, so for example multi-term-query or strict-phrase highlighting cannot be switched off this way and the snippet markup differs from what was requested; search hits are unaffected, nothing throws, no scale is needed, and the four boolean builder options are a working workaround that can express every flag set meeting the documented WEIGHT_MATCHES requirement (9 of the 16 subsets).
- Summary
- You build a UnifiedHighlighter with Builder#withFlags(EnumSet.of(MULTI_TERM_QUERY)), whose javadoc says the set 'will override' the four boolean builder options, and expect it to behave like the same flags chosen through those options: for a PhraseQuery 'alpha beta' on 'alpha beta. alpha gamma.' that gives '<b>alpha</b> <b>beta</b>. <b>alpha</b> gamma.'. Instead the constructor's evaluateFlags(Builder) tests the highlighter's own flags field, which is still null at that point, rather than the builder's, so the set passed to withFlags is never read: getFlags returns the defaults [PHRASES, MULTI_TERM_QUERY, PASSAGE_RELEVANCY_OVER_SPEED, WEIGHT_MATCHES] and the phrase is highlighted strictly as '<b>alpha beta</b>. alpha gamma.'. Every set is ignored the same way, including the empty set (a PrefixQuery is still highlighted), with or without assertions.
- Introduced
- b1b1a890d0890e7e35bb401cc8d851a45cc155f1 on main and its 9.x back-port a9f812c1cf90b0c35af8a82b8c76d826601684c7 (both 2021-12-19, 'LUCENE-10197: UnifiedHighlighter now has a Builder (#412)', author apanimesh061, the back-port committed by dsmiley; issue #11234 / LUCENE-10197) [read: git log -S and git blame in the full-history clone, every buggy line blames to these commits, and git log -G 'uhBuilder\.flags' is empty on all history; exec: the API is absent on 9.0.0 and the repro fails on 9.1.0]. The defect arose inside PR #412: at revision d65ee8ab42 (2021-12-05) evaluateFlags() was a Builder method, so its 'if (Objects.nonNull(flags)) return flags;' read Builder.flags; revision a6f70412c9 (2021-12-18, 'Attempt at making UH with builder back-portable to v9.x') moved it into UnifiedHighlighter as evaluateFlags(Builder uhBuilder) without changing the guard, so 'flags' now names the highlighter's own field [read: PR revisions fetched read-only; those revisions were not built]. withFlags shipped broken with the API, so this is not a regression of behaviour that ever worked in a release (2021-12-19)
- First affected version
- 9.1.0 [exec] (first release with UnifiedHighlighter.Builder and withFlags, from 9.x commit a9f812c1cf9; 10.0.0 is the first release of the main-line commit and is affected [exec]); 9.0.0 has no Builder [exec: a reflection probe reports Builder absent and withFlags absent, and the repro does not compile]
- Affected versions
- every release with UnifiedHighlighter.Builder: the 20 releases 9.1.0 to 9.12.3, the 12 releases 10.0.0 to 10.5.2, 10.6.0 (released 2026-10-29) and main ea57360912ed [exec: the minimal repro fails on the 10.6.0 jars (2026-10-31); the finder repro fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main, on main with -ea and -da; the independent reproduction's test fails 2 of 2 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main, on main with -ea and -da; read: the other releases contain the introducing commit and their evaluateFlags(Builder) body is byte-identical]; not affected: 9.0.0 and earlier, which have no Builder (9.0.0 executed); unreleased maintenance branches not checked
- 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; fixed upstream.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record: 215 recorded queries, namely 90 GitHub issue and PR queries, 18 GitHub code queries, 9 commit queries, 6 downstream-repo queries, 60 mailing-list queries over dev@, java-user@, issues@, commits@ and builds@, 13 JIRA-archive queries and 19 web queries; direct reads of the introducing PR #412 (12 comments, 56 review comments, 30 reviews), issue #11234 / LUCENE-10197 (16 comments), 20 UnifiedHighlighter issues and PRs created since 2021-12-01, the changed files of all 413 open apache/lucene PRs, the upstream commits touching UnifiedHighlighter.java on main, branch_10x, branch_10_6 and branch_9x, and the Solr, Elasticsearch and OpenSearch highlighter sources; 23 hits read and classified, none the same bug; the finder, the cross-model review and the intended-behaviour check found nothing either); not fixed upstream (evaluateFlags(Builder) is unchanged on main ea57360912ed, the upstream HEAD at search time; the last change to UnifiedHighlighter.java, 54283150a81 (#16255), is unrelated; no open PR touches evaluateFlags, withFlags or the constructor); closest items, not reports: #412 (the introducing PR; a reviewer asked 'Isn't empty valid?' about an earlier check that ignored an empty withFlags set, and the author's 2021-10-30 summary says that when flags is initialized the boolean flags are ignored, which is design intent), #11234 / LUCENE-10197 (its issue, with the back-port plan 'the flags computation needs to be as it is already on 9x'), #12431 (a different highlighter bug whose suggested workaround overrides getFlags(String))
- Lucene commit tested
- main
645d4fe14a20(2026-10-31): the minimal test fails through gradle in a scratch worktree and through a standalone runner with assertions on and off, and with assertions on on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0. The recommended fix passes it, together with the whole highlighter module (35 classes, 723 tests) and the module's format, ECJ and forbidden-apis checks. The hunt, the verifiers and the adjudication ran mainea57360912ed, and a refresh re-ran their repros ona9779ef3c193; nothing underlucene/highlighter/changed betweenea57360912edand645d4fe14a20.
- Finding record
lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:131 (at ea57360912ed) (private Set<HighlightFlag> flags), :270 (Builder.flags), :299-312 (Builder#withFlags javadoc 'will override the flags set by withHandleMultiTermQuery, withHighlightPhrasesStrictly, withPassageRelevancyOverSpeed and withWeightMatches' and 'the user can either specify the set of HighlightFlags to be applied or use the boolean flags'; setter stores this.flags = values), :468 (constructor: this.flags = evaluateFlags(builder)), :532-549 (evaluateFlags(Builder) javadoc 'Evaluate the highlight flags and set the flags variable'; body tests 'if (flags != null) return flags;' on the highlighter's own field, then evaluates the four booleans)lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:130-137 (at ea57360912ed) (overrideFieldMatcherForTests copies flags with .withFlags(original.getFlags(fieldName)) at :133)
- The finding in 30 seconds
lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:108-111, (at 645d4fe14a20) :131, :270, :299-312, :430-432, :468, :502-530, :539-549, :586-594, :1163-1183, :1219-1251, :1294-1299
- Who is affected
lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:325-327, (at 645d4fe14a20) :344-346, :469-472lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:82-95, (at 645d4fe14a20) :130-157, :1175, :1262, :1484
- Why it looks unintended
lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94, (at 645d4fe14a20) :299-312, :430-432, :540lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:130-137 (at 645d4fe14a20)
- Suggested fix
lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:519-528 (at 645d4fe14a20)lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:82-95 (at 645d4fe14a20)
- Prior reports
https://github.com/apache/lucene/pull/412https://github.com/apache/lucene/issues/11234https://github.com/apache/lucene/issues/16918lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:559-566 (at 645d4fe14a20)
- TestUnifiedHighlighterBuilderWithFlags.java (one test class):
repro/LFV-288/src/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterBuilderWithFlags.java(38 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's output: the user's call, the constructor's check, the highlight result, every set ignored while the boolean options work, a fix, and a summary card (the PNG).
assets/LFV-288/bug.png(501 KB)
