All findings · Lucene finding · LFV-699 · tstlookup-store-load-surrogate-splitchar

TSTLookup loses emoji and CJK Extension B suggestions after store() and load()

A TSTLookup saved with store() and reloaded with load() no longer finds suggestions through an emoji or a CJK Extension B character: lookup("𠮷") returns [] instead of [𠮷野家/5], while load() returns true and ASCII entries still work. Every release since 4.7.0 (2014) has it.

Summary

Priority
Low, rank 273Needs the little-used TSTLookup plus its optional store/load plus supplementary characters in the dictionary.
Impact
silent wrong results
Since
4.7.0 (LUCENE-5404, 2014); 4.6.1 was losslessintroduced 2014-02-07 · over 12 years
Affected
4.7.0 to 10.5.2 and main
Status
documented
Found by
randomized differential check of the suggesters with a store/load round trip

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

TSTLookup is a suggester that keeps its entries in a ternary search tree, one node per UTF-16 code unit. store() and load() save and reload that tree. An emoji or a CJK Extension B character takes two code units (a surrogate pair), so it becomes two nodes.

Diagram of the TSTLookup store/load bug: the key 𠮷野家 is split into the tree nodes D842, DFB7, 野 and 家; store() writes each surrogate node as the bytes EF BF BD (U+FFFD); after load() those nodes hold U+FFFD, and lookup of 𠮷 returns an empty list instead of 𠮷野家 with weight 5.
One tree node per UTF-16 unit: store() writes each half of 😀 and 𠮷 as U+FFFD, and the reloaded search for 𠮷 takes the wrong branch; the recommended fix writes a lone surrogate in a 3-byte form. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Build a TSTLookup from 𠮷野家 (weight 5), 😀 smile (4) and apple (3). lookup("𠮷") returns [𠮷野家/5] and get("𠮷野家") returns 5.
  2. Save it with store() and read it back with load() into a fresh TSTLookup. Both return true; getCount() is still 3 and lookup("ap") is still [apple/3].
  3. You expect the reloaded copy to answer the same. Instead lookup("𠮷") and lookup("😀") return [], get("𠮷野家") returns null, and lookup("�") (U+FFFD), empty before, now returns [😀 smile/4].
  4. Why: the tree has one node per UTF-16 unit, so 𠮷 is two nodes, D842 and DFB7. store() writes each node's char as its own UTF-8 string; half a pair has no UTF-8 form, so both are saved as U+FFFD. After load() the search compares D842 with FFFD and takes the wrong branch.
  5. Neighbours are hit too: in a lookup of 😀 smile, Ab and ab, the fullwidth key is lost (lookup("A") gives [], was [Ab/4]). Nothing fails loudly.

Who is affected

Applications that use TSTLookup, save it with store(), reload it with load() (for example at startup) and have emoji or CJK Extension B characters in their suggestions. One entry is enough. The loss is silent and lasts until the lookup is rebuilt (the stored tokens would allow a repair, but no version does it); every release since 4.7.0 is affected.

Mechanism, terms and numbers758 words

TSTLookup keeps its suggestions in a ternary search tree with one node per UTF-16 code unit (TSTAutocomplete.java:63), so a supplementary character such as the emoji 😀 (U+1F600) or the CJK Extension B character 𠮷 (U+20BB7) becomes two nodes, a high surrogate and a low surrogate. store() writes each node's split char as a one-char String with DataOutput.writeString (TSTLookup.java:244), which encodes UTF-8; a lone surrogate has no UTF-8 form and is written as EF BF BD, U+FFFD, and load() reads it back with readString().charAt(0) (:219). load() returns true, the count and the ASCII entries survive, but get() and every lookup whose prefix reaches such a character return nothing, BMP keys on the high side of such a node (fullwidth forms, halfwidth katakana) can be lost too, and a U+FFFD prefix can return keys it never matched. It is a regression from LUCENE-5404 (2014), which replaced the lossless writeChar/readChar: 4.6.1 round-trips, and every release from 4.7.0 to 10.5.2 and main does not. A layout-compatible fix keeps every existing file loadable; stores already written with such characters stay lossy until they are rebuilt (their tokens are stored whole, so a loader could also repair them; a probe showed this).

Terms

  • TSTLookup: a prefix suggester in lucene/suggest. build() sorts the entries and inserts them into a ternary search tree; lookup(prefix, ...) walks the tree along the prefix and lists the entries below; get(key) returns a key's weight. Each node has a split char and three children: lower, equal (next char of the key) and higher.
  • Surrogate pair: Java strings are UTF-16. A character above U+FFFF, such as 😀 U+1F600 (D83D DE00) or 𠮷 U+20BB7 (D842 DFB7), is stored as two chars, a high and a low surrogate. Neither half is a character on its own.
  • Lookup.store / Lookup.load: "Persist the constructed lookup data" and "load it from a previously saved copy" (Lookup.java:236-252), for example to avoid rebuilding at startup.

