All findings · Lucene finding · LFV-748 · interval-function-toquerystring-not-reparsable

Flexible query parser: toQueryString prints fn:fuzzyTerm(abc) as fn:fuzzyTerm(abc 2128), which does not parse back

The flexible query parser promises that toQueryString output parses back, but fn:fuzzyTerm(abc) prints as fn:fuzzyTerm(abc 2128): maxEdits 2 and maxExpansions 128 run together, and re-parsing throws "max edits must be 0..2". fn:wildcard(ab* 5) and quoted literals such as "a:b" misprint too.

Summary

Priority
Low, rank 772A minor toQueryString round-trip break for fn: interval nodes that matters only to applications that re-parse node text, which no in-tree caller does.
Impact
minor
Since
9.1.0 (LUCENE-10223 and LUCENE-10414, both first released in 9.1.0)introduced 2021-11-11 · about 4 years
Affected
every release 9.1.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: a bounded-exhaustive parse, print, re-parse check over 8,352 interval expressions

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

The flexible parser and its interval syntax

  1. The flexible StandardQueryParser works in two stages: StandardSyntaxParser turns query text into a tree of query nodes, then processors and builders turn the tree into a Lucene Query.
  2. Every node can print itself back as query text with toQueryString; the package docs say that text "can be parsed back by the original text parser". An application can use this to rewrite a parsed tree and pass it on as text.
  3. Since 9.1.0 the syntax has interval functions, for example fn:ordered(a b), fn:wildcard(ab* 5) (a glob and an optional maxExpansions) and fn:fuzzyTerm(abc 1 5) (a term, then optional maxEdits and maxExpansions). An interval node prints field: followed by its function's toString().
Diagram of the bug: the query text fn:fuzzyTerm(abc) is parsed into an interval node with term abc, maxEdits 2 and maxExpansions 128. toQueryString prints it with the format fn:fuzzyTerm(%s %d%s), which puts no space between the two numbers, giving fn:fuzzyTerm(abc 2128). Parsing that text again reads 2128 as maxEdits and fails with 'max edits must be 0..2'.
fn:fuzzyTerm(abc) parses to maxEdits 2 and maxExpansions 128; toQueryString prints them as one number, abc 2128; parsing that back reads maxEdits 2128 and throws. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Parse fn:fuzzyTerm(abc) with the flexible StandardSyntaxParser. The node gets the defaults maxEdits 2 and maxExpansions 128. Print the tree with toQueryString.
  2. You expect text that StandardQueryParser parses back to the same query, such as f:fn:fuzzyTerm(abc 2 128): the parser's docs promise "a query string that can be parsed back by the original text parser".
  3. Instead you get f:fn:fuzzyTerm(abc 2128). FuzzyTerm.toString uses the format "fn:fuzzyTerm(%s %d%s)", with no space before the second number. Parsing it reads 2128 as maxEdits and throws IllegalArgumentException: max edits must be 0..2, inclusive; got: 2128.
  4. Two sibling printers fail the same way: fn:wildcard(ab* 5) prints as fn:wildcard(ab* maxExpansions:5) (syntax error), fn:or("a:b" c) loses its quotes (syntax error), and a quoted literal containing the ideographic space U+3000 loses its quotes and silently parses back as two words instead of a phrase; a literal with a closing parenthesis, such as "a)(b", also comes back as a different query with no error.
  5. The query that parse() builds is not affected (it is built from the tree, not from this text); the wrong text reaches applications that print node trees and parse that text again, and Lucene's own error messages (fn:fuzzyTerm(abc 12) for maxEdits 1 and maxExpansions 2). Every release since 9.1.0.

Who is affected

Applications that turn flexible-parser node trees with fn: interval functions back into text with toQueryString and parse, store or forward that text: every fn:fuzzyTerm fails, as do wildcards with maxExpansions and some quoted literals; others (with U+3000 or a )) change meaning with no error. The Query that parse() builds is unaffected; Lucene's error messages show the misprint.

Mechanism, terms and numbers719 words

The flexible query parser documents that a query node tree "can also generate a query string that can be parsed back by the original text parser", and QueryNode#toQueryString is "convert to a query string understood by the query parser". For interval functions this fails. IntervalQueryNode#toQueryString prints the field, a colon and the function's toString(), and three of those methods print text the parser rejects or reads differently: FuzzyTerm runs maxEdits and maxExpansions together (fn:fuzzyTerm(abc) prints as fn:fuzzyTerm(abc 2128), which re-parses as maxEdits 2128 and throws), Wildcard prints maxExpansions:5 where the grammar takes a bare number, and AnalyzedText quotes a literal only when Java's \s matches, so literals with : or a keyword print bare (syntax error) and some re-parse with no error as a different query (U+3000 splits a phrase into two words; a ) inside a literal closes the function early). The Query that StandardQueryParser.parse builds is unaffected; applications that round-trip node text and Lucene's own exception messages get the wrong text. Every release from 9.1.0 to 10.6.0 and main; a fix of +12/-4 lines in the three printers passes the repro and the whole queryparser module.

