You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by jp...@apache.org on 2025/02/10 22:00:42 UTC

(lucene) branch branch_10x updated: Make Operations#union merge accept states that have no outgoing transition. (#14207)

This is an automated email from the ASF dual-hosted git repository.

jpountz pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/lucene.git


The following commit(s) were added to refs/heads/branch_10x by this push:
     new 5e3fdaf66c4 Make Operations#union merge accept states that have no outgoing transition. (#14207)
5e3fdaf66c4 is described below

commit 5e3fdaf66c4b6e2fa2a0aaafed0432d458303e16
Author: Adrien Grand <jp...@gmail.com>
AuthorDate: Mon Feb 10 22:55:07 2025 +0100

    Make Operations#union merge accept states that have no outgoing transition. (#14207)
    
    This helps generate simpler automata, especially when these automata are later
    combined through other operations such as `Operations#concat`.
---
 .../apache/lucene/util/automaton/Operations.java   | 78 +++++++++++++++++++++-
 .../lucene/util/automaton/TestOperations.java      | 48 +++++++++++++
 2 files changed, 125 insertions(+), 1 deletion(-)

diff --git a/lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java b/lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java
index 3a87fe40733..fdd6242abcc 100644
--- a/lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java
+++ b/lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java
@@ -516,7 +516,7 @@ public final class Operations {
 
     result.finishState();
 
-    return removeDeadStates(result);
+    return mergeAcceptStatesWithNoTransition(removeDeadStates(result));
   }
 
   // Simple custom ArrayList<Transition>
@@ -1060,6 +1060,82 @@ public final class Operations {
     return result;
   }
 