The bug

  1. TSTAutocomplete.insert makes one node per char: newNode.splitchar = s.charAt(x) (TSTAutocomplete.java:63). 𠮷野家 is four nodes: D842, DFB7, 91CE, 5BB6. The public TSTLookup.add(key, value) calls the same insert (TSTLookup.java:149-150).
  2. TSTLookup.writeRecursively writes out.writeString(new String(new char[] {node.splitchar}, 0, 1)) (TSTLookup.java:244). writeString encodes UTF-8 (DataOutput.java:263-275, through new BytesRef(s), whose javadoc asks for text "with no unpaired surrogates", BytesRef.java:77, and UnicodeUtil.UTF16toUTF8), and UnicodeUtil writes EF BF BD (U+FFFD) for an "unpaired surrogate" (UnicodeUtil.java:231-235).
  3. readRecursively reads in.readString().charAt(0) (TSTLookup.java:219): U+FFFD. Everything else (node layout, tokens, weights, the count) round-trips, so load() returns true.
  4. The search compares the query's char with each node's split char (TSTAutocomplete.java:103-114). A query char D842 meets a node that now holds FFFD, goes to the lower child, and finds nothing.

The tree of the repro, before and after (white-box dump)

 before store()                             after load()
  hi: D83D  first half of 😀                  hi: FFFD
      lo: 'a' p p l e -> apple/3                  lo: 'a' p p l e -> apple/3
      eq: DE00 ' smile' -> 😀 smile/4              eq: FFFD ' smile' -> 😀 smile/4
      hi: D842  first half of 𠮷                  hi: FFFD
          eq: DFB7 野 家 -> 𠮷野家/5                    eq: FFFD 野 家 -> 𠮷野家/5

 lookup("𠮷") after load():  D842 < FFFD -> lower child 'a' -> D842 > 'a' -> no higher child -> []
 lookup("�") after load():  FFFD = FFFD -> list everything below -> [😀 smile/4]

