Merge "Add GitHub release workflow for version tag pushes"
diff --git a/Changes b/Changes
index 3cf53bb..a5366f3 100644
--- a/Changes
+++ b/Changes
@@ -1,9 +1,16 @@
+0.65.1 2026-06-16
+    - [feature] Prevent indexation of documents without
+      a token stream (diewald; AI-assisted Claude Opus 4.6)
+    - [bugfix] Fix "force" in commit method
+      (diewald; AI-assisted Claude Opus 4.6)
+    - [tool] Add standalone MetaExporter tool
+      (kupietz; AI-assistend Claude Opus 4.8)
+    - [bugfix] Fix cross-doc leakage in nested WithinSpans
+      (diewald; AI-assisted Claude Opus 4.6)
+      
 0.65 2026-05-12
-includes all changes in 0.64.7
-
-0.64.7 2026-05-07
     - [bugfix] Keep highlights that extend beyond a cut match
-      (diewald; fixes #177; diewald; AI-assisted Claude Opus 4.6)
+      (diewald; fixes #177; AI-assisted Claude Opus 4.6)
     - [bugfix] Correctly handle foundry and layer in attribute groups
       (diewald; AI-assisted Claude Opus 4.6)
     - [bugfix] Support regular expressions in attribute queries
diff --git a/pom.xml b/pom.xml
index ae4d153..3d8657c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -29,13 +29,28 @@
          for example:
          
          $ java -jar target/Krill-Indexer.jar -c src/test/resources/krill.properties
-		 -i src/test/resources/bzk -o index/ 
-		 		
+		 -i src/test/resources/bzk -o index/
+
+         ** MetaExporter
+		 # after packaging (see above)
+		 # dumps the stored per-document metadata of an existing index,
+		 # without using the web service API.
+
+		 $ java -jar target/Krill-MetaExporter.jar -i [index directory]
+		 -f [comma-separated fields] [format tsv|csv|json] [-o output file]
+
+         for example (list fields, then dump text sigles for diffing instances):
+
+         $ java -jar target/Krill-MetaExporter.jar -i index/ (option) list-fields
+         $ java -jar target/Krill-MetaExporter.jar -i index/ -f textSigle (option) no-header
+
+         (full option names use a double-dash prefix; see the MetaExporter Javadoc)
+
 		-->
 
   <groupId>de.ids-mannheim.korap.krill</groupId>
   <artifactId>Krill</artifactId>
-  <version>0.65</version>
+  <version>0.65.1</version>
   <packaging>jar</packaging>
 
   <name>Krill</name>
@@ -62,7 +77,7 @@
     <java.version>21</java.version>
     <jersey.version>4.0.2</jersey.version>
     <log4j.version>2.26.0</log4j.version>
-    <jackson.version>2.21.4</jackson.version>
+    <jackson.version>2.22.0</jackson.version>
     <jackson.mainversion>2.22</jackson.mainversion>
     <lucene.version>5.0.0</lucene.version>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -122,14 +137,14 @@
     <dependency>
       <groupId>org.xerial</groupId>
       <artifactId>sqlite-jdbc</artifactId>
-      <version>3.53.1.0</version>
+      <version>3.53.2.0</version>
     </dependency>
     
     <!-- Database Connection Pool Manager -->
     <dependency>
       <groupId>com.mchange</groupId>
       <artifactId>c3p0</artifactId>
-      <version>0.13.0</version>
+      <version>0.14.1</version>
     </dependency>
     
     <!-- Lucene core dependency -->
@@ -278,6 +293,23 @@
 	    </configuration>
 	  </execution>
 	  <execution>
+	    <id>metaexporter</id>
+	    <phase>package</phase>
+	    <goals>
+	      <goal>shade</goal>
+	    </goals>
+	    <configuration>
+            <createDependencyReducedPom>false</createDependencyReducedPom>
+	      <transformers>
+		<transformer
+		    implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
+		  <mainClass>de.ids_mannheim.korap.index.MetaExporter</mainClass>
+		</transformer>
+	      </transformers>
+	      <finalName>${project.artifactId}-MetaExporter</finalName>
+	    </configuration>
+	  </execution>
+	  <execution>
 	    <id>server</id>
 	    <phase>package</phase>
 	    <goals>
diff --git a/src/main/java/de/ids_mannheim/korap/KrillIndex.java b/src/main/java/de/ids_mannheim/korap/KrillIndex.java
index 0bcd1cd..892bc05 100644
--- a/src/main/java/de/ids_mannheim/korap/KrillIndex.java
+++ b/src/main/java/de/ids_mannheim/korap/KrillIndex.java
@@ -444,7 +444,7 @@
      */
     public void commit (boolean force) throws IOException {
         // There is something to commit
-        if (commitCounter > 0 || !force)
+        if (commitCounter > 0 || force)
             this.commit();
     };
 
@@ -496,6 +496,15 @@
         if (doc == null)
             return doc;
 
+        if (!hasTokenField(doc)) {
+            log.error(
+                "Rejecting upsert for document '{}': no token stream - "
+                + "existing document (if any) will not be removed",
+                doc.getTextSigle()
+            );
+            return doc;
+        }
+
         // Create a filter based on the corpusID and the docID
         String textSigle = doc.getTextSigle();
         KrillDate current = new KrillDate(LocalDate.now());
@@ -608,6 +617,26 @@
     
 
     /**
+     * Check if a FieldDocument has a known token stream field.
+     * Checks for "tokens" first (current KorAP-XML-Krill format),
+     * then falls back to "base" (legacy format).
+     * A document without either field will not contribute
+     * to token/sentence/paragraph statistics and cannot be searched.
+     *
+     * @param doc The FieldDocument to check
+     * @return true if the document has a known token field with term vectors
+     */
+    public boolean hasTokenField (FieldDocument doc) {
+        if (doc == null)
+            return false;
+        IndexableField field = doc.doc.getField("tokens");
+        if (field == null)
+            field = doc.doc.getField("base");
+        return field != null && field.fieldType().storeTermVectors();
+    }
+
+
+    /**
      * Add a document to the index as a {@link FieldDocument}.
      * 
      * @param doc
@@ -619,6 +648,16 @@
         if (doc == null)
             return doc;
 
+        if (!hasTokenField(doc)) {
+            log.error(
+                "Rejecting document '{}': no token stream - "
+                + "the document would not contribute to statistics "
+                + "and cannot be searched",
+                doc.getTextSigle()
+            );
+            return doc;
+        }
+
         try {
 
             // Add document to writer
diff --git a/src/main/java/de/ids_mannheim/korap/index/MetaExporter.java b/src/main/java/de/ids_mannheim/korap/index/MetaExporter.java
new file mode 100644
index 0000000..adcb5a3
--- /dev/null
+++ b/src/main/java/de/ids_mannheim/korap/index/MetaExporter.java
@@ -0,0 +1,357 @@
+package de.ids_mannheim.korap.index;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintStream;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.CommandLineParser;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.LeafReader;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.store.MMapDirectory;
+import org.apache.lucene.util.Bits;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+/**
+ * Standalone tool to export the per-document metadata stored in an existing
+ * Krill (Lucene) index, without going through the web service API.
+ *
+ * <p>All metadata in a Krill index is kept as Lucene <i>stored fields</i>; only
+ * the primary-data fields {@code tokens} and {@code base} carry the heavy
+ * annotation streams and are skipped here. This tool iterates over every live
+ * (non-deleted) document and prints the requested fields as TSV, CSV or
+ * line-delimited JSON.</p>
+ *
+ * <p>Typical use: dump the {@code textSigle} column of two index instances and
+ * diff the sorted lists to find which texts are missing from / surplus in one
+ * of them.</p>
+ *
+ * <pre>
+ * Usage:
+ *
+ * # List which metadata fields exist in the index
+ * java -cp Krill.jar de.ids_mannheim.korap.index.MetaExporter \
+ *      -i /path/to/index --list-fields
+ *
+ * # Dump just the text sigles (one per line)
+ * java -cp Krill.jar de.ids_mannheim.korap.index.MetaExporter \
+ *      -i /path/to/index -f textSigle
+ *
+ * # Dump several columns as TSV with a header row
+ * java -cp Krill.jar de.ids_mannheim.korap.index.MetaExporter \
+ *      -i /path/to/index -f textSigle,author,title,pubDate
+ *
+ * # Dump all stored metadata fields of every document as JSON lines
+ * java -cp Krill.jar de.ids_mannheim.korap.index.MetaExporter \
+ *      -i /path/to/index --format json
+ * </pre>
+ *
+ * @author Krill
+ */
+public class MetaExporter {
+
+    // Primary-data fields that are never metadata and may be huge.
+    private static final Set<String> SKIP_FIELDS =
+            new LinkedHashSet<>(Arrays.asList("tokens", "base"));
+
+    private final DirectoryReader reader;
+
+    public MetaExporter (String indexPath) throws IOException {
+        this.reader = DirectoryReader.open(
+                new MMapDirectory(Paths.get(indexPath)));
+    }
+
+    public void close () throws IOException {
+        this.reader.close();
+    }
+
+    /**
+     * Collect the sorted union of all stored metadata field names across the
+     * whole index.
+     */
+    public Set<String> collectFieldNames () throws IOException {
+        Set<String> names = new TreeSet<>();
+        for (LeafReaderContext lrc : reader.leaves()) {
+            LeafReader lr = lrc.reader();
+            Bits liveDocs = lr.getLiveDocs();
+            int max = lr.maxDoc();
+            for (int i = 0; i < max; i++) {
+                if (liveDocs != null && !liveDocs.get(i))
+                    continue;
+                Document doc = lr.document(i);
+                for (IndexableField f : doc.getFields()) {
+                    String name = f.name();
+                    if (!SKIP_FIELDS.contains(name))
+                        names.add(name);
+                }
+            }
+        }
+        return names;
+    }
+
+    private enum Format {
+        TSV, CSV, JSON
+    }
+
+    /**
+     * Export the selected fields of every live document.
+     *
+     * @param fields
+     *            ordered list of field names to output; if {@code null} the
+     *            sorted union of all stored fields is used.
+     * @param format
+     *            output format.
+     * @param header
+     *            whether to emit a header row (TSV/CSV only).
+     * @param multiSep
+     *            separator used to join multi-valued fields (TSV/CSV only).
+     */
+    public long export (List<String> fields, Format format, boolean header,
+            String multiSep, Writer out) throws IOException {
+
+        // Resolve field list lazily for tabular output if not given
+        if (fields == null && format != Format.JSON) {
+            fields = new ArrayList<>(collectFieldNames());
+        }
+
+        char sep = (format == Format.CSV) ? ',' : '\t';
+        ObjectMapper mapper = (format == Format.JSON) ? new ObjectMapper() : null;
+
+        if (format != Format.JSON && header) {
+            writeRow(out, fields, format, sep, null);
+        }
+
+        long count = 0;
+        for (LeafReaderContext lrc : reader.leaves()) {
+            LeafReader lr = lrc.reader();
+            Bits liveDocs = lr.getLiveDocs();
+            int max = lr.maxDoc();
+            for (int i = 0; i < max; i++) {
+                if (liveDocs != null && !liveDocs.get(i))
+                    continue;
+                Document doc = lr.document(i);
+
+                if (format == Format.JSON) {
+                    writeJson(out, mapper, doc, fields);
+                }
+                else {
+                    List<String> values = new ArrayList<>(fields.size());
+                    for (String name : fields) {
+                        String[] vs = doc.getValues(name);
+                        values.add(vs.length == 0 ? ""
+                                : String.join(multiSep, vs));
+                    }
+                    writeRow(out, values, format, sep, null);
+                }
+                count++;
+            }
+        }
+        out.flush();
+        return count;
+    }
+
+    private void writeJson (Writer out, ObjectMapper mapper, Document doc,
+            List<String> fields) throws IOException {
+        ObjectNode node = mapper.createObjectNode();
+        if (fields != null) {
+            for (String name : fields) {
+                String[] vs = doc.getValues(name);
+                if (vs.length == 1)
+                    node.put(name, vs[0]);
+                else if (vs.length > 1) {
+                    for (String v : vs)
+                        node.withArray(name).add(v);
+                }
+            }
+        }
+        else {
+            // All stored fields except the primary-data ones
+            for (IndexableField f : doc.getFields()) {
+                String name = f.name();
+                if (SKIP_FIELDS.contains(name))
+                    continue;
+                String v = f.stringValue();
+                if (v == null)
+                    continue;
+                if (node.has(name)) {
+                    node.withArray(name).add(v);
+                }
+                else {
+                    node.put(name, v);
+                }
+            }
+        }
+        out.write(mapper.writeValueAsString(node));
+        out.write('\n');
+    }
+
+    private void writeRow (Writer out, List<String> values, Format format,
+            char sep, String unused) throws IOException {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < values.size(); i++) {
+            if (i > 0)
+                sb.append(sep);
+            sb.append(escape(values.get(i), format, sep));
+        }
+        sb.append('\n');
+        out.write(sb.toString());
+    }
+
+    private String escape (String value, Format format, char sep) {
+        if (value == null)
+            value = "";
+        if (format == Format.CSV) {
+            boolean needQuote = value.indexOf(sep) >= 0
+                    || value.indexOf('"') >= 0
+                    || value.indexOf('\n') >= 0
+                    || value.indexOf('\r') >= 0;
+            if (needQuote) {
+                return '"' + value.replace("\"", "\"\"") + '"';
+            }
+            return value;
+        }
+        // TSV: strip embedded tabs/newlines so the row stays intact
+        return value.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ');
+    }
+
+    public static void main (String[] argv) {
+        Options options = new Options();
+        options.addOption(Option.builder("i").longOpt("index")
+                .desc("index directory to read (required)").hasArg()
+                .argName("index dir").required().build());
+        options.addOption(Option.builder("f").longOpt("fields")
+                .desc("comma-separated list of metadata fields/columns to "
+                        + "export. Defaults to all stored fields.")
+                .hasArg().argName("field,field,...").build());
+        options.addOption(Option.builder("o").longOpt("output")
+                .desc("output file (defaults to stdout).").hasArg()
+                .argName("file").build());
+        options.addOption(Option.builder().longOpt("format")
+                .desc("output format: tsv (default), csv or json (one JSON "
+                        + "object per line).")
+                .hasArg().argName("tsv|csv|json").build());
+        options.addOption(Option.builder().longOpt("no-header")
+                .desc("do not print a header row (tsv/csv).").build());
+        options.addOption(Option.builder().longOpt("multi-sep")
+                .desc("separator for multi-valued fields in tsv/csv "
+                        + "(default '|').")
+                .hasArg().argName("sep").build());
+        options.addOption(Option.builder().longOpt("list-fields")
+                .desc("only list the metadata field names present in the "
+                        + "index and exit.")
+                .build());
+
+        CommandLineParser parser = new DefaultParser();
+        CommandLine cmd;
+        try {
+            cmd = parser.parse(options, argv);
+        }
+        catch (ParseException e) {
+            HelpFormatter formatter = new HelpFormatter();
+            formatter.printHelp(
+                    "Krill metadata exporter\n java -cp Krill.jar "
+                            + "de.ids_mannheim.korap.index.MetaExporter "
+                            + "-i <index dir> [-f <fields>] "
+                            + "[--format tsv|csv|json] [-o <file>] "
+                            + "[--list-fields]",
+                    options);
+            System.err.println("\n" + e.getMessage());
+            return;
+        }
+
+        String indexPath = cmd.getOptionValue("i");
+        MetaExporter exporter = null;
+        try {
+            exporter = new MetaExporter(indexPath);
+
+            if (cmd.hasOption("list-fields")) {
+                PrintStream ps = System.out;
+                for (String name : exporter.collectFieldNames()) {
+                    ps.println(name);
+                }
+                return;
+            }
+
+            Format format = Format.TSV;
+            String fmt = cmd.getOptionValue("format");
+            if (fmt != null) {
+                switch (fmt.toLowerCase()) {
+                    case "tsv": format = Format.TSV; break;
+                    case "csv": format = Format.CSV; break;
+                    case "json": format = Format.JSON; break;
+                    default:
+                        System.err.println("Unknown format: " + fmt);
+                        return;
+                }
+            }
+
+            List<String> fields = null;
+            if (cmd.hasOption("f")) {
+                fields = new ArrayList<>();
+                for (String part : cmd.getOptionValue("f").split(",")) {
+                    String t = part.trim();
+                    if (!t.isEmpty())
+                        fields.add(t);
+                }
+            }
+
+            boolean header = !cmd.hasOption("no-header");
+            String multiSep = cmd.getOptionValue("multi-sep", "|");
+
+            OutputStream os;
+            boolean closeOut = false;
+            if (cmd.hasOption("o")) {
+                os = Files.newOutputStream(Paths.get(cmd.getOptionValue("o")));
+                closeOut = true;
+            }
+            else {
+                os = System.out;
+            }
+
+            Writer out = new BufferedWriter(
+                    new OutputStreamWriter(os, StandardCharsets.UTF_8));
+            long n = exporter.export(fields, format, header, multiSep, out);
+            out.flush();
+            if (closeOut)
+                out.close();
+
+            System.err.println("Exported " + n + " documents.");
+        }
+        catch (IOException e) {
+            System.err.println("Error: " + e.getMessage());
+            e.printStackTrace();
+        }
+        finally {
+            if (exporter != null) {
+                try {
+                    exporter.close();
+                }
+                catch (IOException e) {
+                    // ignore
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/java/de/ids_mannheim/korap/query/spans/WithinSpans.java b/src/main/java/de/ids_mannheim/korap/query/spans/WithinSpans.java
index fe507d9..bfdfefa 100644
--- a/src/main/java/de/ids_mannheim/korap/query/spans/WithinSpans.java
+++ b/src/main/java/de/ids_mannheim/korap/query/spans/WithinSpans.java
@@ -204,17 +204,31 @@
                     log.trace("In the next embedded branch");
 
                 WithinSpan current = null;
+                WithinSpan candidate;
 
-                // New - fetch until theres a span in the correct doc or bigger
+                // Skip stale entries, stop at future documents
                 while (!this.spanStore2.isEmpty()) {
-                    current = spanStore2.removeFirst();
-                    if (current.doc >= this.wrapDoc)
+                    candidate = spanStore2.peekFirst();
+                    if (candidate.doc < this.wrapDoc) {
+                        spanStore2.removeFirst();
+                    }
+                    else if (candidate.doc == this.wrapDoc) {
+                        current = spanStore2.removeFirst();
                         break;
-                };
+                    }
+                    else {
+                        break;
+                    }
+                }
 
-
-                // There is nothing in the second store
                 if (current == null) {
+
+                    // Future-doc entries remain: advance wrap instead
+                    if (!this.spanStore2.isEmpty()) {
+                        this.nextSpanA();
+                        continue;
+                    }
+
                     if (DEBUG)
                         log.trace("SpanStore 2 is empty");
 
@@ -626,32 +640,49 @@
             log.trace("skipTo document {}/{} -> {}", this.embeddedDoc,
                     this.wrapDoc, target);
 
-        // Initialize spans
         if (!this.init())
             return false;
 
-        assert target > this.embeddedDoc;
+        // Already at or past target: just find next match
+        if (this.matchDoc >= target)
+            return this.next() && this.matchDoc >= target;
 
-        // Only forward embedded spans
-        if (this.more && (this.embeddedDoc < target)) {
-            if (this.embeddedSpans.skipTo(target)) {
-                this.inSameDoc = false;
-                this.embeddedStart = -1;
-                this.embeddedEnd = -1;
-                this.embeddedPayload = null;
-                this.embeddedDoc = this.embeddedSpans.doc();
-            }
+        // Fast-forward operands when both are behind target
+        if (this.embeddedDoc >= 0 && this.wrapDoc < target
+                && this.embeddedDoc < target) {
 
-            // Can't be skipped to target
-            else {
-                this.inSameDoc = false;
-                this.more = false;
+            if (!this.wrapSpans.skipTo(target))
                 return false;
-            };
-        };
+            this.wrapDoc = this.wrapSpans.doc();
+            if (this.wrapDoc == DocIdSetIterator.NO_MORE_DOCS)
+                return false;
 
-        // Move to same doc
-        return this.toSameDoc();
+            if (!this.embeddedSpans.skipTo(this.wrapDoc))
+                return false;
+            this.embeddedDoc = this.embeddedSpans.doc();
+            if (this.embeddedDoc == DocIdSetIterator.NO_MORE_DOCS)
+                return false;
+
+            // Reset internal state after skip
+            this.spanStore1.clear();
+            this.spanStore2.clear();
+            this.wrapStart = this.wrapSpans.start();
+            this.embeddedStart = this.embeddedSpans.start();
+            this.wrapEnd = -1;
+            this.embeddedEnd = -1;
+            this.embeddedPayload = null;
+            this.inSameDoc = (this.wrapDoc == this.embeddedDoc);
+            this.more = true;
+            this.tryMatch = true;
+            this.nextSpanB = true;
+        }
+
+        while (true) {
+            if (!this.next())
+                return false;
+            if (this.matchDoc >= target)
+                return true;
+        }
     };
 
 
diff --git a/src/test/java/de/ids_mannheim/korap/TestMetaExporter.java b/src/test/java/de/ids_mannheim/korap/TestMetaExporter.java
new file mode 100644
index 0000000..cff3dbe
--- /dev/null
+++ b/src/test/java/de/ids_mannheim/korap/TestMetaExporter.java
@@ -0,0 +1,144 @@
+package de.ids_mannheim.korap;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Files;
+
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import de.ids_mannheim.korap.index.Indexer;
+import de.ids_mannheim.korap.index.MetaExporter;
+
+/**
+ * Tests for the standalone {@link MetaExporter} tool, which dumps the stored
+ * per-document metadata of an existing index.
+ *
+ * <p>An index containing a single known document (textSigle
+ * {@code BZK_D59.00089}) is built once from {@code src/test/resources/bzk}
+ * and reused by all tests.</p>
+ *
+ * @author Krill
+ */
+public class TestMetaExporter {
+
+    private static File tempBaseDirectory;
+    private static String indexDir;
+
+    private final ByteArrayOutputStream outContent = new ByteArrayOutputStream();
+    private final PrintStream originalOut = System.out;
+
+    @BeforeClass
+    public static void buildIndex () throws IOException {
+        tempBaseDirectory = Files.createTempDirectory("krill-export-test")
+                .toFile();
+        indexDir = new File(tempBaseDirectory, "index").getAbsolutePath();
+
+        // The Indexer prints to stdout; silence it during setup.
+        PrintStream original = System.out;
+        System.setOut(new PrintStream(new ByteArrayOutputStream()));
+        try {
+            Indexer.main(new String[] { "-c",
+                    "src/test/resources/krill.properties", "-i",
+                    "src/test/resources/bzk", "-o", indexDir });
+        }
+        finally {
+            System.setOut(original);
+        }
+    }
+
+    @Before
+    public void redirectOut () {
+        System.setOut(new PrintStream(outContent));
+    }
+
+    private void restoreOut () {
+        System.setOut(originalOut);
+    }
+
+    @Test
+    public void testListFields () {
+        MetaExporter.main(new String[] { "-i", indexDir, "--list-fields" });
+        restoreOut();
+        String out = outContent.toString();
+        // The text sigle is the key field used for diffing instances.
+        assertTrue("expected textSigle among listed fields",
+                out.contains("textSigle"));
+        assertTrue("expected corpusSigle among listed fields",
+                out.contains("corpusSigle"));
+        // The heavy primary-data fields must never be reported as metadata.
+        assertTrue("tokens must not be listed as a metadata field",
+                !out.contains("tokens\n"));
+    }
+
+    @Test
+    public void testExportSingleColumnNoHeader () {
+        MetaExporter.main(new String[] { "-i", indexDir, "-f", "textSigle",
+                "--no-header" });
+        restoreOut();
+        assertEquals("BZK_D59.00089", outContent.toString().trim());
+    }
+
+    @Test
+    public void testExportTsvHeader () {
+        MetaExporter.main(
+                new String[] { "-i", indexDir, "-f", "textSigle,corpusSigle" });
+        restoreOut();
+        String[] lines = outContent.toString().split("\n");
+        assertEquals("textSigle\tcorpusSigle", lines[0]);
+        assertEquals("BZK_D59.00089\tBZK", lines[1]);
+    }
+
+    @Test
+    public void testExportCsv () {
+        MetaExporter.main(new String[] { "-i", indexDir, "-f", "textSigle",
+                "--format", "csv", "--no-header" });
+        restoreOut();
+        assertEquals("BZK_D59.00089", outContent.toString().trim());
+    }
+
+    @Test
+    public void testExportJson () {
+        MetaExporter.main(
+                new String[] { "-i", indexDir, "--format", "json" });
+        restoreOut();
+        String line = outContent.toString().trim();
+        assertTrue("JSON line should be an object", line.startsWith("{"));
+        assertTrue("JSON should contain the textSigle",
+                line.contains("\"textSigle\":\"BZK_D59.00089\""));
+        // tokens/base must be excluded from the JSON dump as well.
+        assertTrue("JSON must not contain the tokens primary-data field",
+                !line.contains("\"tokens\":"));
+    }
+
+    @Test
+    public void testMissingIndexArgumentPrintsHelp () {
+        // Missing required -i must be handled gracefully (help printed, no throw).
+        MetaExporter.main(new String[] { "--list-fields" });
+        restoreOut();
+        assertTrue("expected usage/help output",
+                outContent.toString().contains("Krill metadata exporter")
+                        || outContent.toString().isEmpty());
+    }
+
+    @AfterClass
+    public static void cleanup () {
+        if (tempBaseDirectory != null && tempBaseDirectory.exists())
+            deleteFile(tempBaseDirectory);
+    }
+
+    private static void deleteFile (File path) {
+        if (path.isDirectory() && path.list() != null) {
+            for (String filename : path.list())
+                deleteFile(new File(path, filename));
+        }
+        path.delete();
+    }
+}
diff --git a/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionIndex.java b/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionIndex.java
index 8b5fcef..eaaba0a 100644
--- a/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionIndex.java
+++ b/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionIndex.java
@@ -1296,6 +1296,8 @@
         for (int i = 0; i < 6000; i++) {
             FieldDocument fd = new FieldDocument();
             fd.addString("UID", Integer.toString(i));
+            fd.addTV("tokens", "x",
+                "[(0-1)s:x|_0$<i>0<i>1|-:tokens$<i>1]");
             ki.addDoc(fd);
             if (i == 4500)
                 ki.commit();
diff --git a/src/test/java/de/ids_mannheim/korap/index/TestFieldDocument.java b/src/test/java/de/ids_mannheim/korap/index/TestFieldDocument.java
index a7b3777..87ad55d 100644
--- a/src/test/java/de/ids_mannheim/korap/index/TestFieldDocument.java
+++ b/src/test/java/de/ids_mannheim/korap/index/TestFieldDocument.java
@@ -21,9 +21,11 @@
 
 import static de.ids_mannheim.korap.TestSimple.*;
 import de.ids_mannheim.korap.Krill;
+import de.ids_mannheim.korap.KrillCollection;
 import de.ids_mannheim.korap.KrillIndex;
 import de.ids_mannheim.korap.KrillMeta;
 import de.ids_mannheim.korap.KrillQuery;
+import de.ids_mannheim.korap.collection.CollectionBuilder;
 import de.ids_mannheim.korap.query.QueryBuilder;
 import de.ids_mannheim.korap.query.wrap.SpanQueryWrapper;
 import de.ids_mannheim.korap.response.Match;
@@ -637,6 +639,245 @@
     };
 
 
+    /**
+     * A document with only metadata (no token stream / no "data" section)
+     * should be rejected by addDoc with an error.
+     */
+    @Test
+    public void testAddDocRejectsMetadataOnlyDocument () throws Exception {
+        KrillIndex ki = new KrillIndex();
+
+        FieldDocument fd = new FieldDocument();
+        fd.addString("textSigle", "TEST/META/001");
+        fd.addString("author", "Test Author");
+        fd.addDate("pubDate", 20200101);
+
+        FieldDocument result = ki.addDoc(fd);
+        ki.commit();
+
+        assertEquals(0, ki.numberOf("documents"));
+        assertTrue(
+            "addDoc should return the doc even when rejected",
+            result != null
+        );
+    }
+
+    /**
+     * A document with only metadata should also be rejected via upsertDoc.
+     */
+    @Test
+    public void testUpsertDocRejectsMetadataOnlyDocument () throws Exception {
+        KrillIndex ki = new KrillIndex();
+
+        FieldDocument fd = new FieldDocument();
+        fd.addString("textSigle", "TEST/META/002");
+        fd.addString("author", "Test Author");
+
+        FieldDocument result = ki.upsertDoc(fd);
+        ki.commit();
+
+        assertEquals(0, ki.numberOf("documents"));
+    }
+
+    /**
+     * A JSON document with fields but no data section should be rejected.
+     */
+    @Test
+    public void testAddDocRejectsJsonWithoutTokenStream () throws Exception {
+        String json = "{"
+            + "  \"fields\" : ["
+            + "    {"
+            + "      \"@type\" : \"koral:field\","
+            + "      \"type\" : \"type:string\","
+            + "      \"key\" : \"textSigle\","
+            + "      \"value\" : \"TEST/NODATA/001\""
+            + "    },"
+            + "    {"
+            + "      \"@type\" : \"koral:field\","
+            + "      \"type\" : \"type:text\","
+            + "      \"key\" : \"author\","
+            + "      \"value\" : \"Nobody\""
+            + "    },"
+            + "    {"
+            + "      \"@type\" : \"koral:field\","
+            + "      \"type\" : \"type:date\","
+            + "      \"key\" : \"pubDate\","
+            + "      \"value\" : \"2020-01-01\""
+            + "    }"
+            + "  ]"
+            + "}";
+
+        KrillIndex ki = new KrillIndex();
+        FieldDocument fd = ki.addDoc(json);
+        ki.commit();
+
+        assertEquals(
+            "Metadata-only document should not be indexed",
+            0, ki.numberOf("documents")
+        );
+    }
+
+    /**
+     * A document with a token stream on an unknown field name
+     * (neither "tokens" nor "base") should be rejected.
+     */
+    @Test
+    public void testAddDocRejectsUnknownTokenFieldName () throws Exception {
+        KrillIndex ki = new KrillIndex();
+
+        FieldDocument fd = new FieldDocument();
+        fd.addString("textSigle", "TEST/WRONG/001");
+        fd.addTV("other", "hello world",
+            "[(0-5)s:hello|i:hello|_0$<i>0<i>5|-:tokens$<i>2]"
+            + "[(6-11)s:world|i:world|_1$<i>6<i>11]");
+        ki.addDoc(fd);
+        ki.commit();
+
+        assertEquals(
+            "Document with token stream on unknown field should be rejected",
+            0, ki.numberOf("documents")
+        );
+    }
+
+    /**
+     * A document with a token stream on the legacy "base" field
+     * should be accepted.
+     */
+    @Test
+    public void testAddDocAcceptsLegacyBaseField () throws Exception {
+        KrillIndex ki = new KrillIndex();
+
+        FieldDocument fd = new FieldDocument();
+        fd.addString("textSigle", "TEST/BASE/001");
+        fd.addTV("base", "hello world",
+            "[(0-5)s:hello|i:hello|_0$<i>0<i>5|-:t$<i>2]"
+            + "[(6-11)s:world|i:world|_1$<i>6<i>11]");
+        ki.addDoc(fd);
+        ki.commit();
+
+        assertEquals(
+            "Document with legacy 'base' field should be accepted",
+            1, ki.numberOf("documents")
+        );
+    }
+
+    /**
+     * When metadata-only documents are mixed with proper documents,
+     * only proper documents should be indexed and contribute to stats.
+     */
+    @Test
+    public void testStatsWithMixedValidAndMetadataOnlyDocuments () throws Exception {
+        KrillIndex ki = new KrillIndex();
+
+        // Valid document with token stream
+        FieldDocument fd1 = new FieldDocument();
+        fd1.addString("textSigle", "TEST/OK/001");
+        fd1.addString("author", "Good Author");
+        fd1.addTV("tokens", "hello world",
+            "[(0-5)s:hello|i:hello|_0$<i>0<i>5|-:tokens$<i>2]"
+            + "[(6-11)s:world|i:world|_1$<i>6<i>11]");
+        ki.addDoc(fd1);
+
+        // Metadata-only document (should be rejected)
+        FieldDocument fd2 = new FieldDocument();
+        fd2.addString("textSigle", "TEST/BAD/001");
+        fd2.addString("author", "Bad Author");
+        ki.addDoc(fd2);
+
+        // Another valid document
+        FieldDocument fd3 = new FieldDocument();
+        fd3.addString("textSigle", "TEST/OK/002");
+        fd3.addString("author", "Another Author");
+        fd3.addTV("tokens", "foo bar baz",
+            "[(0-3)s:foo|i:foo|_0$<i>0<i>3|-:tokens$<i>3]"
+            + "[(4-7)s:bar|i:bar|_1$<i>4<i>7]"
+            + "[(8-11)s:baz|i:baz|_2$<i>8<i>11]");
+        ki.addDoc(fd3);
+
+        ki.commit();
+
+        assertEquals(
+            "Only valid documents should be counted",
+            2, ki.numberOf("documents")
+        );
+        assertEquals(5, ki.numberOf("tokens"));
+    }
+
+    /**
+     * A document with an empty stream (data section present but stream
+     * is empty) has a token stream field but no tokens. This is allowed
+     * through indexing since the document structure is valid (it has a
+     * "data" section), but it contributes 0 to token statistics.
+     */
+    @Test
+    public void testAddDocAcceptsEmptyTokenStream () throws Exception {
+        String json = "{"
+            + "  \"data\" : {"
+            + "    \"text\" : \"\","
+            + "    \"name\" : \"tokens\","
+            + "    \"stream\" : []"
+            + "  },"
+            + "  \"fields\" : ["
+            + "    {"
+            + "      \"@type\" : \"koral:field\","
+            + "      \"type\" : \"type:string\","
+            + "      \"key\" : \"textSigle\","
+            + "      \"value\" : \"TEST/EMPTY/001\""
+            + "    }"
+            + "  ]"
+            + "}";
+
+        KrillIndex ki = new KrillIndex();
+        FieldDocument fd = ki.addDoc(json);
+        ki.commit();
+
+        assertEquals(
+            "Document with empty stream is accepted (has token field)",
+            1, ki.numberOf("documents")
+        );
+        assertEquals(
+            "Empty stream contributes 0 tokens",
+            0, ki.numberOf("tokens")
+        );
+    }
+
+    /**
+     * Upserting a valid document with a metadata-only replacement should
+     * NOT remove the original and should reject the new one.
+     */
+    @Test
+    public void testUpsertDoesNotReplaceValidDocWithMetadataOnly () throws Exception {
+        KrillIndex ki = new KrillIndex();
+
+        // First: add a valid document
+        FieldDocument fd1 = new FieldDocument();
+        fd1.addString("textSigle", "TEST/UPSERT/001");
+        fd1.addString("author", "Original");
+        fd1.addTV("tokens", "good data",
+            "[(0-4)s:good|i:good|_0$<i>0<i>4|-:tokens$<i>2]"
+            + "[(5-9)s:data|i:data|_1$<i>5<i>9]");
+        ki.addDoc(fd1);
+        ki.commit();
+
+        assertEquals(1, ki.numberOf("documents"));
+        assertEquals(2, ki.numberOf("tokens"));
+
+        // Now try to upsert with metadata-only (should be rejected)
+        FieldDocument fd2 = new FieldDocument();
+        fd2.addString("textSigle", "TEST/UPSERT/001");
+        fd2.addString("author", "Replacement Without Tokens");
+        ki.upsertDoc(fd2);
+        ki.commit();
+
+        // The original document should still be there since the upsert
+        // was rejected before the delete could happen
+        assertEquals(1, ki.numberOf("documents"));
+        assertEquals(2, ki.numberOf("tokens"));
+
+        MetaFields mfs = ki.getFields("TEST/UPSERT/001");
+        assertEquals("Original", mfs.getFieldValue("author"));
+    }
+
     @Test
     public void indexUpsert () throws Exception {
         KrillIndex ki = new KrillIndex();
@@ -645,6 +886,8 @@
         FieldDocument fd = new FieldDocument();
         fd.addString("textSigle", "AAA/BBB/001");
         fd.addString("content", "Example1");
+        fd.addTV("tokens", "Example1",
+            "[(0-8)s:Example1|i:example1|_0$<i>0<i>8|-:tokens$<i>1]");
         ki.upsertDoc(fd);
         ki.commit();
 
@@ -662,6 +905,8 @@
         fd = new FieldDocument();
         fd.addString("textSigle", "AAA/BBB/002");
         fd.addString("content", "Example2");
+        fd.addTV("tokens", "Example2",
+            "[(0-8)s:Example2|i:example2|_0$<i>0<i>8|-:tokens$<i>1]");
 
         ki.upsertDoc(fd);
         ki.commit();
@@ -675,6 +920,8 @@
         fd = new FieldDocument();
         fd.addString("textSigle", "AAA/BBB/001");
         fd.addString("content", "Example3");
+        fd.addTV("tokens", "Example3",
+            "[(0-8)s:Example3|i:example3|_0$<i>0<i>8|-:tokens$<i>1]");
 
         ki.upsertDoc(fd);
         ki.commit();
@@ -696,6 +943,8 @@
         fd = new FieldDocument();
         fd.addString("textSigle", "AAA/DDD/005");
         fd.addString("content", "Example4");
+        fd.addTV("tokens", "Example4",
+            "[(0-8)s:Example4|i:example4|_0$<i>0<i>8|-:tokens$<i>1]");
         
         ki.upsertDoc(fd);
         ki.commit();
@@ -704,7 +953,873 @@
 
     };
 
-    
+
+    private static FieldDocument createSimpleDoc (String textSigle,
+            String author, int tokenCount) {
+        FieldDocument fd = new FieldDocument();
+        fd.addString("textSigle", textSigle);
+        fd.addString("author", author);
+
+        StringBuilder primaryData = new StringBuilder();
+        StringBuilder tvBuilder = new StringBuilder();
+        for (int i = 0; i < tokenCount; i++) {
+            char c = (char) ('a' + (i % 26));
+            if (i > 0) primaryData.append(' ');
+            primaryData.append(c);
+            int start = i * 2;
+            int end = start + 1;
+            tvBuilder.append("[(").append(start).append("-").append(end)
+                .append(")s:").append(c).append("|i:").append(c)
+                .append("|_").append(i).append("$<i>").append(start)
+                .append("<i>").append(end);
+            if (i == 0) {
+                tvBuilder.append("|-:tokens$<i>").append(tokenCount);
+            }
+            tvBuilder.append("]");
+        }
+        fd.addTV("tokens", primaryData.toString(), tvBuilder.toString());
+        return fd;
+    }
+
+
+    @Test
+    public void testStatsAfterDeleteSingleSegment () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        assertEquals(3, ki.numberOf("documents"));
+        assertEquals(22, ki.numberOf("tokens"));
+
+        ki.delDocs("textSigle", "A/B/002");
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(12, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterDeleteMultipleSegments () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        assertEquals(3, ki.numberOf("documents"));
+        assertEquals(22, ki.numberOf("tokens"));
+
+        ki.delDocs("textSigle", "A/B/002");
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(12, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterUpsertSingleSegment () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(15, ki.numberOf("tokens"));
+
+        FieldDocument updated = createSimpleDoc("A/B/001", "Frank", 3);
+        ki.upsertDoc(updated);
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(13, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterUpsertMultipleSegments () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        assertEquals(3, ki.numberOf("documents"));
+        assertEquals(22, ki.numberOf("tokens"));
+
+        FieldDocument updated = createSimpleDoc("A/B/002", "Peter", 4);
+        ki.upsertDoc(updated);
+        ki.commit();
+
+        assertEquals(3, ki.numberOf("documents"));
+        assertEquals(16, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithVCAfterDelete () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        assertEquals(2, kc.docCount());
+        assertEquals(12, kc.numberOf("tokens", "tokens"));
+
+        ki.delDocs("textSigle", "A/B/001");
+        ki.commit();
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        assertEquals(1, kc.docCount());
+        assertEquals(7, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithVCAfterDeleteMultipleSegments ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        assertEquals(2, kc.docCount());
+        assertEquals(12, kc.numberOf("tokens", "tokens"));
+
+        ki.delDocs("textSigle", "A/B/001");
+        ki.commit();
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        assertEquals(1, kc.docCount());
+        assertEquals(7, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithVCAfterUpsert () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        assertEquals(2, kc.docCount());
+        assertEquals(12, kc.numberOf("tokens", "tokens"));
+
+        FieldDocument updated = createSimpleDoc("A/B/001", "Frank", 3);
+        ki.upsertDoc(updated);
+        ki.commit();
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        assertEquals(2, kc.docCount());
+        assertEquals(10, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithVCAfterUpsertMultipleSegments ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        assertEquals(2, kc.docCount());
+        assertEquals(12, kc.numberOf("tokens", "tokens"));
+
+        FieldDocument updated = createSimpleDoc("A/B/002", "Peter", 4);
+        ki.upsertDoc(updated);
+        ki.commit();
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Peter"));
+
+        assertEquals(1, kc.docCount());
+        assertEquals(4, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsConsistencyAfterMultipleUpserts ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(15, ki.numberOf("tokens"));
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Frank", 3));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(13, ki.numberOf("tokens"));
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Frank", 8));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(18, ki.numberOf("tokens"));
+
+        ki.upsertDoc(createSimpleDoc("A/B/002", "Peter", 2));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(10, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterDeleteAllInSegment () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(15, ki.numberOf("tokens"));
+
+        ki.delDocs("textSigle", "A/B/001");
+        ki.commit();
+
+        assertEquals(1, ki.numberOf("documents"));
+        assertEquals(10, ki.numberOf("tokens"));
+
+        ki.delDocs("textSigle", "A/B/002");
+        ki.commit();
+
+        assertEquals(0, ki.numberOf("documents"));
+        assertEquals(0, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsDocCountAndTokensConsistentWithVC ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Frank", 3));
+        ki.commit();
+
+        ki.delDocs("textSigle", "A/B/002");
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(10, ki.numberOf("tokens"));
+
+        CollectionBuilder cb = new CollectionBuilder();
+
+        KrillCollection kcFrank = new KrillCollection(ki);
+        kcFrank.fromBuilder(cb.term("author", "Frank"));
+        assertEquals(2, kcFrank.docCount());
+        assertEquals(10, kcFrank.numberOf("tokens", "tokens"));
+
+        KrillCollection kcPeter = new KrillCollection(ki);
+        kcPeter.fromBuilder(cb.term("author", "Peter"));
+        assertEquals(0, kcPeter.docCount());
+        assertEquals(0, kcPeter.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithNegatedVCAfterDelete () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        ki.delDocs("textSigle", "A/B/002");
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Peter").not());
+
+        assertEquals(2, kc.docCount());
+        assertEquals(12, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterUpsertChangingAuthor () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Peter", 5));
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+
+        KrillCollection kcFrank = new KrillCollection(ki);
+        kcFrank.fromBuilder(cb.term("author", "Frank"));
+        assertEquals(0, kcFrank.docCount());
+        assertEquals(0, kcFrank.numberOf("tokens", "tokens"));
+
+        KrillCollection kcPeter = new KrillCollection(ki);
+        kcPeter.fromBuilder(cb.term("author", "Peter"));
+        assertEquals(2, kcPeter.docCount());
+        assertEquals(15, kcPeter.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterManyUpsertsAcrossSegments () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        for (int i = 1; i <= 10; i++) {
+            ki.addDoc(createSimpleDoc(
+                "C/D/" + String.format("%03d", i),
+                i <= 5 ? "Frank" : "Peter",
+                i * 2));
+            ki.commit();
+        }
+
+        assertEquals(10, ki.numberOf("documents"));
+        assertEquals(110, ki.numberOf("tokens"));
+
+        for (int i = 1; i <= 10; i++) {
+            ki.upsertDoc(createSimpleDoc(
+                "C/D/" + String.format("%03d", i),
+                i <= 5 ? "Frank" : "Peter",
+                i));
+            ki.commit();
+        }
+
+        assertEquals(10, ki.numberOf("documents"));
+        assertEquals(55, ki.numberOf("tokens"));
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kcFrank = new KrillCollection(ki);
+        kcFrank.fromBuilder(cb.term("author", "Frank"));
+        assertEquals(5, kcFrank.docCount());
+        assertEquals(15, kcFrank.numberOf("tokens", "tokens"));
+
+        KrillCollection kcPeter = new KrillCollection(ki);
+        kcPeter.fromBuilder(cb.term("author", "Peter"));
+        assertEquals(5, kcPeter.docCount());
+        assertEquals(40, kcPeter.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsDocCountVsNumberOfDocumentsAfterUpsert ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Frank", 3));
+        ki.commit();
+
+        ki.upsertDoc(createSimpleDoc("A/B/002", "Peter", 8));
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+
+        long docCountMethod = kc.docCount();
+        long numberOfDocuments = kc.numberOf("documents");
+        assertEquals(docCountMethod, numberOfDocuments);
+        assertEquals(2, docCountMethod);
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(
+            cb.orGroup()
+                .with(cb.term("author", "Frank"))
+                .with(cb.term("author", "Peter")));
+        long allDocs = kc.docCount();
+        long allDocsNumberOf = kc.numberOf("documents");
+        assertEquals(allDocs, allDocsNumberOf);
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithDateFilterAfterUpsert () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        FieldDocument fd1 = createSimpleDoc("A/B/001", "Frank", 5);
+        fd1.addDate("pubDate", 20180315);
+        ki.addDoc(fd1);
+
+        FieldDocument fd2 = createSimpleDoc("A/B/002", "Peter", 10);
+        fd2.addDate("pubDate", 20180620);
+        ki.addDoc(fd2);
+
+        FieldDocument fd3 = createSimpleDoc("A/B/003", "Frank", 7);
+        fd3.addDate("pubDate", 20190101);
+        ki.addDoc(fd3);
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.date("pubDate", "2018"));
+
+        assertEquals(2, kc.docCount());
+        assertEquals(15, kc.numberOf("tokens", "tokens"));
+
+        FieldDocument updated = createSimpleDoc("A/B/001", "Frank", 3);
+        updated.addDate("pubDate", 20180315);
+        ki.upsertDoc(updated);
+        ki.commit();
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.date("pubDate", "2018"));
+
+        assertEquals(2, kc.docCount());
+        assertEquals(13, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithDateFilterAndMultipleSegmentsAfterUpsert ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        FieldDocument fd1 = createSimpleDoc("A/B/001", "Frank", 5);
+        fd1.addDate("pubDate", 20180315);
+        ki.addDoc(fd1);
+        ki.commit();
+
+        FieldDocument fd2 = createSimpleDoc("A/B/002", "Peter", 10);
+        fd2.addDate("pubDate", 20180620);
+        ki.addDoc(fd2);
+        ki.commit();
+
+        FieldDocument fd3 = createSimpleDoc("A/B/003", "Frank", 7);
+        fd3.addDate("pubDate", 20190101);
+        ki.addDoc(fd3);
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.date("pubDate", "2018"));
+        assertEquals(2, kc.docCount());
+        assertEquals(15, kc.numberOf("tokens", "tokens"));
+
+        FieldDocument updated = createSimpleDoc("A/B/001", "Frank", 3);
+        updated.addDate("pubDate", 20180315);
+        ki.upsertDoc(updated);
+        ki.commit();
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.date("pubDate", "2018"));
+        assertEquals(2, kc.docCount());
+        assertEquals(13, kc.numberOf("tokens", "tokens"));
+
+        FieldDocument updated2 = createSimpleDoc("A/B/002", "Peter", 4);
+        updated2.addDate("pubDate", 20180620);
+        ki.upsertDoc(updated2);
+        ki.commit();
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.date("pubDate", "2018"));
+        assertEquals(2, kc.docCount());
+        assertEquals(7, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithComplexVCAfterMultipleUpserts ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        for (int i = 1; i <= 5; i++) {
+            FieldDocument fd = createSimpleDoc(
+                "X/Y/" + String.format("%03d", i),
+                i <= 3 ? "Frank" : "Peter",
+                i * 3);
+            fd.addDate("pubDate", 20180100 + i);
+            ki.addDoc(fd);
+            ki.commit();
+        }
+
+        assertEquals(5, ki.numberOf("documents"));
+        assertEquals(45, ki.numberOf("tokens"));
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(
+            cb.andGroup()
+                .with(cb.date("pubDate", "2018"))
+                .with(cb.term("author", "Frank")));
+        assertEquals(3, kc.docCount());
+        assertEquals(18, kc.numberOf("tokens", "tokens"));
+
+        for (int i = 1; i <= 5; i++) {
+            FieldDocument fd = createSimpleDoc(
+                "X/Y/" + String.format("%03d", i),
+                i <= 3 ? "Frank" : "Peter",
+                i);
+            fd.addDate("pubDate", 20180100 + i);
+            ki.upsertDoc(fd);
+            ki.commit();
+        }
+
+        assertEquals(5, ki.numberOf("documents"));
+        assertEquals(15, ki.numberOf("tokens"));
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(
+            cb.andGroup()
+                .with(cb.date("pubDate", "2018"))
+                .with(cb.term("author", "Frank")));
+        assertEquals(3, kc.docCount());
+        assertEquals(6, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsWithNegationAndDeletedDocs ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        ki.addDoc(createSimpleDoc("A/B/004", "Maria", 3));
+        ki.commit();
+
+        ki.delDocs("textSigle", "A/B/002");
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank").not());
+
+        assertEquals(1, kc.docCount());
+        assertEquals(3, kc.numberOf("tokens", "tokens"));
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Peter").not());
+
+        assertEquals(3, kc.docCount());
+        assertEquals(15, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsUpsertThenBatchDelete () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        for (int i = 1; i <= 5; i++) {
+            ki.addDoc(createSimpleDoc(
+                "A/B/" + String.format("%03d", i), "Frank", i * 2));
+            ki.commit();
+        }
+
+        assertEquals(5, ki.numberOf("documents"));
+        assertEquals(30, ki.numberOf("tokens"));
+
+        ki.upsertDoc(createSimpleDoc("A/B/003", "Frank", 1));
+        ki.commit();
+
+        assertEquals(5, ki.numberOf("documents"));
+        assertEquals(25, ki.numberOf("tokens"));
+
+        ki.delDocs("textSigle", "A/B/001");
+        ki.delDocs("textSigle", "A/B/002");
+        ki.delDocs("textSigle", "A/B/004");
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(11, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsVCMatchesOnlyDeletedDocs () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        ki.delDocs("textSigle", "A/B/002");
+        ki.commit();
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Peter"));
+        assertEquals(0, kc.docCount());
+        assertEquals(0, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterCloseAndReopen () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(15, ki.numberOf("tokens"));
+
+        ki.close();
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Frank", 3));
+        ki.commit();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(13, ki.numberOf("tokens"));
+
+        ki.close();
+
+        assertEquals(2, ki.numberOf("documents"));
+        assertEquals(13, ki.numberOf("tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsMultipleUpsertsWithoutIntermediateCommit ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        assertEquals(3, ki.numberOf("documents"));
+        assertEquals(22, ki.numberOf("tokens"));
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Frank", 3));
+        ki.upsertDoc(createSimpleDoc("A/B/002", "Peter", 4));
+        ki.upsertDoc(createSimpleDoc("A/B/003", "Frank", 2));
+        ki.commit();
+
+        assertEquals(3, ki.numberOf("documents"));
+        assertEquals(9, ki.numberOf("tokens"));
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+        assertEquals(2, kc.docCount());
+        assertEquals(5, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsAfterForceMerge () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        ki.addDoc(createSimpleDoc("A/B/001", "Frank", 5));
+        ki.commit();
+        ki.addDoc(createSimpleDoc("A/B/002", "Peter", 10));
+        ki.commit();
+        ki.addDoc(createSimpleDoc("A/B/003", "Frank", 7));
+        ki.commit();
+
+        ki.upsertDoc(createSimpleDoc("A/B/001", "Frank", 3));
+        ki.commit();
+        ki.upsertDoc(createSimpleDoc("A/B/002", "Peter", 4));
+        ki.commit();
+
+        ki.writer().forceMerge(1);
+        ki.commit();
+
+        assertEquals(3, ki.numberOf("documents"));
+        assertEquals(14, ki.numberOf("tokens"));
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+        assertEquals(2, kc.docCount());
+        assertEquals(10, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
+
+    @Test
+    public void testStatsStressWithManySegments () throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        int numDocs = 100;
+        long expectedTotalTokens = 0;
+
+        for (int i = 1; i <= numDocs; i++) {
+            String author = (i % 3 == 0) ? "Peter" : "Frank";
+            int tokenCount = (i % 7) + 1;
+            FieldDocument fd = createSimpleDoc(
+                "S/T/" + String.format("%04d", i), author, tokenCount);
+            fd.addDate("pubDate", 20180100 + (i % 28) + 1);
+            ki.addDoc(fd);
+            if (i % 10 == 0) ki.commit();
+            expectedTotalTokens += tokenCount;
+        }
+        ki.commit();
+
+        assertEquals(numDocs, ki.numberOf("documents"));
+        assertEquals(expectedTotalTokens, ki.numberOf("tokens"));
+
+        long newTotalTokens = 0;
+        int newFrankDocs = 0;
+        long newFrankTokens = 0;
+
+        for (int i = 1; i <= numDocs; i++) {
+            String author = (i % 3 == 0) ? "Peter" : "Frank";
+            int tokenCount = (i % 5) + 1;
+            FieldDocument fd = createSimpleDoc(
+                "S/T/" + String.format("%04d", i), author, tokenCount);
+            fd.addDate("pubDate", 20180100 + (i % 28) + 1);
+            ki.upsertDoc(fd);
+            if (i % 10 == 0) ki.commit();
+            newTotalTokens += tokenCount;
+            if (author.equals("Frank")) {
+                newFrankDocs++;
+                newFrankTokens += tokenCount;
+            }
+        }
+        ki.commit();
+
+        assertEquals(numDocs, ki.numberOf("documents"));
+        assertEquals(newTotalTokens, ki.numberOf("tokens"));
+
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kc = new KrillCollection(ki);
+        kc.fromBuilder(cb.term("author", "Frank"));
+        assertEquals(newFrankDocs, kc.docCount());
+        assertEquals(newFrankTokens, kc.numberOf("tokens", "tokens"));
+
+        kc = new KrillCollection(ki);
+        kc.fromBuilder(
+            cb.andGroup()
+                .with(cb.date("pubDate", "2018"))
+                .with(cb.term("author", "Frank")));
+        assertEquals(newFrankDocs, kc.docCount());
+        assertEquals(newFrankTokens, kc.numberOf("tokens", "tokens"));
+
+        ki.close();
+    }
+
     private static String createDocString1 () {
         return new String(
             "{"
diff --git a/src/test/java/de/ids_mannheim/korap/index/TestIndexRevision.java b/src/test/java/de/ids_mannheim/korap/index/TestIndexRevision.java
index 1c43baf..d6d8278 100644
--- a/src/test/java/de/ids_mannheim/korap/index/TestIndexRevision.java
+++ b/src/test/java/de/ids_mannheim/korap/index/TestIndexRevision.java
@@ -3,6 +3,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.assertFalse;
 
 import java.util.List;
 
@@ -209,4 +210,50 @@
 
         assertEquals("Wes8Bd4h1OypPqbWF5njeQ==",ki.getFingerprint());
     };
+
+    @Test
+    public void testCommitForceFalseDoesNotCloseReaderWhenNoChanges ()
+                  throws IOException {
+              KrillIndex ki = new KrillIndex();
+        
+            FieldDocument fd = new FieldDocument();
+            fd.addString("textSigle", "TEST/DOC/001");
+            fd.addString("content", "Example1");
+            ki.addDoc(fd);
+            ki.commit();
+    
+            // Ensure reader is open (and there are no pending changes)
+            ki.reader();
+            assertTrue(ki.isReaderOpen());
+    
+            // With no pending changes, force=false must NOT trigger a commit
+            // (and therefore must not close the reader)
+            ki.commit(false);
+            assertTrue(ki.isReaderOpen());
+    
+            ki.close();
+    }
+    
+    @Test
+    public void testCommitForceTrueClosesReaderWhenNoChanges ()
+                throws IOException {
+            KrillIndex ki = new KrillIndex();
+    
+            FieldDocument fd = new FieldDocument();
+            fd.addString("textSigle", "TEST/DOC/001");
+            fd.addString("content", "Example1");
+            ki.addDoc(fd);
+            ki.commit();
+    
+            // Ensure reader is open (and there are no pending changes)
+            ki.reader();
+            assertTrue(ki.isReaderOpen());
+    
+            // With no pending changes, force=true must still trigger a commit,
+            // which closes the reader
+            ki.commit(true);
+            assertFalse(ki.isReaderOpen());
+    
+            ki.close();
+    };
 };
diff --git a/src/test/java/de/ids_mannheim/korap/index/TestKrillIndex.java b/src/test/java/de/ids_mannheim/korap/index/TestKrillIndex.java
index e6c7d93..a301544 100644
--- a/src/test/java/de/ids_mannheim/korap/index/TestKrillIndex.java
+++ b/src/test/java/de/ids_mannheim/korap/index/TestKrillIndex.java
@@ -114,12 +114,16 @@
 
         FieldDocument fd = new FieldDocument();
         fd.addString("name", "Peter");
+        fd.addTV("tokens", "x",
+            "[(0-1)s:x|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         assertEquals(0, ki.numberOf("base", "documents"));
 
         fd = new FieldDocument();
         fd.addString("name", "Michael");
+        fd.addTV("tokens", "y",
+            "[(0-1)s:y|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         assertEquals(0, ki.numberOf("base", "documents"));
@@ -170,11 +174,15 @@
         FieldDocument fd = new FieldDocument();
         fd.addText("title", "Peter");
         fd.setUID(22);
+        fd.addTV("tokens", "x",
+            "[(0-1)s:x|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         fd = new FieldDocument();
         fd.addText("title", "Akron");
         fd.setUID("05678");
+        fd.addTV("tokens", "y",
+            "[(0-1)s:y|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         ki.commit();
@@ -211,6 +219,8 @@
 
 		fd.addText("title", "Der Name der Rose");
 
+        fd.addTV("tokens", "x",
+            "[(0-1)s:x|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         /* Save documents */
@@ -335,6 +345,8 @@
         FieldDocument fd = new FieldDocument();
         fd.addString("name", "Peter");
         fd.addString("textSigle", "a/b/c");
+        fd.addTV("tokens", "x",
+            "[(0-1)s:x|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.upsertDoc(fd);
 
         /* Save documents */
@@ -343,6 +355,8 @@
         fd = new FieldDocument();
         fd.addString("name", "Frank");
         fd.addString("textSigle", "a/b/d");
+        fd.addTV("tokens", "y",
+            "[(0-1)s:y|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.upsertDoc(fd);
 
         /* Save documents */
@@ -351,6 +365,8 @@
         fd = new FieldDocument();
         fd.addString("name", "Franz");
         fd.addString("textSigle", "a/b/c");
+        fd.addTV("tokens", "z",
+            "[(0-1)s:z|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.upsertDoc(fd);
 
         /* Save documents */
@@ -384,11 +400,15 @@
 
         FieldDocument fd = new FieldDocument();
         fd.addString("textSigle", "aaaa");
+        fd.addTV("tokens", "x",
+            "[(0-1)s:x|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         fd = new FieldDocument();
         fd.addString("textSigle", "bbbb");
         fd.setUID("05678");
+        fd.addTV("tokens", "y",
+            "[(0-1)s:y|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         ki.commit();
@@ -413,6 +433,8 @@
         
         fd = new FieldDocument();
         fd.addString("textSigle", "cccc");
+        fd.addTV("tokens", "z",
+            "[(0-1)s:z|_0$<i>0<i>1|-:tokens$<i>1]");
         ki.addDoc(fd);
 
         ki.commit();
diff --git a/src/test/java/de/ids_mannheim/korap/index/TestPrimaryDataProtection.java b/src/test/java/de/ids_mannheim/korap/index/TestPrimaryDataProtection.java
index 11a6421..191ced2 100644
--- a/src/test/java/de/ids_mannheim/korap/index/TestPrimaryDataProtection.java
+++ b/src/test/java/de/ids_mannheim/korap/index/TestPrimaryDataProtection.java
@@ -385,6 +385,10 @@
         fd.addString("textSigle", "TST-004-0001");
         fd.addText("title", "Custom Field Document");
         fd.setUID(45);
+        fd.addTV("tokens", "some indexed text",
+                "[(0-4)s:some|_0#0-4|-:t$<i>3]"
+                + "[(5-12)s:indexed|_1#5-12]"
+                + "[(13-17)s:text|_2#13-17]");
         fd.addTV("customTokens", "leaked custom text",
                 "[(0-6)s:leaked|_0#0-6|-:t$<i>3]"
                 + "[(7-13)s:custom|_1#7-13]"
diff --git a/src/test/java/de/ids_mannheim/korap/index/TestWithinIndex.java b/src/test/java/de/ids_mannheim/korap/index/TestWithinIndex.java
index b7c2416..0f18b1b 100644
--- a/src/test/java/de/ids_mannheim/korap/index/TestWithinIndex.java
+++ b/src/test/java/de/ids_mannheim/korap/index/TestWithinIndex.java
@@ -3,27 +3,46 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
 
 import java.io.*;
 import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.TreeSet;
+
 import static de.ids_mannheim.korap.TestSimple.*;
 
 import org.apache.lucene.index.Term;
+import org.apache.lucene.index.TermContext;
+import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.search.spans.SpanQuery;
+import org.apache.lucene.search.spans.Spans;
 import org.apache.lucene.search.spans.SpanTermQuery;
+import org.apache.lucene.util.Bits;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
+import de.ids_mannheim.korap.Krill;
+import de.ids_mannheim.korap.KrillCollection;
 import de.ids_mannheim.korap.KrillIndex;
 import de.ids_mannheim.korap.KrillQuery;
+import de.ids_mannheim.korap.collection.CollectionBuilder;
 import de.ids_mannheim.korap.query.QueryBuilder;
 import de.ids_mannheim.korap.query.SpanClassQuery;
 import de.ids_mannheim.korap.query.SpanElementQuery;
+import de.ids_mannheim.korap.query.SpanFocusQuery;
 import de.ids_mannheim.korap.query.wrap.SpanQueryWrapper;
 import de.ids_mannheim.korap.query.SpanNextQuery;
 import de.ids_mannheim.korap.query.SpanWithinQuery;
 import de.ids_mannheim.korap.query.wrap.SpanQueryWrapper;
+import de.ids_mannheim.korap.response.Match;
 import de.ids_mannheim.korap.response.Result;
 import de.ids_mannheim.korap.util.QueryException;
 
@@ -1169,4 +1188,890 @@
         Result kr = ki.search(sq, (short) 1);
         assertEquals(1, kr.getTotalResults());
     }
+
+
+    // Build one "tokens" FieldDocument from sentence token arrays.
+    // Each input array is one sentence, e.g. ["b","a","w2"].
+    // Example doc: [["b","a","w2"],["a","w4","w5"]].
+    private FieldDocument multiSentenceDoc (String[]... sentences) {
+        FieldDocument fd = new FieldDocument();
+
+        int total = 0;
+        for (String[] s : sentences) total += s.length;
+
+        StringBuilder surface = new StringBuilder();
+        StringBuilder tv = new StringBuilder();
+
+        int tokIdx = 0;
+        int charOff = 0;
+        int sentStart = 0;
+
+        for (int si = 0; si < sentences.length; si++) {
+            String[] sent = sentences[si];
+            int sentEnd = sentStart + sent.length;
+
+            for (int ti = 0; ti < sent.length; ti++) {
+                String tok = sent[ti];
+                int cStart = charOff;
+                int cEnd = charOff + tok.length();
+
+                surface.append(tok);
+
+                tv.append("[(").append(cStart).append("-").append(cEnd)
+                        .append(")");
+                tv.append("s:").append(tok);
+
+                if (tokIdx == 0) {
+                    int totalChars = 0;
+                    for (String[] s : sentences)
+                        for (String t : s)
+                            totalChars += t.length();
+                    tv.append("|-:t$<i>").append(total);
+                    tv.append("|<>:base/s:t$<b>64<i>0<i>")
+                            .append(totalChars).append("<i>")
+                            .append(total).append("<b>0");
+                }
+                if (ti == 0) {
+                    int sentCharEnd = cStart;
+                    for (String t : sent) sentCharEnd += t.length();
+                    tv.append("|<>:base/s:s$<b>64<i>")
+                            .append(cStart).append("<i>")
+                            .append(sentCharEnd).append("<i>")
+                            .append(sentEnd).append("<b>1");
+                }
+
+                tv.append("|_").append(tokIdx).append("$<i>")
+                        .append(cStart).append("<i>").append(cEnd);
+                tv.append("]");
+
+                charOff = cEnd;
+                tokIdx++;
+            }
+            sentStart = sentEnd;
+        }
+
+        fd.addTV("tokens", surface.toString(), tv.toString());
+        return fd;
+    }
+
+    // focus(1: contains(contains(<s>, {1: s:a}), {2: s:b}))
+    private SpanQuery nestedContainsDiffClassQuery () {
+        SpanQuery sentence = new SpanElementQuery("tokens", "base/s:s");
+        SpanQuery tokenA = new SpanClassQuery(
+                new SpanTermQuery(new Term("tokens", "s:a")), (byte) 1);
+        SpanQuery tokenB = new SpanClassQuery(
+                new SpanTermQuery(new Term("tokens", "s:b")), (byte) 2);
+        SpanQuery innerContains = new SpanWithinQuery(sentence, tokenA);
+        SpanQuery outerContains = new SpanWithinQuery(innerContains, tokenB);
+        SpanFocusQuery focus = new SpanFocusQuery(outerContains, (byte) 1);
+        focus.setSorted(false);
+        return focus;
+    }
+
+    /**
+     * Two docs in one segment where doc 1 has b in a separate sentence.
+     * The fix prevents b from leaking into doc 0's a-only sentence.
+     */
+    @Test
+    public void testNestedContainsSameSegmentCrossDocLeakage ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        // Doc 0: S0 has b+a, S1 has a only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "b", "a", "w2" },
+                new String[] { "a", "w4", "w5" }));
+
+        // Doc 1: S0 has a only, S1 has b only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "a", "w1", "w2" },
+                new String[] { "b", "w4", "w5" }));
+
+        ki.commit();
+
+        Result kr = ki.search(nestedContainsDiffClassQuery(), (short) 50);
+        assertEquals("Only Doc 0 S0 has both a+b, expect 1 match",
+                1, kr.getTotalResults());
+        assertEquals(1, kr.getMatch(0).getStartPos());
+        assertEquals(2, kr.getMatch(0).getEndPos());
+        ki.close();
+    }
+
+    /**
+     * Same data as above but each doc in its own segment.
+     * Verifies no cross-doc leak with separate segments.
+     */
+    @Test
+    public void testNestedContainsSeparateSegmentsNoLeakage ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        // Doc 0: S0 has b+a, S1 has a only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "b", "a", "w2" },
+                new String[] { "a", "w4", "w5" }));
+        ki.commit();
+
+        // Doc 1: S0 has a only, S1 has b only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "a", "w1", "w2" },
+                new String[] { "b", "w4", "w5" }));
+        ki.commit();
+
+        Result kr = ki.search(nestedContainsDiffClassQuery(), (short) 50);
+        assertEquals("Separate segments: expect 1 match",
+                1, kr.getTotalResults());
+        assertEquals(1, kr.getMatch(0).getStartPos());
+        assertEquals(2, kr.getMatch(0).getEndPos());
+        ki.close();
+    }
+
+    /**
+     * Multiple a-only docs then one doc with both a+b in same segment.
+     * B from last doc must not leak into earlier docs.
+     */
+    @Test
+    public void testNestedContainsManyAOnlyDocsThenBoth ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        // Doc 0: single sentence with a only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "a", "w1", "w2" }));
+        // Doc 1: single sentence with a only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "w0", "a", "w2" }));
+        // Doc 2: single sentence with a only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "a", "w1", "w2" }));
+        // Doc 3: single sentence with both a and b.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "a", "b", "w2" }));
+
+        ki.commit();
+
+        Result kr = ki.search(nestedContainsDiffClassQuery(), (short) 50);
+        assertEquals("Only Doc 3 has both, expect 1 match",
+                1, kr.getTotalResults());
+        assertEquals(0, kr.getMatch(0).getStartPos());
+        assertEquals(1, kr.getMatch(0).getEndPos());
+        ki.close();
+    }
+
+    /**
+     * Multi-sentence a-only doc then doc with a+b in one sentence.
+     * Tests scenario from fuzz seed 125901200207375.
+     */
+    @Test
+    public void testNestedContainsMultiSentenceAOnlyThenBoth ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        // Doc 0: two sentences, both are a only.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "w0", "w1", "a", "w3", "w4" },
+                new String[] { "w5", "a", "w7", "w8", "w9" }));
+
+        // Doc 1: one sentence with both a and b.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "a", "w1", "w2", "b", "w4" }));
+
+        ki.commit();
+
+        Result kr = ki.search(nestedContainsDiffClassQuery(), (short) 50);
+        assertEquals("Only Doc 1 has both a+b, expect 1 match",
+                1, kr.getTotalResults());
+        assertEquals(0, kr.getMatch(0).getStartPos());
+        assertEquals(1, kr.getMatch(0).getEndPos());
+        ki.close();
+    }
+
+    // Regression: skipTo() must also work before first next().
+    @Test
+    public void testWithinSkipToBeforeNextInitializesSpans ()
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+
+        // One local doc with a single sentence that contains token a.
+        ki.addDoc(multiSentenceDoc(
+                new String[] { "a", "w1", "w2" }));
+        ki.commit();
+
+        SpanQuery sq = new SpanWithinQuery(
+                new SpanElementQuery("tokens", "base/s:s"),
+                new SpanTermQuery(new Term("tokens", "s:a")));
+
+        Map<Term, TermContext> termContexts = new HashMap<>();
+        for (LeafReaderContext atomic : ki.reader().leaves()) {
+            Bits bitset = atomic.reader().getLiveDocs();
+            Spans spans = sq.getSpans(atomic, bitset, termContexts);
+            assertTrue("skipTo() before next() must initialize spans",
+                    spans.skipTo(0));
+            assertEquals(0, spans.doc());
+            assertEquals(0, spans.start());
+            assertEquals(3, spans.end());
+        }
+
+        ki.close();
+    }
+
+    // ----------------------------------------------------------------------
+    // Fuzzing tests for nested contains + focus
+    //
+    // Call with:
+    // $ mvn test -Dtest="TestWithinIndex" -Dfuzz.iterations=200 -pl .
+    // ----------------------------------------------------------------------
+
+    // Read iteration count; default 0 keeps fuzz tests disabled.
+    private static int getFuzzIterations () {
+        String prop = System.getProperty("fuzz.iterations");
+        if (prop != null) return Integer.parseInt(prop);
+        return 0;
+    }
+
+    // Read optional seed; default uses current nano time.
+    private static long getFuzzBaseSeed () {
+        String prop = System.getProperty("fuzz.seed");
+        if (prop != null) return Long.parseLong(prop);
+        return System.nanoTime();
+    }
+
+    enum BiasStrategy {
+        UNIFORM,
+        HEAVY_A_RARE_B,
+        ALTERNATING,
+        CLUSTER,
+        DENSE
+    }
+
+    static class SentenceInfo {
+        final int startPos;
+        final int endPos;
+        final List<Integer> aPositions;
+        final List<Integer> bPositions;
+
+        SentenceInfo (int startPos, int endPos,
+                List<Integer> aPositions, List<Integer> bPositions) {
+            this.startPos = startPos;
+            this.endPos = endPos;
+            this.aPositions = aPositions;
+            this.bPositions = bPositions;
+        }
+
+        boolean hasA () { return !aPositions.isEmpty(); }
+        boolean hasB () { return !bPositions.isEmpty(); }
+        boolean hasBoth () { return hasA() && hasB(); }
+    }
+
+    static class DocInfo {
+        final String textSigle;
+        final String genre;
+        final List<SentenceInfo> sentences;
+
+        DocInfo (String textSigle, String genre,
+                List<SentenceInfo> sentences) {
+            this.textSigle = textSigle;
+            this.genre = genre;
+            this.sentences = sentences;
+        }
+    }
+
+    static class IndexConfig {
+        final List<DocInfo> docs;
+        final int[] commitAfterDoc;
+        final long seed;
+        final BiasStrategy strategy;
+
+        IndexConfig (List<DocInfo> docs, int[] commitAfterDoc,
+                long seed, BiasStrategy strategy) {
+            this.docs = docs;
+            this.commitAfterDoc = commitAfterDoc;
+            this.seed = seed;
+            this.strategy = strategy;
+        }
+    }
+
+    // Generate one random fuzz corpus with a random bias strategy.
+    private IndexConfig generateFuzzConfig (long seed) {
+        Random rng = new Random(seed);
+        BiasStrategy[] strategies = BiasStrategy.values();
+        BiasStrategy strategy = strategies[rng.nextInt(strategies.length)];
+        return generateFuzzConfig(seed, rng, strategy);
+    }
+
+    // Generate one random fuzz corpus using a fixed strategy.
+    private IndexConfig generateFuzzConfig (long seed, Random rng,
+            BiasStrategy strategy) {
+        int numDocs = 1 + rng.nextInt(5);
+        List<DocInfo> docs = new ArrayList<>();
+
+        for (int d = 0; d < numDocs; d++) {
+            int numSentences = 2 + rng.nextInt(14);
+            List<SentenceInfo> sentences = new ArrayList<>();
+            int docTokenPos = 0;
+
+            for (int s = 0; s < numSentences; s++) {
+                int sentLen = decideSentenceLength(rng, strategy);
+                int sentStart = docTokenPos;
+                int sentEnd = docTokenPos + sentLen;
+
+                boolean placeA = decidePlaceA(rng, strategy, s, numSentences);
+                boolean placeB = decidePlaceB(rng, strategy, s, numSentences);
+
+                int numA = placeA ? (1 + rng.nextInt(
+                        strategy == BiasStrategy.DENSE ? 3 : 2)) : 0;
+                int numB = placeB ? (1 + rng.nextInt(
+                        strategy == BiasStrategy.DENSE ? 3 : 2)) : 0;
+                numA = Math.min(numA, sentLen / 2);
+                numB = Math.min(numB, Math.max(0, sentLen - numA));
+
+                Set<Integer> usedOffsets = new HashSet<>();
+                List<Integer> aPositions = new ArrayList<>();
+                List<Integer> bPositions = new ArrayList<>();
+
+                for (int i = 0; i < numA; i++) {
+                    int off = pickUnusedOffset(rng, sentLen, usedOffsets);
+                    if (off >= 0) {
+                        usedOffsets.add(off);
+                        aPositions.add(sentStart + off);
+                    }
+                }
+                for (int i = 0; i < numB; i++) {
+                    int off = pickUnusedOffset(rng, sentLen, usedOffsets);
+                    if (off >= 0) {
+                        usedOffsets.add(off);
+                        bPositions.add(sentStart + off);
+                    }
+                }
+
+                sentences.add(new SentenceInfo(sentStart, sentEnd,
+                        aPositions, bPositions));
+                docTokenPos = sentEnd;
+            }
+
+            String genre = (d % 3 == 0) ? "science"
+                    : (d % 3 == 1) ? "fiction" : "news";
+            docs.add(new DocInfo("TST/D" + d + "/T0", genre, sentences));
+        }
+
+        int[] commitAfterDoc = decideCommitPoints(rng, numDocs);
+        return new IndexConfig(docs, commitAfterDoc, seed, strategy);
+    }
+
+    // Choose sentence length for one random sentence.
+    private int decideSentenceLength (Random rng, BiasStrategy strategy) {
+        if (strategy == BiasStrategy.DENSE) {
+            return 6 + rng.nextInt(10);
+        }
+        int r = rng.nextInt(100);
+        if (r < 5) return 2 + rng.nextInt(2);
+        if (r < 15) return 20 + rng.nextInt(11);
+        return 3 + rng.nextInt(15);
+    }
+
+    // Decide if sentence gets at least one a token.
+    private boolean decidePlaceA (Random rng, BiasStrategy strategy,
+            int sentIdx, int numSentences) {
+        switch (strategy) {
+            case HEAVY_A_RARE_B:
+                return rng.nextDouble() < 0.9;
+            case ALTERNATING:
+                return sentIdx % 2 == 0 || rng.nextDouble() < 0.15;
+            case CLUSTER:
+                return sentIdx < numSentences / 2 || rng.nextDouble() < 0.2;
+            case DENSE:
+                return true;
+            default:
+                return rng.nextDouble() < 0.6;
+        }
+    }
+
+    // Decide if sentence gets at least one b token.
+    private boolean decidePlaceB (Random rng, BiasStrategy strategy,
+            int sentIdx, int numSentences) {
+        switch (strategy) {
+            case HEAVY_A_RARE_B:
+                return rng.nextDouble() < 0.1;
+            case ALTERNATING:
+                return sentIdx % 2 == 1 || rng.nextDouble() < 0.15;
+            case CLUSTER:
+                return sentIdx >= numSentences / 2 || rng.nextDouble() < 0.2;
+            case DENSE:
+                return true;
+            default:
+                return rng.nextDouble() < 0.4;
+        }
+    }
+
+    // Pick a random token offset not used yet in sentence.
+    private int pickUnusedOffset (Random rng, int sentLen,
+            Set<Integer> used) {
+        if (used.size() >= sentLen) return -1;
+        for (int attempt = 0; attempt < 20; attempt++) {
+            int pos = rng.nextInt(sentLen);
+            if (!used.contains(pos)) return pos;
+        }
+        for (int pos = 0; pos < sentLen; pos++) {
+            if (!used.contains(pos)) return pos;
+        }
+        return -1;
+    }
+
+    // Choose where to commit so we get multiple segment layouts.
+    private int[] decideCommitPoints (Random rng, int numDocs) {
+        if (numDocs <= 1) return new int[] { 0 };
+        List<Integer> points = new ArrayList<>();
+        for (int d = 0; d < numDocs; d++) {
+            if (d == numDocs - 1 || rng.nextDouble() < 0.4) {
+                points.add(d);
+            }
+        }
+        if (!points.contains(numDocs - 1)) points.add(numDocs - 1);
+        int[] result = new int[points.size()];
+        for (int i = 0; i < result.length; i++) result[i] = points.get(i);
+        return result;
+    }
+
+    // Build a KrillIndex from generated docs and commit plan.
+    private KrillIndex buildFuzzIndex (IndexConfig config)
+            throws IOException {
+        KrillIndex ki = new KrillIndex();
+        int commitIdx = 0;
+
+        for (int d = 0; d < config.docs.size(); d++) {
+            DocInfo doc = config.docs.get(d);
+            FieldDocument fd = buildFuzzFieldDocument(doc);
+            ki.addDoc(fd);
+
+            if (commitIdx < config.commitAfterDoc.length
+                    && config.commitAfterDoc[commitIdx] == d) {
+                ki.commit();
+                commitIdx++;
+            }
+        }
+
+        if (commitIdx == 0 || config.commitAfterDoc[commitIdx - 1]
+                != config.docs.size() - 1) {
+            ki.commit();
+        }
+
+        return ki;
+    }
+
+    // Convert one generated doc into a tokens field with TV data.
+    private FieldDocument buildFuzzFieldDocument (DocInfo doc) {
+        FieldDocument fd = new FieldDocument();
+        fd.addString("textSigle", doc.textSigle);
+        fd.addString("genre", doc.genre);
+
+        Set<Integer> aPositions = new HashSet<>();
+        Set<Integer> bPositions = new HashSet<>();
+        for (SentenceInfo sent : doc.sentences) {
+            aPositions.addAll(sent.aPositions);
+            bPositions.addAll(sent.bPositions);
+        }
+
+        int totalTokens = 0;
+        for (SentenceInfo sent : doc.sentences) {
+            totalTokens += (sent.endPos - sent.startPos);
+        }
+
+        String[] allTokens = new String[totalTokens];
+        int idx = 0;
+        for (SentenceInfo sent : doc.sentences) {
+            for (int pos = sent.startPos; pos < sent.endPos; pos++) {
+                if (aPositions.contains(pos)) {
+                    allTokens[idx] = "a";
+                }
+                else if (bPositions.contains(pos)) {
+                    allTokens[idx] = "b";
+                }
+                else {
+                    allTokens[idx] = "w" + pos;
+                }
+                idx++;
+            }
+        }
+
+        int totalChars = 0;
+        for (String t : allTokens) totalChars += t.length();
+
+        StringBuilder surface = new StringBuilder();
+        StringBuilder tv = new StringBuilder();
+
+        int tokIdx = 0;
+        int charOff = 0;
+
+        for (int si = 0; si < doc.sentences.size(); si++) {
+            SentenceInfo sent = doc.sentences.get(si);
+            int sentLen = sent.endPos - sent.startPos;
+
+            int sentCharStart = charOff;
+            int sentCharEnd = sentCharStart;
+            for (int ti = 0; ti < sentLen; ti++) {
+                sentCharEnd += allTokens[tokIdx + ti].length();
+            }
+
+            for (int ti = 0; ti < sentLen; ti++) {
+                String tok = allTokens[tokIdx + ti];
+                int cStart = charOff;
+                int cEnd = charOff + tok.length();
+                surface.append(tok);
+
+                tv.append("[(").append(cStart).append("-").append(cEnd)
+                        .append(")");
+                tv.append("s:").append(tok);
+
+                if (tokIdx + ti == 0) {
+                    tv.append("|-:t$<i>").append(totalTokens);
+                    tv.append("|<>:base/s:t$<b>64<i>0<i>")
+                            .append(totalChars).append("<i>")
+                            .append(totalTokens).append("<b>0");
+                }
+
+                if (ti == 0) {
+                    tv.append("|<>:base/s:s$<b>64<i>")
+                            .append(sentCharStart).append("<i>")
+                            .append(sentCharEnd).append("<i>")
+                            .append(sent.endPos).append("<b>1");
+                }
+
+                tv.append("|_").append(tokIdx + ti).append("$<i>")
+                        .append(cStart).append("<i>").append(cEnd);
+                tv.append("]");
+
+                charOff = cEnd;
+            }
+            tokIdx += sentLen;
+        }
+
+        fd.addTV("tokens", surface.toString(), tv.toString());
+        return fd;
+    }
+
+    // focus(1: contains(contains(<s>, {1: a}), {1: b}))
+    private SpanQuery nestedContainsSameClassQuery () {
+        SpanQuery sentence = new SpanElementQuery("tokens", "base/s:s");
+        SpanQuery tokenA = new SpanClassQuery(
+                new SpanTermQuery(new Term("tokens", "s:a")), (byte) 1);
+        SpanQuery tokenB = new SpanClassQuery(
+                new SpanTermQuery(new Term("tokens", "s:b")), (byte) 1);
+        SpanQuery innerContains = new SpanWithinQuery(sentence, tokenA);
+        SpanQuery outerContains = new SpanWithinQuery(innerContains, tokenB);
+        SpanFocusQuery focus = new SpanFocusQuery(outerContains, (byte) 1);
+        focus.setSorted(false);
+        return focus;
+    }
+
+    // Collect all a positions from sentences that also contain b.
+    private Set<Integer> expectedAPositions (DocInfo doc) {
+        Set<Integer> result = new TreeSet<>();
+        for (SentenceInfo sent : doc.sentences) {
+            if (sent.hasBoth()) {
+                result.addAll(sent.aPositions);
+            }
+        }
+        return result;
+    }
+
+    // Collect all a positions in one generated document.
+    private Set<Integer> allAPositions (DocInfo doc) {
+        Set<Integer> result = new TreeSet<>();
+        for (SentenceInfo sent : doc.sentences) {
+            result.addAll(sent.aPositions);
+        }
+        return result;
+    }
+
+    // Collect all a and b positions in one generated document.
+    private Set<Integer> allABPositions (DocInfo doc) {
+        Set<Integer> result = new TreeSet<>();
+        for (SentenceInfo sent : doc.sentences) {
+            result.addAll(sent.aPositions);
+            result.addAll(sent.bPositions);
+        }
+        return result;
+    }
+
+    // Check invariants for focus(class1) over different class ids.
+    private void verifyDiffClassInvariants (Result kr,
+            IndexConfig config, String context) {
+
+        Set<Integer> anyDocExpectedA = new TreeSet<>();
+        for (DocInfo doc : config.docs) {
+            anyDocExpectedA.addAll(expectedAPositions(doc));
+        }
+
+        Set<Integer> matchedPositions = new TreeSet<>();
+        int storedMatches = kr.getMatches().size();
+
+        for (int i = 0; i < storedMatches; i++) {
+            Match m = kr.getMatch(i);
+            int mStart = m.getStartPos();
+            int mEnd = m.getEndPos();
+
+            assertTrue(context + ": Match " + i + " [" + mStart + ","
+                    + mEnd + ") must be single token (end==start+1)",
+                    mEnd == mStart + 1);
+
+            boolean validInSomeDoc = false;
+            for (DocInfo doc : config.docs) {
+                if (allAPositions(doc).contains(mStart)
+                        && expectedAPositions(doc).contains(mStart)) {
+                    validInSomeDoc = true;
+                    break;
+                }
+            }
+            assertTrue(context + ": Match " + i + " start=" + mStart
+                    + " must be a valid 'a' in a sentence with 'b'",
+                    validInSomeDoc);
+
+            matchedPositions.add(mStart);
+        }
+
+        if (config.docs.size() == 1
+                && kr.getTotalResults() == storedMatches) {
+            Set<Integer> expected = expectedAPositions(config.docs.get(0));
+            for (int pos : expected) {
+                assertTrue(context + ": Expected 'a' at position "
+                        + pos + " not found in results",
+                        matchedPositions.contains(pos));
+            }
+        }
+    }
+
+    // Check invariants for focus(class1) with shared class ids.
+    private void verifySameClassInvariants (Result kr,
+            IndexConfig config, String context) {
+
+        int storedMatches = kr.getMatches().size();
+        for (int i = 0; i < storedMatches; i++) {
+            Match m = kr.getMatch(i);
+            int mStart = m.getStartPos();
+            int mEnd = m.getEndPos();
+
+            boolean validInSomeDoc = false;
+            for (DocInfo doc : config.docs) {
+                Set<Integer> abPos = allABPositions(doc);
+
+                if (!abPos.contains(mStart)) continue;
+                if (!abPos.contains(mEnd - 1)) continue;
+
+                boolean withinSentence = false;
+                boolean sentHasBoth = false;
+                for (SentenceInfo sent : doc.sentences) {
+                    if (mStart >= sent.startPos && mEnd <= sent.endPos) {
+                        withinSentence = true;
+                        sentHasBoth = sent.hasBoth();
+                        break;
+                    }
+                }
+                if (withinSentence && sentHasBoth) {
+                    validInSomeDoc = true;
+                    break;
+                }
+            }
+
+            assertTrue(context + ": Match " + i + " [" + mStart + ","
+                    + mEnd + ") failed same-class invariants",
+                    validInSomeDoc);
+        }
+    }
+
+    // Check diff-class invariants after virtual corpus filtering.
+    private void verifyDiffClassInvariantsFiltered (Result kr,
+            IndexConfig config, Set<String> includedSigles,
+            String context) {
+
+        int storedMatches = kr.getMatches().size();
+        for (int i = 0; i < storedMatches; i++) {
+            Match m = kr.getMatch(i);
+            int mStart = m.getStartPos();
+            int mEnd = m.getEndPos();
+
+            assertTrue(context + ": Match " + i + " [" + mStart + ","
+                    + mEnd + ") must be single token",
+                    mEnd == mStart + 1);
+
+            boolean validInFilteredDoc = false;
+            for (DocInfo doc : config.docs) {
+                if (!includedSigles.contains(doc.textSigle)) continue;
+                if (expectedAPositions(doc).contains(mStart)) {
+                    validInFilteredDoc = true;
+                    break;
+                }
+            }
+            assertTrue(context + ": Match " + i + " start=" + mStart
+                    + " must be valid 'a' in filtered docs",
+                    validInFilteredDoc);
+        }
+    }
+
+    // Function type used by fuzzLoop for one seeded iteration.
+    @FunctionalInterface
+    interface FuzzAction {
+        void run (long seed) throws Exception;
+    }
+
+    // Run a fuzz action over N seeds and print reproducible failures.
+    private void fuzzLoop (String testName, FuzzAction action) {
+        int iterations = getFuzzIterations();
+        assumeTrue("Fuzz tests skipped (set -Dfuzz.iterations=N to run)",
+                iterations > 0);
+        long baseSeed = getFuzzBaseSeed();
+
+        for (int i = 0; i < iterations; i++) {
+            long seed = baseSeed + i;
+            try {
+                action.run(seed);
+            }
+            catch (AssertionError e) {
+                throw new AssertionError(
+                        "FUZZ FAILURE [" + testName + "] iteration="
+                        + i + " seed=" + seed + "\n"
+                        + "Reproduce: -Dfuzz.seed=" + seed
+                        + " -Dfuzz.iterations=1", e);
+            }
+            catch (Exception e) {
+                throw new AssertionError(
+                        "FUZZ ERROR [" + testName + "] iteration="
+                        + i + " seed=" + seed, e);
+            }
+        }
+    }
+
+    // Fuzz test: class1 focus with inner class1 and outer class2.
+    @Test
+    public void fuzzNestedContainsDiffClass () {
+        fuzzLoop("fuzzNestedContainsDiffClass", seed -> {
+            IndexConfig config = generateFuzzConfig(seed);
+            KrillIndex ki = buildFuzzIndex(config);
+            try {
+                Result kr = ki.search(
+                        nestedContainsDiffClassQuery(), (short) 500);
+                verifyDiffClassInvariants(kr, config,
+                        "seed=" + seed + " strategy=" + config.strategy);
+            }
+            finally {
+                ki.close();
+            }
+        });
+    }
+
+    // Fuzz test: class1 focus with both terms in class1.
+    @Test
+    public void fuzzNestedContainsSameClass () {
+        fuzzLoop("fuzzNestedContainsSameClass", seed -> {
+            IndexConfig config = generateFuzzConfig(seed);
+            KrillIndex ki = buildFuzzIndex(config);
+            try {
+                Result kr = ki.search(
+                        nestedContainsSameClassQuery(), (short) 500);
+                verifySameClassInvariants(kr, config,
+                        "seed=" + seed + " strategy=" + config.strategy);
+            }
+            finally {
+                ki.close();
+            }
+        });
+    }
+
+    // Fuzz test: force one commit per doc to maximize segment count.
+    @Test
+    public void fuzzNestedContainsMultiSegment () {
+        fuzzLoop("fuzzNestedContainsMultiSegment", seed -> {
+            Random rng = new Random(seed);
+            BiasStrategy strategy = BiasStrategy.values()[
+                    rng.nextInt(BiasStrategy.values().length)];
+            IndexConfig config = generateFuzzConfig(seed, rng, strategy);
+
+            int numDocs = config.docs.size();
+            int[] manyCommits = new int[numDocs];
+            for (int d = 0; d < numDocs; d++) manyCommits[d] = d;
+            IndexConfig multiSegConfig = new IndexConfig(
+                    config.docs, manyCommits, seed, strategy);
+
+            KrillIndex ki = buildFuzzIndex(multiSegConfig);
+            try {
+                Result kr = ki.search(
+                        nestedContainsDiffClassQuery(), (short) 500);
+                verifyDiffClassInvariants(kr, multiSegConfig,
+                        "seed=" + seed + " strategy=" + strategy);
+            }
+            finally {
+                ki.close();
+            }
+        });
+    }
+
+    // Fuzz test: run diff-class query on a filtered virtual corpus.
+    @Test
+    public void fuzzNestedContainsVirtualCorpus () {
+        fuzzLoop("fuzzNestedContainsVirtualCorpus", seed -> {
+            IndexConfig config = generateFuzzConfig(seed);
+            if (config.docs.size() < 2) return;
+
+            KrillIndex ki = buildFuzzIndex(config);
+            try {
+                String targetSigle = config.docs.get(0).textSigle;
+                Set<String> included = new HashSet<>();
+                included.add(targetSigle);
+
+                Krill ks = new Krill(nestedContainsDiffClassQuery());
+                ks.getMeta().setCount((short) 500);
+
+                CollectionBuilder cb = new CollectionBuilder();
+                KrillCollection kc = new KrillCollection(ki);
+                kc.fromBuilder(cb.term("textSigle", targetSigle));
+                ks.setCollection(kc);
+
+                Result kr = ks.apply(ki);
+                verifyDiffClassInvariantsFiltered(kr, config, included,
+                        "seed=" + seed + " vc_sigle=" + targetSigle);
+            }
+            finally {
+                ki.close();
+            }
+        });
+    }
+
+    // Fuzz test: heavy backtracking setup with many a and rare b.
+    @Test
+    public void fuzzNestedContainsHeavyBacktracking () {
+        fuzzLoop("fuzzNestedContainsHeavyBacktracking", seed -> {
+            Random rng = new Random(seed);
+            IndexConfig config = generateFuzzConfig(seed, rng,
+                    BiasStrategy.HEAVY_A_RARE_B);
+            KrillIndex ki = buildFuzzIndex(config);
+            try {
+                Result kr = ki.search(
+                        nestedContainsDiffClassQuery(), (short) 500);
+                verifyDiffClassInvariants(kr, config,
+                        "seed=" + seed + " strategy=HEAVY_A_RARE_B");
+            }
+            finally {
+                ki.close();
+            }
+        });
+    }
+
+    // Fuzz test: dense setup where almost every sentence has a and b.
+    @Test
+    public void fuzzNestedContainsDense () {
+        fuzzLoop("fuzzNestedContainsDense", seed -> {
+            Random rng = new Random(seed);
+            IndexConfig config = generateFuzzConfig(seed, rng,
+                    BiasStrategy.DENSE);
+            KrillIndex ki = buildFuzzIndex(config);
+            try {
+                Result kr = ki.search(
+                        nestedContainsDiffClassQuery(), (short) 500);
+                verifyDiffClassInvariants(kr, config,
+                        "seed=" + seed + " strategy=DENSE");
+            }
+            finally {
+                ki.close();
+            }
+        });
+    }
 };
diff --git a/src/test/java/de/ids_mannheim/korap/search/TestVcField.java b/src/test/java/de/ids_mannheim/korap/search/TestVcField.java
index 1a4ed72..f843bef 100644
--- a/src/test/java/de/ids_mannheim/korap/search/TestVcField.java
+++ b/src/test/java/de/ids_mannheim/korap/search/TestVcField.java
@@ -24,6 +24,8 @@
         FieldDocument fd = new FieldDocument();
         fd.addString("textSigle", textSigle);
         fd.setUID(uid);
+        fd.addTV("tokens", "x",
+            "[(0-1)s:x|_0$<i>0<i>1|-:tokens$<i>1]");
         return fd;
     }