+  /**
+   * Merge all accept states that don't have outgoing transitions to a single shared state. This is
+   * a subset of minimization that is much cheaper. This helper is useful because operations like
+   * concatenation need to connect accept states of an automaton with the start state of the next
+   * one, so having fewer accept states makes the produced automata simpler.
+   */
+  static Automaton mergeAcceptStatesWithNoTransition(Automaton a) {
+    int numStates = a.getNumStates();
+
+    int numAcceptStatesWithNoTransition = 0;
+    int[] acceptStatesWithNoTransition = new int[0];
+
+    BitSet acceptStates = a.getAcceptStates();
+    for (int i = 0; i < numStates; ++i) {
+      if (acceptStates.get(i) && a.getNumTransitions(i) == 0) {
+        acceptStatesWithNoTransition =
+            ArrayUtil.grow(acceptStatesWithNoTransition, 1 + numAcceptStatesWithNoTransition);
+        acceptStatesWithNoTransition[numAcceptStatesWithNoTransition++] = i;
+      }
+    }
+
+    if (numAcceptStatesWithNoTransition <= 1) {
+      // No states to merge
+      return a;
+    }
+
+    // Shrink for simplicity.
+    acceptStatesWithNoTransition =
+        ArrayUtil.copyOfSubArray(acceptStatesWithNoTransition, 0, numAcceptStatesWithNoTransition);
+
+    // Now copy states, preserving accept states.
+    Automaton result = new Automaton();
+    for (int s = 0; s < numStates; s++) {
+      int remappedS = remap(s, acceptStatesWithNoTransition);
+      while (result.getNumStates() <= remappedS) {
+        result.createState();
+      }
+      if (acceptStates.get(s)) {
+        result.setAccept(remappedS, true);
+      }
+    }
+
+    // Now copy transitions, making sure to remap states.
+    Transition t = new Transition();
+    for (int s = 0; s < numStates; ++s) {
+      int remappedSource = remap(s, acceptStatesWithNoTransition);
+      int numTransitions = a.initTransition(s, t);
+      for (int j = 0; j < numTransitions; j++) {
+        a.getNextTransition(t);
+        int remappedDest = remap(t.dest, acceptStatesWithNoTransition);
+        result.addTransition(remappedSource, remappedDest, t.min, t.max);
+      }
+    }
+
+    result.finishState();
+    return result;
+  }
+
+  private static int remap(int s, int[] combinedStates) {
+    int idx = Arrays.binarySearch(combinedStates, s);
+    if (idx >= 0) {
+      // This state is part of the states that get combined, remap to the first one.
+      return combinedStates[0];
+    } else {
+      idx = -1 - idx;
+      if (idx <= 1) {
+        // There is either no combined state before the current state, or only the first one, which
+        // we're preserving: no renumbering needed.
+        return s;
+      } else {
+        // Subtract the number of states that get combined into the first combined state.
+        return s - (idx - 1);
+      }
+    }
+  }
+
   /**
    * Returns the longest string that is a prefix of all accepted strings and visits each state at
    * most once. The automaton must not have dead states. If this automaton has already been
diff --git a/lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java b/lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java
index c6fde722290..7fb1d679946 100644
--- a/lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java
+++ b/lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java
@@ -374,6 +374,54 @@ public class TestOperations extends LuceneTestCase {
             Operations.determinize(Operations.repeat(aOrAb), Integer.MAX_VALUE)));
   }
 
+  public void testMergeAcceptStatesWithNoTransition() {
+    Automaton emptyLanguage = Automata.makeEmpty();
+    assertSame(emptyLanguage, Operations.mergeAcceptStatesWithNoTransition(emptyLanguage));
+
+    Automaton a = Automata.makeString("a");
+    assertSame(a, Operations.mergeAcceptStatesWithNoTransition(a));
+
+    // All accept states get combined
+    Automaton aOrC = new Automaton();
+    aOrC.createState();
+    aOrC.createState();
+    aOrC.createState();
+    aOrC.addTransition(0, 1, 'a');
+    aOrC.setAccept(1, true);
+    aOrC.addTransition(0, 2, 'c');
+    aOrC.setAccept(2, true);
+    Automaton aOrCSingleAcceptState = Operations.mergeAcceptStatesWithNoTransition(aOrC);
+    assertEquals(1, aOrCSingleAcceptState.getAcceptStates().cardinality());
+    assertTrue(AutomatonTestUtil.sameLanguage(aOrC, aOrCSingleAcceptState));
+
+    // Two accept states get combined, but not the 3rd one since it has an outgoing transition
+    Automaton aOrCOrXStar = new Automaton();
+    aOrCOrXStar.createState();
+    aOrCOrXStar.createState();
+    aOrCOrXStar.createState();
+    aOrCOrXStar.createState();
+    aOrCOrXStar.addTransition(0, 1, 'a');
+    aOrCOrXStar.setAccept(1, true);
+    aOrCOrXStar.addTransition(0, 2, 'c');
+    aOrCOrXStar.setAccept(2, true);
+    aOrCOrXStar.addTransition(0, 3, 'x');
+    aOrCOrXStar.addTransition(3, 3, 'x');
+    aOrCOrXStar.setAccept(3, true);
+    Automaton aOrCOrXStarSingleAcceptState =
+        Operations.mergeAcceptStatesWithNoTransition(aOrCOrXStar);
+    assertEquals(2, aOrCOrXStarSingleAcceptState.getAcceptStates().cardinality());
+    assertTrue(AutomatonTestUtil.sameLanguage(aOrCOrXStar, aOrCOrXStarSingleAcceptState));
+
+    int iters = atLeast(100);
+    for (int iter = 0; iter < iters; iter++) {
+      // sameLangage requires a deterministic automaton
+      Automaton expected =
+          Operations.determinize(AutomatonTestUtil.randomAutomaton(random()), Integer.MAX_VALUE);
+      Automaton actual = Operations.mergeAcceptStatesWithNoTransition(expected);
+      assertTrue(AutomatonTestUtil.sameLanguage(expected, actual));
+    }
+  }
+
   public void testDuelRepeat() {
     final int iters = atLeast(1_000);
     for (int iter = 0; iter < iters; ++iter) {