The printers

  • IntervalQueryNode#toQueryString is String.format(Locale.ROOT, "%s:%s", field, source) (IntervalQueryNode.java:47-50): the field, a colon and the function's toString(). It ignores its EscapeQuerySyntax argument, and IntervalQueryNode#toString delegates to it (:52-55), so there is no separate display form.
  • FuzzyTerm.toString (FuzzyTerm.java:52-60) formats "fn:fuzzyTerm(%s %d%s)" with the term, maxEdits and the Integer maxExpansions: no space before the second number, so 2 and 128 print as 2128 and 1 and 5 as 15. The grammar reads fn:fuzzyTerm(abc 2128) as term abc with maxEdits 2128 (StandardSyntaxParser.jj:659-679), and building the query throws "max edits must be 0..2, inclusive" (FuzzyAutomatonBuilder.java:38-39, the throw at :39).
  • Wildcard.toString (Wildcard.java:44-51) prints the raw glob and " maxExpansions:" + maxExpansions. The grammar takes the glob as a term, number or quoted token followed by an optional bare number (StandardSyntaxParser.jj:641-657), so maxExpansions:5 is a syntax error, and a quoted glob such as "a b*" loses its quotes.
  • AnalyzedText.requiresQuotes (AnalyzedText.java:53-55) quotes a literal only if Pattern.compile("[\\s]") finds a match. The lexer is stricter: a term cannot start with whitespace (including U+3000) or any of + - ! ( ) : ^ @ < > = [ ] " { } ~ \ / (StandardSyntaxParser.jj:131-133) and ends at any of them except + and - (:134); it reads AND/&&, OR/|| and NOT/! as operators (:144-146) and treats U+3000 as whitespace (:135), which Java's \s does not match by default. So most such literals print bare and fail to parse, some come back as a different query, and a literal such as "x-y" still round-trips. FuzzyTerm reuses this check for its term.

What comes back

Field body, executed on stock builds with assertions on and off:

inputtoQueryStringparsing that back
fn:ordered(a fn:extend(b 1 2)) (control)body:fn:ordered(a fn:extend(b 1 2))same query
fn:fuzzyTerm(abc)body:fn:fuzzyTerm(abc 2128)IllegalArgumentException "... got: 2128"
fn:fuzzyTerm(abc 1 5)body:fn:fuzzyTerm(abc 15)IllegalArgumentException "... got: 15"
fn:wildcard(ab* 5)body:fn:wildcard(ab* maxExpansions:5)syntax error
fn:or("a:b" c)body:fn:or(a:b c)syntax error
fn:or("x<U+3000>y" z)body:fn:or(x<U+3000>y z)no error, but or(x,y,z) instead of or(BLOCK(x,y),z)

Also executed, with assertions off: fn:or("a)(b" c) prints as body:fn:or(a)(b c) and parses back with no error as body:a (body:b body:c) instead of body:or(BLOCK(a,b),c); fn:or("x-y" z) round-trips; and the upstream test's own input fn:fuzzyTerm(dfe 2 128) (TestStandardQPEnhancements.java:161) prints as body:fn:fuzzyTerm(dfe 2128) and fails to re-parse.

Over a grid of 8,352 interval expressions (18 leaves, including quoted literals with :, AND, &&, || and U+3000, and every depth-1 function over them), 6,100 do not round-trip on main, 121 of them as a silently different query; with the recommended fix, none. The grid is a test design, not a usage frequency.

Where the text goes

StandardQueryParser.parse builds the Query from the tree and never re-parses this text. In-tree, toQueryString output appears only in exception messages (QueryTreeBuilder.java:161, BooleanQueryNodeBuilder.java:72, MatchAllDocsQueryNodeBuilder.java:42, MatchNoDocsQueryNodeBuilder.java:42, AllowLeadingWildcardProcessor.java:74). For example, with a clause limit of 2, parse("a b fn:fuzzyTerm(abc 1 2)", "body") throws "Too many boolean clauses, the maximum supported is 2: body:a body:b body:fn:fuzzyTerm(abc 12)".

