Preserve negation of a single operand in a VC (fixes #178)

Change-Id: I894618d6fa610804949394580905c51fde8535fc
diff --git a/Changes b/Changes
index a522f7a..7ad8452 100644
--- a/Changes
+++ b/Changes
@@ -1,4 +1,4 @@
-0.64.7 2026-04-28
+0.64.7 2026-04-29
     - [bugfix] Keep highlights that extend beyond a cut match
       (diewald; fixes #177; diewald; AI-assisted Claude Opus 4.6)
     - [bugfix] Correctly handle foundry and layer in attribute groups
@@ -10,6 +10,8 @@
       (fixes #121; diewald; AI-assisted Claude Opus 4.6)
     - [bugfix] Fix regex alternation in non-enclosed groups
       (fixes #243; diewald; AI-assisted Claude Opus 4.6)
+    - [bugfix] Preserve negation of a single operand inside a
+      collection group (fixes #178; diewald; AI-assisted Claude Opus 4.6))
 
 0.64.6 2026-03-09
     - [performance] Add leaf cache. (diewald)
diff --git a/src/main/java/de/ids_mannheim/korap/collection/CollectionBuilder.java b/src/main/java/de/ids_mannheim/korap/collection/CollectionBuilder.java
index 4464302..527557e 100644
--- a/src/main/java/de/ids_mannheim/korap/collection/CollectionBuilder.java
+++ b/src/main/java/de/ids_mannheim/korap/collection/CollectionBuilder.java
@@ -382,7 +382,11 @@
             if (this.operands == null || this.operands.isEmpty())
                 return null;
 
-            if (this.operands.size() == 1)
+            // Shortcut for a single positive operand. A negative
+            // operand must go through the BooleanGroupFilter so that
+            // its negation is preserved.
+            if (this.operands.size() == 1
+                    && !this.operands.get(0).isNegative())
                 return this.operands.get(0).toFilter();
 
             // BooleanFilter bool = new BooleanFilter();
diff --git a/src/test/java/de/ids_mannheim/korap/collection/TestCollectionBuilder.java b/src/test/java/de/ids_mannheim/korap/collection/TestCollectionBuilder.java
index c9d47ea..6e00612 100644
--- a/src/test/java/de/ids_mannheim/korap/collection/TestCollectionBuilder.java
+++ b/src/test/java/de/ids_mannheim/korap/collection/TestCollectionBuilder.java
@@ -220,14 +220,18 @@
         assertEquals("author:tree", kbi.toString());
         assertTrue(kbi.isNegative());
 
+        // A negative single operand inside a group must keep its
+        // negation. The outer .not() then negates
+        // again, semantically yielding a positive `author:tree`,
+        // expressed as a negated group around a negative operand.
         kbi = kc.andGroup().with(kc.term("author", "tree").not());
         kbi.not();
-        assertEquals("author:tree", kbi.toString());
+        assertEquals("AndGroup(-author:tree)", kbi.toString());
         assertTrue(kbi.isNegative());
 
         kbi = kc.orGroup().with(kc.term("author", "tree").not());
         kbi.not();
-        assertEquals("author:tree", kbi.toString());
+        assertEquals("OrGroup(-author:tree)", kbi.toString());
         assertTrue(kbi.isNegative());
     };
 
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 5877547..8b5fcef 100644
--- a/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionIndex.java
+++ b/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionIndex.java
@@ -552,6 +552,80 @@
 
 
     @Test
+    public void testIndexWithNegativeYearDateRangeIssue178 () throws IOException {
+        ki = new KrillIndex();
+        ki.addDoc(createDoc1());
+        ki.addDoc(createDoc2());
+        ki.addDoc(createDoc3());
+        ki.commit();
+        CollectionBuilder cb = new CollectionBuilder();
+        KrillCollection kcn = new KrillCollection(ki);
+
+        // All three docs are in 2005, so positive match returns 3
+        kcn.fromBuilder(cb.date("pubDate", "2005"));
+        assertEquals(3, kcn.docCount());
+
+        // Negation must exclude all docs in 2005
+        kcn.fromBuilder(cb.date("pubDate", "2005").not());
+        assertEquals(0, kcn.docCount());
+
+        // Negation of a year that does not contain any doc keeps all docs
+        kcn.fromBuilder(cb.date("pubDate", "2006").not());
+        assertEquals(3, kcn.docCount());
+
+        // Month check
+        kcn.fromBuilder(cb.date("pubDate", "2005-12"));
+        assertEquals(3, kcn.docCount());
+
+        // Negation of a month
+        kcn.fromBuilder(cb.date("pubDate", "2005-12").not());
+        assertEquals(0, kcn.docCount());
+
+        // Day check
+        kcn.fromBuilder(cb.date("pubDate", "2005-12-10"));
+        assertEquals(1, kcn.docCount());
+
+        // Negation of a day
+        kcn.fromBuilder(cb.date("pubDate", "2005-12-10").not());
+        assertEquals(2, kcn.docCount());
+
+
+        // Negation via the JSON KoralQuery path
+        String json = "{\"collection\":{\"@type\":\"koral:doc\","
+                + "\"key\":\"pubDate\",\"type\":\"type:date\","
+                + "\"value\":\"2005\",\"match\":\"match:ne\"}}";
+        KrillCollection kc = new KrillCollection(json);
+        kc.setIndex(ki);
+        assertEquals(0, kc.docCount());
+
+        // Negation as a single-operand docGroup (this is the actual
+        // problematic case described in issue #178).
+        String groupJson = "{\"collection\":{\"@type\":\"koral:docGroup\","
+                + "\"operation\":\"operation:and\",\"operands\":[{"
+                + "\"@type\":\"koral:doc\",\"key\":\"pubDate\","
+                + "\"type\":\"type:date\",\"value\":\"2005\","
+                + "\"match\":\"match:ne\"}]}}";
+        KrillCollection kcGroup = new KrillCollection(groupJson);
+        kcGroup.setIndex(ki);
+        assertEquals(0, kcGroup.docCount());
+
+        // Negation combined with another (positive) operand in an
+        // AndGroup must still exclude matching dates.
+        String mixJson = "{\"collection\":{\"@type\":\"koral:docGroup\","
+                + "\"operation\":\"operation:and\",\"operands\":[{"
+                + "\"@type\":\"koral:doc\",\"key\":\"author\","
+                + "\"type\":\"type:string\",\"value\":\"Frank\","
+                + "\"match\":\"match:eq\"},{"
+                + "\"@type\":\"koral:doc\",\"key\":\"pubDate\","
+                + "\"type\":\"type:date\",\"value\":\"2005\","
+                + "\"match\":\"match:ne\"}]}}";
+        KrillCollection kcMix = new KrillCollection(mixJson);
+        kcMix.setIndex(ki);
+        assertEquals(0, kcMix.docCount());
+    };
+
+
+    @Test
     public void testIndexWithRegexes () throws IOException {
         ki = new KrillIndex();
 
diff --git a/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionJSON.java b/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionJSON.java
index 9804ac5..4846a43 100644
--- a/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionJSON.java
+++ b/src/test/java/de/ids_mannheim/korap/collection/TestKrillCollectionJSON.java
@@ -101,6 +101,17 @@
     };
 
     @Test
+    public void collectionWithNegativeDateYear () {
+        // pubDate ne 2017 must serialize to a Lucene query that excludes
+        // documents whose pubDate is in 2017.
+        String json = "{\"collection\":{\"@type\":\"koral:doc\","
+                + "\"key\":\"pubDate\",\"type\":\"type:date\","
+                + "\"value\":\"2017\",\"match\":\"match:ne\"}}";
+        KrillCollection kc = new KrillCollection(json);
+        assertEquals("-pubDate:[20170000 TO 20179999]", kc.toString());
+    };
+
+    @Test
     public void collectionWithMultipleNe () {
         String metaQuery = _getJSONString("collection_multine.jsonld");
         KrillCollection kc = new KrillCollection(metaQuery);