(The tree's top node is an empty root with split char U+0000; the dump starts at its higher child.)

The three effects

effectexample (executed)
get() and lookups whose prefix reaches a surrogate node misslookup("𠮷"): [𠮷野家/5] -> []; get("𠮷野家"): 5 -> null
a BMP key whose char at that level is in U+E000..U+FFFD and sits on the high side of the node is lost (a char below U+FFFD turns lo, U+FFFD itself now goes down the surrogate's eq branch; U+FFFE and U+FFFF still go hi)lookup("A"): [Ab/4] -> [] (fullwidth U+FF21); keys at U+E000 and U+FFFD lost beside a surrogate node, keys at U+D7FF, U+FFFE, U+FFFF kept; a halfwidth katakana key in an independent test
a U+FFFD prefix can match supplementary keyslookup("�"): [] -> [😀 smile/4]

Tokens are stored whole, so a prefix that ends before the first surrogate node still lists the key (the prefix a still finds a😀): the loss depends on the lookup path, not only on the key. ASCII keys and getCount() are unchanged.

Exposure and likelihood313 words
  • Who is exposed. TSTLookup is opt-in: an application picks it among the Lookup implementations. It is not deprecated and has no experimental tag of its own (class javadoc TSTLookup.java:36-42), though its base class Lookup is @lucene.experimental (Lookup.java:38). Only lookups that are persisted with store() and reloaded with load() are hit, and only for text with characters above U+FFFF, whether built with build() or added with add().
  • Silent. load() returns true, getCount() is right and ASCII entries work. Only lookups whose path reaches a surrogate node change: users who type such a character, and users who type some BMP prefixes beside such a node (the fullwidth A, U+FFFD), get missing or wrong suggestions. It does not depend on randomness: the same with assertions on and off and with every seed tried.
  • Lasting. The saved file holds U+FFFD in place of the surrogate split chars, and no Lucene version, fixed or not, reads them back. The tokens and weights are stored whole, though: a white-box probe re-derived the lost split chars from them and got every answer back. Without such a repair step, the lookup has to be rebuilt from its source.
  • How much changes. In the finder's random sweep (100,000 dictionaries of 1-6 keys, with U+00E9, U+1F600 and U+FFFD among the possible keys; 341,084,860 lookups over 20 suggester configurations), the round trip changed 245,800 TSTLookup results, and 0 with the fix. An independent test: 13 of 24 code-point-boundary prefixes change.
  • Siblings are fine. FSTCompletionLookup and WFSTCompletionLookup round-trip the same keys.
  • Downstream (read, not run). In a 2020 Lucene/Solr source snapshot, Solr's suggester reloads a persisted lookup from a storeDir, and TSTLookupFactory creates a TSTLookup. The Lucene.NET port carries the same two lines.
  • How likely. Rare: it needs a persisted TSTLookup, such characters in the dictionary, and a lookup through them; no report of it was found.

Minimal repro

The test shows that TSTLookup.store/load loses a key that contains a supplementary character (U+20BB7): each surrogate split char is written with writeString and comes back as U+FFFD.

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

import java.io.StringReader;
import org.apache.lucene.search.suggest.FileDictionary;
import org.apache.lucene.store.ByteBuffersDataOutput;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTSTLookupStoreLoadSupplementary extends LuceneTestCase {

  public void testStoreLoadKeepsSupplementaryKey() throws Exception {
    String key = "\uD842\uDFB7\u91CE\u5BB6";
    try (Directory dir = newDirectory()) {
      TSTLookup lookup = new TSTLookup(dir, "tst");
      lookup.build(new FileDictionary(new StringReader(key + "\t5")));
      ByteBuffersDataOutput out = new ByteBuffersDataOutput();
      lookup.store(out);
      TSTLookup loaded = new TSTLookup(dir, "tst");
      loaded.load(out.toDataInput());
      // key starts with U+20BB7; expected get() after store/load to be 5, actual null
      assertEquals(Long.valueOf(5), loaded.get(key));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-699/, 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/suggest/build/libs/lucene-suggest-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

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

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.suggest.tst.TestTSTLookupStoreLoadSupplementary
Expected vs actual
Expected get(key) on the reloaded lookup to return the weight 5; actual null (expected:<5> but was:<null>), with -ea and -da.

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

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:<5> but was:<null>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.suggest.tst.TestTSTLookupStoreLoadSupplementary.testStoreLoadKeepsSupplementaryKey(TestTSTLookupStoreLoadSupplementary.java:37)

Why it looks unintended

  • Lookup.load promises to "load it from a previously saved copy", and TSTLookup.load returns true.
  • Upstream TestPersistence checks that every key keeps its exact weight after a TSTLookup store/load (ASCII keys only); before the reload, every emoji key is found.
  • A regression: 4.6.1 wrote the split char losslessly with writeChar and round-trips; 4.7.0 switched to writeString and does not.
The full argument192 words
  • The contract. Lookup.store: "Persist the constructed lookup data ... @return true if successful"; Lookup.load: "Discard current lookup data and load it from a previously saved copy" (Lookup.java:236-252). TSTLookup implements both and returns true (TSTLookup.java:267-280).
  • The upstream test. TestPersistence.testTSTPersistence runs runTest(TSTLookup.class, true) (TestPersistence.java:50-52): it stores, reloads into a new TSTLookup and asserts that every key returns 1 hit with its exact weight (:82-105), for 15 ASCII words (:31-48).
  • Nothing limits it to the BMP. No javadoc restricts TSTLookup to BMP text; it sorts its input with a comparator whose comment explains where supplementary code points go (TSTLookup.java:73-115; a TODO at :71 asks whether the comparator is needed at all); before store() every repro's supplementary key is found.
  • History. Before LUCENE-5404 the split char was written with DataOutputStream.writeChar and read with readChar (4.6.1, TSTLookup.java lines 154 and 179), lossless for any UTF-16 unit. LUCENE-5404 (2014) moved store/load to DataOutput/DataInput and replaced them with writeString/readString().charAt(0). Its discussion (19 comments) and its 4.7.0 CHANGES entry never mention characters. A probe round-trips on 4.6.1 and not on 4.7.0.
The devil's-advocate check232 words

The strongest "intended" case (the devil's-advocate verifier; the reviews by another model family rated it weak): writeString is documented as UTF-8, and since LUCENE-510 (2008) "If any text ... has illegal UTF-16 surrogate characters, these characters are now silently replaced with the Unicode replacement character U+FFFD"; store/load is an "Optional operation" with no format header or version, and Lookup is @lucene.experimental; no upstream test stores non-ASCII keys in a TSTLookup; and LUCENE-5404 changed the format on purpose.

Why it fails: the caller's text is valid; TSTLookup itself splits it into lone surrogates and passes one to writeString as if it were a string, so DataOutput behaves as documented and TSTLookup's use of it is the defect; writeString builds a new BytesRef(s), whose javadoc asks for text "with no unpaired surrogates" (BytesRef.java:77). "Optional" lets an implementation return false; it does not allow returning true and loading a different lookup. An untested input is not an unsupported one, and the comparator anticipates it. The format change was deliberate; the character loss was a side effect nobody discussed. Verdict: a defect (adjudication: confirmed, high).

What a maintainer may still choose: the second cross-model fact check rated "intended" weak but "acceptable / won't fix" moderate. A maintainer could document the limit, deprecate TSTLookup, or prefer a format break with a header to a hand-written decoder in an unversioned format.

Suggested fix

Recommended Keep the layout; write a lone surrogate as its 3-byte generalized-UTF-8 form

Write a lone surrogate split char as its 3-byte form (ED A0 80 to ED BF BF) and decode it on load; every other char is written as today, so existing files load as before. The repro and the module's 238 tests pass. Old stores stay lossy until rebuilt, or repaired from their stored tokens.

All options and trade-offs4 options · 1,187 words

All options make the split char round-trip; they differ in what happens to files already on disk. Executed matrix (two dictionaries, each stored by each build with store(OutputStream) and loaded by each build): a stock file read by fix B answers exactly as on stock; an ASCII-only file is byte-identical under fix B; a fix B file read by stock answers as today. Under fix A, both stock files in the probe load with load() = true and every lookup empty, the ASCII-only one included, which works today; a fix A file makes stock and fix B throw StringIndexOutOfBoundsException. The 10.5.2 and 9.12.3 readers print exactly what main's stock reader prints for all six files (executed). A pre-9.0 reader misreads the weights of every file written by 9.0 or later, stock files included (9.0 made the Directory API little-endian, LUCENE-9047; executed with 8.8.1: apple/2 reads back as apple/144115188075855872), independent of this bug. Options A-C do not repair files written by 4.7.0 to main: their surrogate nodes already hold U+FFFD. The tokens are stored whole, though, so a loader could re-derive the split chars (option D; a probe did it, no patch). Add the new test with whichever option, and a CHANGES note that such stores must be rebuilt (unless the fix adds option D).

Keep the layout; write a lone surrogate as its 3-byte generalized-UTF-8 form (validated by the checks) (recommended)

writeRecursively and readRecursively call two small helpers instead of writeString/readString().charAt(0). A surrogate is written as length 3 plus ED A0 80..ED BF BF (the same length as today's EF BF BD); that byte pattern never occurs in valid UTF-8 (after ED the next byte must be 80-9F), so the reader can tell it from every file written today. Every other char goes through writeString as before. +32/-2 in TSTLookup.java.

private static void writeSplitChar(DataOutput out, char c) throws IOException {
  if (Character.isSurrogate(c)) {
    out.writeVInt(3);
    out.writeByte((byte) (0xE0 | (c >> 12)));
    out.writeByte((byte) (0x80 | ((c >> 6) & 0x3F)));
    out.writeByte((byte) (0x80 | (c & 0x3F)));
  } else {
    out.writeString(String.valueOf(c));
  }
}

private static char readSplitChar(DataInput in) throws IOException {
  int length = in.readVInt();
  byte[] bytes = new byte[length];
  in.readBytes(bytes, 0, length);
  if (length == 3 && (bytes[0] & 0xFF) == 0xED && (bytes[1] & 0xE0) == 0xA0) {
    return (char) (0xD000 | ((bytes[1] & 0x3F) << 6) | (bytes[2] & 0x3F));
  }
  return new String(bytes, 0, length, StandardCharsets.UTF_8).charAt(0);
}

Trade-offs

  • No break for today's readers: old files load as today (still lossy until rebuilt); new files without supplementary characters are byte-identical, and with them differ only in the surrogate nodes (11 of 154 bytes in the probe); main's stock reader and the 9.12.3 and 10.5.2 readers load a new file exactly as today (executed), because readString decodes with the JDK's UTF-8 decoder, which turns each ED A0 80..ED BF BF into a String that starts with U+FFFD (a single U+FFFD for all 2048 forms on OpenJDK 25.0.3, executed). Pre-9.0 readers cannot read any file written today (see above).
  • 32 lines instead of 2, and a hand-written decoder for one byte pattern.
  • The format stays unversioned; a maintainer may prefer to add a header at the same time (option C).
  • Cost on load: one small byte[] per node, as readString allocates today.
  • readSplitChar checks the first two bytes (ED, then A0-BF) but not that the third is 80-BF; every file store() writes satisfies that, but a reviewer may ask for the check. The new test covers two surrogate pairs; a production test may add boundary cases like the probe's.
  • A maintainer may prefer option C, or option A with a header in a major release, to a hand-written decoder in an unversioned format (the second cross-model fact check's view).

What the checks validated

  • Gradle, scratch worktree of a9779ef3c193, JDK 25: stock + the new test fails 2 of 2 with assertions on and off; with the fix, the new test and TestPersistence (4 tests) pass with assertions on and off; the whole lucene/suggest module, 32 suites, 238 tests (7 skipped), 0 failures with assertions on and off; :lucene:suggest:check -x test (ecjLint, forbiddenApis, checkGoogleJavaFormat) passes.
  • Standalone runner: the new test 2 of 2 pass with -ea and -da.
  • Compatibility matrix as described above (executed), including the 10.5.2, 9.12.3 and 8.8.1 readers.
  • Every surrogate value: 1,031 keys covering D800-DFFF plus BMP keys at U+D7FF, U+E000, U+FFFD, U+FFFE, U+FFFF; 3,093 checks after a round trip: stock changes 3,078, fix B (-ea and -da) 0, fix A 0. The fix B bytes hold 2,048 03 ED A0..BF 80..BF forms and one 03 EF BF BD (the real U+FFFD key).

Write the split char as a VInt (the finder's two-line fix) (validated by the checks)

out.writeVInt(node.splitchar) / (char) in.readVInt(). +2/-2.

-    node.splitchar = in.readString().charAt(0);
+    node.splitchar = (char) in.readVInt();
...
-    out.writeString(new String(new char[] {node.splitchar}, 0, 1));
+    out.writeVInt(node.splitchar); // a lone surrogate must survive (writeString maps it to U+FFFD)

Trade-offs

Simplest, lossless, and ASCII nodes get 1 byte smaller (the ASCII file is 337 instead of 392 bytes). But every node's byte layout changes and nothing marks the format: a fixed reader misparses files written by 4.7.0 to main (both old files in the probe loaded with load() = true and every lookup empty, the ASCII-only one included), and an older reader threw StringIndexOutOfBoundsException on both new files (executed). Acceptable only with an explicit "rebuild your lookups" note in a major release, or together with option C.

What the checks validated

Gradle: the new test and TestPersistence pass with assertions on and off; the whole module, 238 tests, passes with assertions on; checkGoogleJavaFormat passes. Standalone: 2 of 2 pass with -ea and -da; the finder's and the independent reproducer's tests pass with it.

Add a versioned header, then any lossless encoding

Write a header (for example with CodecUtil.writeHeader) and the split char with writeVInt or writeShort; read headerless files with today's decoding. Not built.

Trade-offs

Lets the format evolve later. Detecting "no header" is not free: today's files start with a VLong count, so the reader must tell the header's magic bytes apart from a count. The LUCENE-5404 discussion already noted the missing codec header.

Repair old files on load from their stored tokens (not built as a patch)

Each node's split char equals token.charAt(depth) for any token stored at or below the node on its eq side, where depth is the number of eq edges from the root (TSTAutocomplete.insert advances the char index only on eq, TSTAutocomplete.java:57-86). A loader can re-derive every split char after readRecursively, keeping the tree shape. Combined with B or C, files written by 4.7.0 to main would answer correctly again. Executed only as a white-box probe on the stock files: for the supplementary file it changed the 4 surrogate nodes back and every answer then equals a fresh build; the ASCII file was unchanged.

Trade-offs

One more pass over the tree on load. Only a white-box probe exists: no patch, test or review.

Downstream note

Downstream, Solr's suggester can reload a persisted TSTLookup from its storeDir (seen in 2020 source, not run), so such deployments would lose these suggestions after a restart.

How it was found

A randomized differential check ran 20 configurations of seven suggesters against a reference written from their javadoc and required a store()/load() round trip to change nothing. Over 100,000 small random dictionaries whose keys may include an emoji, the round trip changed 245,800 TSTLookup results, and none with the fix.

The method in full174 words

Method: randomized differential translation validation on the stock main jars (the study's "semi-formal" tier). A hunt unit, "suggest lookups, cross-implementation differential", 2026-10-27.

  • 20 configurations of seven Lookup classes (TSTLookup, FSTCompletionLookup, WFSTCompletionLookup, AnalyzingSuggester, FuzzySuggester, AnalyzingInfixSuggester, BlendedInfixSuggester; not every Lookup: FreeTextSuggester, for example, was not modelled) were run against an independent reference written from their javadoc, plus a store/load round trip that must reproduce every result list and getCount().
  • Scope R1: 100,000 random dictionaries of 1-6 entries over {a,b,c}^1..3 plus keys with U+00E9, U+1F600 and U+FFFD; 341,084,860 lookups over 20 suggester configurations. The class "TST RT:changed" (a TSTLookup result that differs after the round trip) appeared 245,800 times on the stock jar and 0 times with the fix shadowed.
  • The mechanism (writeString of a one-char String) had first been noticed while writing the model. The ASCII-only bounded-exhaustive scope (596,908 dictionaries) could not reach it.
  • An AI finder then wrote the public-API JUnit, and independent AI tools reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): three runs of a prior-art search over GitHub, JIRA, the mailing lists and downstream projects, manual searches and the changed files of all 409 open PRs. The discussion of the introducing change never mentions characters.

Searches4 searches

None of the issues, PRs, commits, JIRA entries, list threads or web pages found reports TSTLookup losing suggestions after store()/load(), lone surrogates in its split chars, or the writeString / readString().charAt(0) pair. Upstream main and branch_10x still have both lines.

Related, not this bug: gh#6467 / LUCENE-5404 (the introducing change; body and 19 comments never mention the char encoding); SOLR-2375 (the original, lossless store/load); gh#1588 / LUCENE-510 (writeString's U+FFFD substitution; background); a downstream remark (kitodo/kitodo-presentation#594) that TSTLookup gave "a flood of errors", without details (this bug raises no error); the Lucene.NET port's TSTLookup.cs, which has the same code (source, not a report).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive2026-10-27three runs of a scripted prior-report search with different specs (identifiers and mechanism; user symptoms such as emoji, restart, storeDir; the general writeString / lone-surrogate mechanism), 51 queries each119, 414 and 347 distinct hits; the only high-overlap hit, gh#4915, is unrelated
GitHub (apache/lucene and all repositories), Solr, Elasticsearch, OpenSearch, Lucene.NET2026-10-27manual searches (TSTLookup with surrogate, unicode, store, emoji; TernaryTreeNode, TSTAutocomplete, splitchar; lone/unpaired surrogate; LUCENE-5404)only 9 apache/lucene issues name TSTLookup, all read or triaged; nothing about this bug
JIRA (LUCENE, SOLR) and the Lucene and Solr lists2026-10-2722 JQL queries; dev@, java-user@, issues@, commits@, solr-user@ and the Solr listsno report; SOLR-2375 and a few suggester threads read
Open PRs and code history2026-10-27the changed files of all 409 open apache/lucene PRs; git log --follow of TSTLookup.java; upstream main and branch_10xno PR touches lucene/suggest/.../tst/; the two lines are unchanged since 2014

History

Introduced by LUCENE-5404 (2014-02-07), which replaced the lossless writeChar/readChar with writeString/readString; first released in 4.7.0. A probe round-trips on all 14 releases up to 4.6.1 and fails on all 119 from 4.7.0 to 10.5.2 and on main.

Versions tested6 rows

Introduced by LUCENE-5404 (issue "Add support to get number of entries a Suggester Lookup was built with and minor refactorings"; commit subject "Add .getCount method to all suggesters (Lookup), persist count metadata on .store(), ...") by Areek Zillur, 2014-02-07: b5926da2d678 on trunk (released in 6.0.0 and later) and fdf4595051a on branch_4x (released in 4.7.0 to 5.5.5). No GitHub PR; the patch was reviewed on JIRA (now gh#6467). git blame of TSTLookup.java:219 and :244 on main points to b5926da2d678; fdf4595051a is the only commit touching TSTLookup.java between the 4.6.1 and 4.7.0 tags. Store/load itself is older (SOLR-2375, 2011) and was lossless until then. Not fixed upstream.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 2 of 2 fail (gradle and the standalone runner, assertions on and off)
10.5.2, 9.12.3 affectedthe brief's test 2 of 2 fail; the finder's 2 of 2; the independent reproducer's 3 of 3
4.7.0-10.5.2 (119 releases) affectedthe version-independent probe loses the same 7 of 10 answers on each, with 0 control changes
3.6.0-3.6.2, 4.0.0-4.6.1 (14 releases) not affectedthe probe round-trips: 10 of 10 answers unchanged
4.6.1 vs 4.7.0 boundary4.6.1 round-trips with assertions on and off; 4.7.0 changes 7 of 10 answers with both
main + fix not affectedall repros pass; the whole lucene/suggest module, 238 tests, 0 failed

Independent checks

An independent reproducer, a devil's advocate, an adjudication and reviews by another model family (which ran their own tests) all found it real; the adjudication confirmed it with high confidence. Registry re-runs and a final audit reproduced it with new seeds.

Checks and verdicts295 words

Every check wrote or ran its own test against the stock jars, with assertions on and off.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit fails 2 of 2 on main (-ea, -da), 20 of 20 iterations, 10.5.2 and 9.12.3; passes with the fix; with the unit's fixes, 47 of 47 tests of four upstream suggest suites (TestPersistence among them) pass.
cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6) ran its own JUnit on main ea57360912ed (whose lucene/suggest is identical; -ea with two seeds, and -da), 10.5.2 and 9.12.3; narrowed the scope to lookup paths and flagged that the VInt fix changes the byte layout.
Independent reproducer realOwn 3-test JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3: 13 of 24 prefixes change, a halfwidth katakana key is lost; passes with its own two-line fix.
Cross-model review, intended lens real (high)OpenAI GPT-5.6: the intended-behaviour argument is weak; its expected-behaviour test fails on main and passes with the fix; FSTCompletionLookup keeps the same key.
Devil's advocate real (high)Built the LUCENE-510 / optional-operation case and rejected it; its probe fails on main and 10.5.2, the FST and WFST controls round-trip; history probe 4.6.1 OK, 4.7.0 and later changed.
adjudication confirmed (high)Silent wrong results, duplicate of nothing; re-ran the independent reproduction's test (-ea), the finder's (-da) and the independent reproduction's fix.
registry re-run and audit keepFresh seeds: the finder's and the independent reproduction's tests fail with -ea and -da and pass with the fix; the audit's own probe finds 8 differences in 10 checks after a reload.

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

Finding record

LFV-699, snapshot of 2026-11-06

Registry title
TSTLookup.store/load writes each tree node's split char with DataOutput.writeString, so the two surrogate halves of an emoji or CJK Extension B character come back as U+FFFD: after load(), get() and lookups whose prefix runs through such a character silently find nothing, and some neighbouring BMP keys are lost too (every release since 4.7.0)
Area
suggest / ternary search tree suggester persistence (TSTLookup.store and load, writeRecursively and readRecursively)
Priority
Low, rank 273: Needs the little-used TSTLookup plus its optional store/load plus supplementary characters in the dictionary.
Impact class
silent wrong results
Impact note
Hits applications that use the opt-in TSTLookup suggester, persist it with Lookup.store and reload it with Lookup.load (for example to skip a rebuild at restart), and whose suggestion text contains supplementary characters (emoji, CJK Extension B): after the reload, which returns true, get() and every lookup whose prefix runs through such a character return nothing, some BMP keys stored beside a surrogate node (fullwidth forms, halfwidth katakana, depending on the tree shape) disappear as well, and a U+FFFD prefix can return emoji keys it never matched before; nothing fails loudly, no scale is needed, ASCII-only dictionaries and getCount() are unaffected, and every release since 4.7.0 has it.
Summary
You build a TSTLookup from ordinary text that contains an emoji or a CJK Extension B character (for example the key 𠮷野家 with weight 5), store() it, load() it back, and expect the reloaded copy to answer like the original, as Lookup.load promises ('load it from a previously saved copy'). Instead lookup('𠮷', true, 5) returns [] instead of [𠮷野家/5] and get('𠮷野家') returns null after the reload, because TSTLookup keeps one tree node per UTF-16 code unit, writeRecursively writes each node's split char with DataOutput.writeString of a one-char String, and a lone surrogate has no UTF-8 form, so it is written and reloaded as U+FFFD and the tree's char comparisons then take the wrong branches. ASCII keys and getCount() survive, while some BMP keys next to a surrogate node are lost too (in the finder's test lookup('A') returns [] instead of [Ab/4]).
Introduced
b5926da2d67820f5fdf8c8ec9b2e642b74d12070 (trunk, svn r1565810; the line released in 6.0.0 and later) and its branch_4x twin fdf4595051ada90409a47f8259a6d6e17d4711be (svn r1565811; the line released in 4.7.0-5.5.5), both 'LUCENE-5404: Add .getCount method to all suggesters (Lookup), persist count metadata on .store(), ...' by Areek Zillur (now apache/lucene#6467; an svn-era JIRA patch, no GitHub PR) [read: git blame of TSTLookup.java:219 and :244 on main points to b5926da2d678; it is the only commit touching TSTLookup.java between the 4.6.1 and 4.7.0 tags; exec: 4.6.1 round-trips correctly, 4.7.0 does not]. It moved Lookup.store/load from java.io streams to Lucene DataOutput/DataInput and replaced the lossless DataOutputStream.writeChar(node.splitchar) / readChar() with writeString(new String(new char[] {node.splitchar}, 0, 1)) / readString().charAt(0); the issue discussion never mentions characters, so the loss is a side effect of that move, not a design choice. No later commit changed the two lines. (2014-02-07)
First affected version
4.7.0 [exec] (release tag dated 2014-02-25); the last unaffected release is 4.6.1 [exec]; TSTLookup store/load existed before (since Lucene 3.3, stream API since 3.6.0) and was lossless there [exec: 3.6.0-4.6.1]
Affected versions
every release from 4.7.0 through 10.5.2 and main a9779ef3c193; not fixed upstream. [exec] the follow-up's main() probe on 133 releases: 3.6.0-4.6.1 (14 releases) round-trip correctly, and all 119 cached releases from 4.7.0 to 10.5.2 lose the same 7 of 10 answers with 0 control changes (also -da on 4.6.1, 4.7.0, 9.12.3 and 10.5.2), main with -ea and -da, and main with the fix shadowed passes; the finder's JUnit fails on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit on main (-ea and -da), 10.5.2 and 9.12.3; the refute reviewer's JUnit on main, 10.5.2 and 9.12.3; the intended-behaviour check's history probe on 4.6.1 (OK), 4.7.0, 4.10.4, 5.5.5, 8.11.4, 10.5.2 and main (CHANGED). [read] both lines are present unchanged in all 110 release tags of the history clone that contain the introducing commit and absent (writeChar/readChar) in every earlier tag; upstream main head a95c754ee003 and branch_10x still have them. All runs on JDK 25.0.3 (aarch64); the defect is pure-Java string encoding, so the platform does not matter.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new): three follow-up scripted prior-report searches with different specs (identifiers and mechanism; user-visible symptoms such as emoji, restart and storeDir; the general writeString / lone-surrogate mechanism), 51 queries each over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (119, 414 and 347 distinct hits; the only 'high' overlap hit, #4915, is unrelated), plus the finder's run (51 queries, nothing relevant); manual GitHub searches on apache/lucene (only 9 issues name TSTLookup, all read or triaged) and across GitHub, apache/solr, Elasticsearch, OpenSearch and Lucene.NET; 22 JQL queries on LUCENE and SOLR; list searches on the Lucene and Solr dev and user lists; the changed-file lists of all 409 open apache/lucene PRs (none touches lucene/suggest/.../tst/); the file history of TSTLookup.java; 8 web queries. The independent reproduction and the intended-behaviour check searched with more phrasings and found nothing either. Not fixed upstream: main head a95c754ee003 and branch_10x still have both lines. Related, none a report of this bug: #6467 / LUCENE-5404 (the change that introduced it; body and 19 comments never mention the char encoding), SOLR-2375 (added the original, lossless store/load), #1588 / LUCENE-510 (made writeString standard UTF-8 with U+FFFD for unpaired surrogates; background), kitodo/kitodo-presentation#594 (a downstream remark that TSTLookup gave 'a flood of errors', with no details; this bug raises no error) and the Lucene.NET port, whose TSTLookup.cs carries the same code (source, not a report).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (the brief's test); a version-independent probe on 133 release jars from 3.6.0 to 10.5.2. Fix B was validated in the scratch worktree (the new test and TestPersistence with assertions on and off, the whole lucene/suggest module's 238 tests with assertions on and off, :lucene:suggest:check -x test); fix A with the same suites (the module with assertions on) and the format check; a store-format compatibility matrix (stock, fix A, fix B writing and reading each other's files, also read by the 10.5.2, 9.12.3 and 8.8.1 release jars) was executed, as were a probe that stores every surrogate value as a split char (stock, fix A, fix B) and a white-box probe that repairs a stock file from its stored tokens.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:219 (readRecursively: node.splitchar = in.readString().charAt(0)), :244 (writeRecursively: out.writeString(new String(new char[] {node.splitchar}, 0, 1))), :222 and :253 (tokens read and written whole with readString/writeString), :268-279 (store/load write and read a VLong count plus the tree and return true), :71-115 (utf8SortedAsUTF16SortOrder comparator that handles supplementary code points explicitly), :36-42 (class javadoc: no @lucene.experimental, not deprecated)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java:57-86 (insert: one node per UTF-16 code unit, newNode.splitchar = s.charAt(x) at :63; comparisons at :72 and :74), :103-105 (prefixCompletion compares s.charAt(x) with p.splitchar)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38 (@lucene.experimental), :237-243 (store: 'Persist the constructed lookup data ... Optional operation', true if successful), :246-252 (load: 'Discard current lookup data and load it from a previously saved copy. Optional operation.')
  • lucene/core/src/java/org/apache/lucene/store/DataOutput.java:266-275 (writeString writes UTF-8 through new BytesRef(s)); lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:172-176 ('replace unpaired surrogate or out-of-order low surrogate with substitution character', EF BF BD)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/TestPersistence.java:50-51 (testTSTPersistence: runTest(TSTLookup.class, true), exact round trip, ASCII keys only)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTAutocomplete.java:57-86 (at a9779ef3c193) (insert, :63 one node per char, :72/:74 comparisons), :97-148 (prefixCompletion, :103-114 descent)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:219 (at a9779ef3c193) (readRecursively), :244 (writeRecursively), :222/:253 (tokens), :267-280 (store/load return true), :135-141 (build decodes to String), :149-150 (add calls the same insert)
  • lucene/core/src/java/org/apache/lucene/store/DataOutput.java:263-275; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/util/BytesRef.java:77 ("no unpaired surrogates"), :79-82; lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java:199-240 (:231-235 substitution); lucene/core/src/java/org/apache/lucene/store/DataInput.java:226-231
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:236-252 (at a9779ef3c193)
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:36-42; (at a9779ef3c193) lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:38 (at a9779ef3c193)
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:236-252; (at a9779ef3c193) lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:71-115, (at a9779ef3c193) :267-280; lucene/core/src/java/org/apache/lucene/util/BytesRef.java:77
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/TestPersistence.java:31-48, (at a9779ef3c193) :50-52, :82-105
  • a Lucene clone releases/lucene-solr/4.6.1:lucene/suggest/src/java/org/apache/lucene/search/suggest/tst/TSTLookup.java:154, :179 (readChar/writeChar)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/store/DataInput.java:226-231 (at a9779ef3c193) (readString decodes with the JDK UTF-8 decoder); a Lucene clone releases/lucene-solr/4.7.0 DataInput.java:187-192, 6.6.6 :234-239, 8.8.1 :255-260, releases/lucene/9.12.3 :303-308, 10.5.2 :230-235 (the same decoding; whole-file compatibility only from 9.0 on, see LUCENE-9047)
History
  • a Lucene clone: releases/lucene-solr/4.6.1 and 4.7.0 TSTLookup.java (readChar/writeChar vs readString/writeString); git log 4.6.1..4.7.0 -- TSTLookup.java (fdf4595051a only)

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

Repro files

About the diagrams

A picture of the repro, made in parallel with this doc from executed runs on main a9779ef3c193 and the code, and updated after the checks to show the recommended fix (option B); the alt text describes the intended content.

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.