Exposure and likelihood178 words
  • Who. Applications that use the flexible parser's node tree as an intermediate form: parse user text with StandardSyntaxParser, inspect or rewrite the tree, then print it with toQueryString and parse, store, cache or forward the text. Also anyone reading logs or exception messages that print interval nodes: fn:fuzzyTerm(abc 12) hides the real maxEdits 1 and maxExpansions 2.
  • Default vs opt-in. Default parser configuration and public API; the trigger needs the fn: interval syntax and an application that re-parses node text (no in-tree caller does).
  • Effect. An exception on the second parse, or, for some quoted literals, a different query with no error: with U+3000 a two-word phrase becomes two separate words, and fn:or("a)(b" c) comes back as body:a (body:b body:c). The results of the original parse() are unaffected.
  • Scale. Any index; deterministic string formatting.
  • How likely: rare. It needs both the niche fn: syntax and a re-parse of toQueryString text; the documented searches found no prior public report since 9.1.0 (2022-03). Two outside ports of the parser copy FuzzyTerm.toString unchanged; no report was found there either.

Minimal repro

One JUnit class on Lucene's test framework, one test method, public API only: parse fn:fuzzyTerm(abc), print the tree with toQueryString, parse the printed text with StandardQueryParser and compare with the direct parse. Deterministic, single thread, StandardAnalyzer from core.

TestFuzzyTermToQueryString.java (one test class)43 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.queryparser.flexible.standard;

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.queryparser.flexible.standard.parser.EscapeQuerySyntaxImpl;
import org.apache.lucene.queryparser.flexible.standard.parser.StandardSyntaxParser;
import org.apache.lucene.search.Query;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFuzzyTermToQueryString extends LuceneTestCase {

  public void testToQueryStringParsesBack() throws Exception {
    StandardQueryParser parser = new StandardQueryParser(new StandardAnalyzer());
    String printed =
        new StandardSyntaxParser()
            .parse("fn:fuzzyTerm(abc)", "f")
            .toQueryString(new EscapeQuerySyntaxImpl())
            .toString();
    Query reparsed;
    try {
      reparsed = parser.parse(printed, "f");
    } catch (Exception e) {
      throw new AssertionError(
          "expected toQueryString output that parses back, but '" + printed + "' fails: " + e, e);
    }
    assertEquals(parser.parse("fn:fuzzyTerm(abc)", "f"), reparsed);
  }
}
Command
cd $LUCENE && ./gradlew :lucene:queryparser:test --tests TestFuzzyTermToQueryString -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
The printed text parses back to the same query as fn:fuzzyTerm(abc).
Observed
'f:fn:fuzzyTerm(abc 2128)' fails: java.lang.IllegalArgumentException: max edits must be 0..2, inclusive; got: 2128, with assertions on and off.

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

More files, environment, full output229 words

The test prints the node tree of fn:fuzzyTerm(abc) on field f and parses the printed text. The try/catch only puts the printed string into the failure message (the cause is kept); with a fix, the final assertEquals checks that the re-parsed query equals the direct parse. It covers the fuzzyTerm part, the least arguable one; the wildcard and literal cases are shown in the table above and were executed by the hunt's and a verifier's tests.

Regression test: this same class; it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 10.6.0 and 9.1.0: fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java string formatting and parsing)
Command (environment)
cp $REPRO/TestFuzzyTermToQueryString.java $LUCENE/lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/
cd $LUCENE && ./gradlew :lucene:queryparser:test --tests TestFuzzyTermToQueryString -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false fails the same way in the standalone runner)
> Task :lucene:queryparser:test FAILED

TestFuzzyTermToQueryString > testToQueryStringParsesBack FAILED
    java.lang.AssertionError: expected toQueryString output that parses back, but 'f:fn:fuzzyTerm(abc 2128)' fails: java.lang.IllegalArgumentException: max edits must be 0..2, inclusive; got: 2128
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:D6B50425F7113193]:0)
        at org.apache.lucene.queryparser.flexible.standard.TestFuzzyTermToQueryString.testToQueryStringParsesBack(TestFuzzyTermToQueryString.java:38)

        Caused by:
        java.lang.IllegalArgumentException: max edits must be 0..2, inclusive; got: 2128
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.FuzzyAutomatonBuilder.<init>(FuzzyAutomatonBuilder.java:39)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.FuzzyQuery.getFuzzyAutomaton(FuzzyQuery.java:191)

# standalone runner, seed DEADBEEF: main 645d4fe14a20 and 10.6.0 fail with -ea and -da, 9.1.0 fails with -ea

Why it looks unintended

  • toQueryString is documented as "convert to a query string understood by the query parser"; the package docs promise text that "can be parsed back".
  • abc 2128 is not a display form under any reading, and, on inspection, every other interval function prints its grammar syntax.
  • 11.0 fixed the same round-trip failure for range nodes as a bug (GITHUB#7865).
The full argument211 words
  • The contract: QueryNode.java:26 ("convert to a query string understood by the query parser") and core/nodes/package-info.java:42-43 ("A query node tree can also generate a query string that can be parsed back by the original text parser, at this point only the standard lucene syntax is supported"); EscapeQuerySyntax exists "to allow the QueryNode to escape the queries, when the toQueryString method is called" (EscapeQuerySyntax.java:22-23).
  • The inputs are accepted, tested syntax: CHANGES 9.1.0 lists LUCENE-10223 (interval functions) and LUCENE-10414 ("Add fn:fuzzyTerm interval function to flexible query parser", lucene/CHANGES.txt:4415-4417, :4452-4453); upstream tests parse fn:wildcard(foo* 128) ("Explicit maxExpansions") and fn:fuzzyTerm(dfe 2 128) (TestStandardQPEnhancements.java:97-102, :156-162); quoted literals are documented ("Quoted or unquoted character sequences ...", intervalfn/package-info.java:149). The package docs list only fn:wildcard(glob) and not fn:fuzzyTerm, so "accepted, tested syntax" is the accurate wording.
  • The code aims for parseable output: on inspection, the other interval functions print their grammar syntax (the controls round-trip and the 8,352-expression grid shows no failure outside the three printers; there is no dedicated round-trip suite over all functions) and AnalyzedText already quotes when it thinks it must.
  • Precedent: "GITHUB#7865 AbstractRangeQueryNode#toQueryString(EscapeQuerySyntax) now returns a string which can be parsed back into the original node." (lucene/CHANGES.txt:296-298, 11.0.0 Bug Fixes; PR #14697 with regression tests).
The devil's-advocate check172 words

The strongest case for "display only": IntervalFunction#toString has no javadoc; the only upstream test that touches the output prints it "for visual diagnostics" and asserts nothing (TestStandardQPEnhancements.java:180-184); maxExpansions:N looks like a deliberate human-readable label; a 2017 java-user reply said a round trip between a parsed query and its string form was never guaranteed; open #8667 calls the toQueryString design flawed; and no in-tree caller re-parses the text.

Why it fails: IntervalQueryNode#toQueryString, the method whose javadoc promises parser syntax, returns exactly these strings, and IntervalQueryNode#toString delegates to it, so even if IntervalFunction#toString were diagnostic only, toQueryString would still need the fix. The 2017 reply was answered in the same thread with "Why don't we fix this in Lucene?", and the project later fixed the range-node case as a bug (GITHUB#7865). #8667 is about nodes built by custom syntax parsers, while the package docs promise the round trip for "the standard lucene syntax". And abc 2128 cannot be a deliberate display choice. All verifiers and the adjudication rejected the intended-behaviour case.

Suggested fix

Recommended Make the three printers emit grammar syntax (+12/-4, validated)

Make the three printers emit the grammar's own syntax: "fn:fuzzyTerm(%s %d %d)" in FuzzyTerm, the bare number (" " + maxExpansions) and a quoted glob when needed in Wildcard, and quote every literal the lexer would not read back as one term in AnalyzedText.requiresQuotes. +12/-4 lines; it passes the repro and the whole queryparser module.

// FuzzyTerm.toString()
"fn:fuzzyTerm(%s %d %d)",
// Wildcard.toString()
AnalyzedText.requiresQuotes(wildcard) ? '"' + wildcard + '"' : wildcard,
maxExpansions == 0 ? "" : " " + maxExpansions);
// AnalyzedText.requiresQuotes(String term)
return term.isEmpty()
    || Pattern.compile("[\\s\\u3000+\\-!():^@<>=\\[\\]\"{}~\\\\/]").matcher(term).find()
    || term.equals("AND") || term.equals("OR") || term.equals("NOT")
    || term.equals("&&") || term.equals("||") || term.startsWith("fn:");
All options and trade-offs3 options · 655 words

Two fixes were written and validated in a scratch worktree of 645d4fe14a20: the full fix (recommended) and a smaller numbers-only fix; a dedicated serializer is described but not written.

Make the three printers emit grammar syntax (+12/-4, validated) (recommended)

The change touches FuzzyTerm.java, Wildcard.java and AnalyzedText.java (the hunt's fix sketch, reformatted by applyGoogleJavaFormat). fn:fuzzyTerm prints both numbers separated (fn:fuzzyTerm(abc 2 128)); fn:wildcard prints the bare number and quotes a glob that needs it; a literal is quoted when it is empty, contains whitespace including U+3000, contains any character a term cannot start with, equals AND, OR, NOT, && or ||, or starts with fn: (redundant, since : is already covered). That covers every literal the lexer would not read back as one term, and a few it would: + and - may follow the first character of a term, so fn:or(a-b c) now prints as fn:or("a-b" c). Quoting more than needed changes the text but not its meaning: a quoted and an unquoted literal build the same node. Apart from such extra quotes, every other printed form is unchanged.

// FuzzyTerm.toString()
"fn:fuzzyTerm(%s %d %d)",
// Wildcard.toString()
AnalyzedText.requiresQuotes(wildcard) ? '"' + wildcard + '"' : wildcard,
maxExpansions == 0 ? "" : " " + maxExpansions);
// AnalyzedText.requiresQuotes(String term)
return term.isEmpty()
    || Pattern.compile("[\\s\\u3000+\\-!():^@<>=\\[\\]\"{}~\\\\/]").matcher(term).find()
    || term.equals("AND") || term.equals("OR") || term.equals("NOT")
    || term.equals("&&") || term.equals("||") || term.startsWith("fn:");

Trade-offs

  • Changes toString only; nothing on a search path. The pattern is compiled per call, as today; a static final Pattern would be a natural cleanup.
  • A hand-built node whose literal holds a raw double quote still prints unparsably (the inner quote is not escaped). The parser itself keeps backslashes inside interval literals (a separate grammar bug); when that is fixed, the printer must also escape " and \ inside quotes, so the two fixes should agree. More generally, the public constructors can build nodes the current syntax cannot represent, so the fix is validated for parser-originated input and the tested grid, not as a general serializer.
  • Not a complete serializer fix: IntervalQueryNode#toQueryString still ignores its EscapeQuerySyntax argument, and the field: prefix is still printed unescaped (LFV-356, a separate fix site).

What the checks validated

Gradle in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the minimal test fails, a verifier's 8-test round-trip class fails 5 of 8. With the fix:

  • the minimal test passes with assertions on and off;
  • the verifier's 8-test class (fuzzyTerm defaults and explicit arguments, wildcard with maxExpansions, the "a:b" and U+3000 literals, 3 controls) passes 8 of 8;
  • the whole :lucene:queryparser:test passes: 30 classes, 533 tests, 0 failures, 1 skipped;
  • checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for queryparser pass.

Earlier, on an identical copy of the four files: the finder's 5 tests, 132 upstream queryparser tests, 7 extra edge cases and the 8,352-expression round trip (0 failures) all pass with it.

Fix only the two number formats now (+2/-2, validated on the number cases)

the FuzzyTerm format "fn:fuzzyTerm(%s %d %d)" and the Wildcard suffix " " + maxExpansions; literal quoting unchanged, to be fixed together with the grammar-side unescaping.

Trade-offs

  • Removes the least arguable part with no interaction with the grammar bug.
  • Leaves the quoted-literal cases ("a:b", "AND", U+3000) broken until then.

What the checks validated

Same run: the minimal test passes with assertions on; the verifier's 8-test class has exactly its 2 literal tests still failing. Module suite and lint not run for this subset.

A dedicated serializer instead of toString (alternative, not written)

Give IntervalFunction an explicit toQueryString(EscapeQuerySyntax) and call it from IntervalQueryNode#toQueryString, leaving toString free to be a display form.

Trade-offs

  • Makes the contract explicit and lets the printer use the escaper once the grammar unescapes interval literals.
  • More code and a new method on every interval function class, while, on inspection, the existing toString methods already print grammar syntax for every other function.

Downstream note

An application that checks or rewrites a parsed node tree and then re-parses its toQueryString text should not rely on the text meaning the same as the tree, and should add restrictions as separate Query clauses (for example a FILTER clause) instead.

How it was found

A hunt that checked the flexible parser against its documented syntax ran a bounded-exhaustive round trip: 8,352 interval expressions were parsed, printed with toQueryString and parsed again on the stock build. 6,100 did not come back the same (121 as a silently different query); with the fix sketch, none failed.

The method in full131 words

Method: semi-formal (a bounded-exhaustive check of the stock jars, confirmed by JUnit tests).

  • Signal. While writing a reference model of the interval syntax, an exploratory probe printed fn:fuzzyTerm(abc 2128) for fn:fuzzyTerm(abc).
  • Exhaustive check. Every expression of the unit's grid (18 leaves, including quoted literals with :, AND, &&, || and U+3000, and every depth-1 function over them: 8,352 expressions) was parsed with StandardSyntaxParser, printed with toQueryString, parsed again with StandardQueryParser, and the two queries compared with equals.
  • Confirmation. A public-API JUnit failed 4 of 5 on main with assertions on and off; then an independent reproducer, a devil's-advocate verifier, read-only cross-model reviews (OpenAI GPT-5.6) and an adjudication checked it, and a follow-up ran the version history, a prior-report search and a security screen.

Prior reports, history and checks

Prior reports

No prior report found (two search-tool runs with 96 queries, 17 manual GitHub searches, all 408 open PRs, the lists, JIRA and the web on 2026-10-27; rechecked 2026-10-31). Related: #8667 (toQueryString design) and #7865 / PR #14697 (range-node round trip, fixed in 11.0).

Searches3 searches

Verdict new: no issue, PR, review comment, list thread, JIRA entry, commit or downstream report says that IntervalQueryNode#toQueryString or the interval-function toString methods print text that does not parse back. No open PR touches nodes/intervalfn or IntervalQueryNode.

Related, not this bug: #8667 / LUCENE-7616 (open since 2017: toQueryString always emits standard syntax, whatever syntax parser built the node); #7865 / PR #14697 (range nodes, fixed in 11.0, the precedent); open PRs #16853 and #16892 (the escaper, which IntervalQueryNode ignores); #16820 (open proposal to deprecate the flexible parser in Lucene 11); the 2017 java-user thread on range queries parsed twice.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and three downstream search projects)2026-10-27two runs of a scripted prior-report search (51 and 45 queries, incl. class and method names), a re-read of the finder's run, 17 manual GitHub searches, the changed files of all 408 open PRs, the #429 and #668 review threads read in full137 and 344 distinct hits; the only high hit is the introducing PR #668; none is this bug
Mailing lists, JIRA and the web2026-10-27dev@, java-user@, issues@, commits@; the JIRA archive; 11 web searchesonly the 2017 range-query thread and older toQueryString threads; nothing on interval functions
GitHub (recheck)2026-10-318 issue/PR searches (fuzzyTerm toString, IntervalQueryNode, toQueryString interval, fn:fuzzyTerm, requiresQuotes, ...), toQueryString newest first, the 25 newest issues and 25 newest PRs, tracking issue #16937nothing new

History

Introduced in 9.1.0 by LUCENE-10223 (#429: the interval syntax, toQueryString and the literal quoting) and LUCENE-10414 (#668: fn:fuzzyTerm and the wildcard maxExpansions format); the four files are unchanged since, through 10.6.0 and main.

Versions tested4 rows

Introduced in two steps by Dawid Weiss: f725b27e12a "LUCENE-10223: interval support in standard syntax parser (#429)" (2021-11-11) added IntervalQueryNode#toQueryString, the whitespace-only quoting and the unquoted wildcard glob; f6cebac3337 "LUCENE-10414: Add fn:fuzzyTerm interval function to flexible query parser (#668)" (2022-02-10) added the fn:fuzzyTerm(%s %d%s) format and the maxExpansions: suffix. Their branch_9x backports both first shipped in 9.1.0. Not a regression: each defect is in the code that added its feature; the round-trip promise dates from the flexible parser itself (LUCENE-1567, 2009).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal repro fails, assertions on and off
9.1.0 to 10.5.2, main a9779ef3c193 affectedthe same 7 misprints on 18 releases and main (portable probe); the minimal repro fails on 9.1.0; the other 14 releases have byte-identical defect files
9.1.0 without LUCENE-10414 (emulation) quoting onlyonly the literal and glob quoting misprints; no fn:fuzzyTerm or wildcard maxExpansions yet
9.0.0 not affectedno interval functions in the flexible parser (fn:... parses as a field named fn)

Independent checks

An independent reproducer, a root-cause review by OpenAI GPT-5.6, an intended-behaviour devil's advocate and an adjudication found it real (adjudication: confirmed, high confidence); the only dissent was a procedural "not real" from a read-only reviewer that could not run Java. This doc's gradle run reproduced it and validated the fix.

Checks and verdicts363 words

Every executed check ran on stock builds; the cross-model reviewers could only read code, and separate runs re-ran the repro.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (bounded-exhaustive round trip, then a JUnit) candidate5-test class fails 4/5 on main with assertions on and off, and on 10.5.2, 9.12.3 and 9.1.0; the control passes. Round trip: 6,100 of 8,352 fail, 0 with the fix sketch; the sketch passes 5/5 and 132 upstream tests.
Refute lens (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run Java, so its "not real" is procedural; agreed on the root cause ("Static inspection strongly supports a real defect"). A separate run re-ran the repro: 4/5 fail.
Intended lens (OpenAI GPT-5.6, read-only) real (high)The display-only argument rated moderate and failing; found that the fix sketch's quoting missed &&, || and U+3000 (widened and re-validated).
Independent reproducer realOwn 8-test class from the claim: 5 fail on main with assertions on and off and on 10.5.2, 9.12.3, 9.1.0, including the silent U+3000 case; 3 controls pass; 8/8 with the fix.
Root-cause lens (OpenAI GPT-5.6, read-only) real (medium)First wrong state in the three printers, not the grammar. Separate runs: repro 4/5 fail, fix 5/5, 7 edge cases (4 fail on main, 7/7 with the fix), U+3000 fails on main and passes with the fix; caveat: a hand-built raw double quote still misprints.
Intended-behaviour devil's advocate realRe-ran the repro (4/5 fail, assertions on and off), built and rejected the display-only case, and showed the misprint in the parser's too-many-clauses message.
Adjudication confirmed (high), minorRe-ran the independent reproduction's class on a clean checkout with seed C0FFEE02: 5 of 8 fail with assertions on and off.
Minimal repro still reproducesOne test on 645d4fe14a20: fails with assertions on and off, passes with the fix sketch.
This doc realStandalone runner: fails on main 645d4fe14a20, 10.6.0 and 9.1.0. Gradle in a scratch worktree: stock fails; the recommended fix passes the repro, the verifier's 8 tests, the whole queryparser module (533 tests) and lint; the numbers-only fix leaves only the 2 literal tests failing.

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

Finding record

LFV-748, snapshot of 2026-11-06

Registry title
Flexible query parser: IntervalQueryNode#toQueryString prints some fn: interval functions in a form the parser rejects or reads as a different query (fn:fuzzyTerm(abc) prints as fn:fuzzyTerm(abc 2128))
Area
queryparser / flexible StandardQueryParser interval function nodes (IntervalQueryNode#toQueryString; intervalfn FuzzyTerm, Wildcard and AnalyzedText toString)
Priority
Low, rank 772: A minor toQueryString round-trip break for fn: interval nodes that matters only to applications that re-parse node text, which no in-tree caller does.
Impact class
minor
Impact note
A round-trip (contract) defect in a utility method, with no effect on the Query that StandardQueryParser.parse builds: applications that turn flexible-parser node trees with fn: interval functions back into text with toQueryString (or IntervalQueryNode#toString) and parse or forward that text get a string that does not parse for every fn:fuzzyTerm, for fn:wildcard with a maxExpansions argument and for quoted literals that hold a query-syntax character such as ':' or a keyword such as AND, and a silently different query for some quoted literals (one that holds U+3000, or a closing parenthesis as in "a)(b"); inside Lucene the output only feeds exception messages (for example the parser's own too-many-clauses message); default parser configuration, any scale, every release from 9.1.0 to 10.6.0 and main.
Summary
You parse fn:fuzzyTerm(abc), fn:wildcard(ab* 5) or fn:or("a:b" c) with the flexible syntax parser, print the node tree with toQueryString, and expect a string that StandardQueryParser reads back as the same query, as the flexible parser's package documentation promises ('a query string that can be parsed back by the original text parser'). Instead FuzzyTerm prints maxEdits and maxExpansions with no space between them (fn:fuzzyTerm(abc 2128), which re-parses as maxEdits 2128 and throws 'max edits must be 0..2'), Wildcard prints 'maxExpansions:5', a syntax error, and AnalyzedText quotes a literal only when it contains ASCII whitespace, so fn:or("a:b" c) prints the unparsable fn:or(a:b c). A quoted literal with the ideographic space U+3000 prints unquoted and re-parses, with no error, as two separate literals.
Introduced
f725b27e12a992f12aab12a8e8bfb9b31125bdae (main, 2021-11-11, by dweiss): 'LUCENE-10223: interval support in standard syntax parser (#429)' (issue LUCENE-10223 = GitHub #11259, PR apache/lucene#429), which added IntervalQueryNode#toQueryString (field + ':' + the function's toString, the EscapeQuerySyntax argument unused), the whitespace-only quoting rule AnalyzedText.requiresQuotes and the unquoted wildcard glob; and f6cebac3337926ca871b922241976a4ba4799c70 (main, 2022-02-10, by dweiss): 'LUCENE-10414: Add fn:fuzzyTerm interval function to flexible query parser (#668)' (GitHub #11450, PR #668), which added FuzzyTerm.toString with the format 'fn:fuzzyTerm(%s %d%s)' and the Wildcard ' maxExpansions:' suffix; the branch_9x backports ff9ee28c603 and 9a293da5967 are the commits that shipped in 9.1.0 [read: git blame -w on main and git log -S on the format strings and requiresQuotes find only these two commits; the split between them was also executed, on 9.1.0 jars with the five queryparser files of LUCENE-10414 reverted (an emulation, not a release build)] (2021-11-11 (LUCENE-10223: literal quoting, unquoted glob, IntervalQueryNode#toQueryString); 2022-02-10 (LUCENE-10414: fuzzyTerm numbers run together, 'maxExpansions:N' suffix))
First affected version
9.1.0 (tagged 2022-03-21) [exec]: both introducing changes first shipped there; 9.0.0 has no interval functions in the flexible parser at all (fn:... parses as a field named 'fn') [exec], so no release that has the feature is unaffected
Affected versions
every release with fn: interval functions in the flexible StandardQueryParser, 9.1.0 through 9.12.3 and 10.0.0 through 10.6.0, and main a9779ef3c193 and 645d4fe14a20 (unreleased 11.0); never fixed. [exec 2026-10-31] the minimal repro fails 1 of 1 on 10.6.0 with -ea and -da and on main 645d4fe14a20; the four defect files have the same blobs at releases/lucene/10.6.0 as at 645d4fe14a20. [exec] a portable main() probe reports the same 7 misprints, with its control OK, on 18 releases (9.1.0, 9.2.0, 9.3.0, 9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.2, 9.10.0, 9.11.1, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.2) and on main (-ea and -da), byte-identical per-case output; the finder's JUnit fails 4 of 5 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 5 of 8 on 9.1.0, 9.12.3 and 10.5.2 (-ea) and on main (-ea and -da). [read] the other 14 releases of the range (9.4.0, 9.4.1, 9.9.0, 9.9.1, 9.11.0, 9.12.0-9.12.2, 10.2.0, 10.2.1, 10.3.0, 10.3.1, 10.5.0, 10.5.1): FuzzyTerm.java, Wildcard.java, AnalyzedText.java and IntervalQueryNode.java have identical blob hashes at all 32 release tags 9.1.0..10.5.2 and on main.
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; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new): two scripted prior-report searches (A: 51 queries, 137 distinct hits, 0 high, 7 medium; B: 45 queries on the class and method names, 344 distinct hits, 1 high = the introducing PR #668, 11 medium) over GitHub issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; a re-read of the finder's run (51 queries, 101 hits); 17 manual GitHub searches; the file lists of all 408 open apache/lucene PRs (none touches nodes/intervalfn or IntervalQueryNode); 11 general web searches; the review threads of #429 and #668 read in full. Upstream main has the code unchanged (nodes/intervalfn last changed by #668 in 2022, IntervalQueryNode.java only by #429 in 2021). Related, not this bug: #8667 / LUCENE-7616 (open since 2017, by others: toQueryString always emits standard syntax whatever syntax parser built the node; a design issue about custom syntax parsers that predates interval functions), #7865 / PR #14697 (the range-node round trip, fixed as a bug in 11.0; the precedent), open outside PRs #16853 and #16892 (escaper and escape helper; IntervalQueryNode#toQueryString ignores the escaper, so neither changes interval output), #16820 (open proposal to deprecate the flexible parser in Lucene 11) and the 2017 java-user thread 'Range queries get misinterpreted when parsed twice via the Standard parsers' (range nodes). Two outside ports (Kotlin, Go) copy FuzzyTerm.toString unchanged, with no report.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off through a standalone runner, and with assertions on through gradle in a scratch worktree; with the recommended fix it passes, as do a verifier's 8-test round-trip class and the whole queryparser module. Releases 10.6.0 (assertions on and off) and 9.1.0 fail with the standalone runner. Earlier, the hunt ran a portable probe on 18 releases from 9.1.0 to 10.5.2 and on main a9779ef3c193. Upstream main 2f424fd428da (2026-10-30) and branch_11x 603af8703753 (2026-10-29) carry byte-identical defect files [read].

Lucene and upstream sources

Finding record
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/FuzzyTerm.java:52-60, Wildcard.java:45-51, AnalyzedText.java:45-55, standard/nodes/IntervalQueryNode.java:47-55, core/nodes/package-info.java:42-43, core/nodes/QueryNode.java:26-27 (read during the follow-up)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/IntervalQueryNode.java:47-55
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/FuzzyTerm.java:34-60; Wildcard.java:44-51; AnalyzedText.java:44-55
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:130-168 (lexer), :641-686 (IntervalWildcard, IntervalFuzzyTerm, IntervalText)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/FuzzyAutomatonBuilder.java:38-39; FuzzyQuery.java:54; lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:64
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/builders/QueryTreeBuilder.java:161; standard/builders/BooleanQueryNodeBuilder.java:72; MatchAllDocsQueryNodeBuilder.java:42; MatchNoDocsQueryNodeBuilder.java:42; standard/processors/AllowLeadingWildcardProcessor.java:74
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/core/nodes/QueryNode.java:26; core/nodes/package-info.java:42-43; core/parser/EscapeQuerySyntax.java:22-23
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:296-298 (11.0.0 Bug Fixes), :4415-4417 and :4452-4453 (9.1.0)
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/TestStandardQPEnhancements.java:97-102, :156-162, :180-184
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:149, :183
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:131-136, :649-650, :668-669, :684-685
History
  • a Lucene clone git ls-tree at releases/lucene/9.1.0, releases/lucene/10.5.2, releases/lucene/10.6.0, origin/main 2f424fd428da and origin/branch_11x 603af8703753 (same blob hashes as 645d4fe14a20 for the four defect files)

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 from the code at 645d4fe14a20 and the minimal repro.

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.