This is an automated email from the ASF dual-hosted git repository.
krickert pushed a commit to branch OPENNLP-1903-NameFinder-Threading
in repository https://gitbox.apache.org/repos/asf/opennlp.git
The following commit(s) were added to
refs/heads/OPENNLP-1903-NameFinder-Threading by this push:
new b7dc4018f OPENNLP-1903: Tighten the BeamSearch chain-node change after
review pre-flight
b7dc4018f is described below
commit b7dc4018f706732c9a7662c5dcc2eb9b0c2e6034
Author: Kristian Rickert <[email protected]>
AuthorDate: Mon Aug 3 07:43:45 2026 -0400
OPENNLP-1903: Tighten the BeamSearch chain-node change after review
pre-flight
Simplify SearchNode by dropping the single-use outcome array cache, share
one seeded MaxentModel fixture between the equivalence test and the JMH
benchmark, convert the equivalence test matrices to parameterized tests,
and anchor benchmark/test documentation to the JIRA issue instead of
relative history. Attribute the real-model benchmark result to the
en-ner-person.bin model.
---
.../src/main/java/opennlp/tools/ml/BeamSearch.java | 51 ++-
opennlp-core/opennlp-runtime/BENCHMARKS.md | 18 +-
.../java/opennlp/tools/ml/BeamSearchBenchmark.java | 105 +----
.../tools/ml/BeamSearchEquivalenceTest.java | 495 ++++++++++-----------
.../java/opennlp/tools/ml/SeededMaxentModel.java | 120 +++++
5 files changed, 404 insertions(+), 385 deletions(-)
diff --git
a/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/BeamSearch.java
b/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/BeamSearch.java
index f698ec495..155d96da2 100644
---
a/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/BeamSearch.java
+++
b/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/BeamSearch.java
@@ -75,14 +75,14 @@ public class BeamSearch implements
SequenceClassificationModel, AutoCloseable {
}
/**
- * Immutable chain node used only inside
+ * Immutable node in a backward-linked chain of outcome candidates, used
only inside
* {@link #bestSequences(int, Object[], Object[], double,
BeamSearchContextGenerator, SequenceValidator)}.
- * Each child links to its parent instead of copying the parent's
outcome/probability lists,
- * so expanding a candidate is O(1); the outcome array is materialized
lazily and cached.
- * Score accumulation ({@code parent.score + StrictMath.log(prob)}) mirrors
- * {@link Sequence#Sequence(Sequence, String, double)} bit-for-bit, and
{@link #compareTo}
- * mirrors {@link Sequence#compareTo}, so the search is behavior-identical
to running the
- * queues over {@link Sequence} directly.
+ * A node stores its own outcome plus a parent link, so extending a
candidate is O(1);
+ * the full outcome array is materialized only when a candidate is expanded.
+ * Score accumulation ({@code parent.score + StrictMath.log(prob)}) and
+ * {@link #compareTo(SearchNode)} must stay in lockstep with
+ * {@link Sequence#Sequence(Sequence, String, double)} and {@link
Sequence#compareTo(Sequence)}
+ * to keep search results bit-identical to a search over {@link Sequence}
instances.
*/
private static final class SearchNode implements Comparable<SearchNode> {
private final SearchNode parent;
@@ -90,8 +90,10 @@ public class BeamSearch implements
SequenceClassificationModel, AutoCloseable {
private final double prob;
private final double score;
private final int size;
- private String[] outcomesCache; // lazily built; nodes are never mutated,
so the cache stays valid
+ /**
+ * Creates the root node: the empty candidate with score {@code 0}.
+ */
private SearchNode() {
this.parent = null;
this.outcome = null;
@@ -100,6 +102,13 @@ public class BeamSearch implements
SequenceClassificationModel, AutoCloseable {
this.size = 0;
}
+ /**
+ * Creates a candidate extending {@code parent} by one outcome.
+ *
+ * @param parent The candidate to extend. Must not be {@code null}.
+ * @param outcome The outcome to append.
+ * @param prob The probability of {@code outcome}.
+ */
private SearchNode(SearchNode parent, String outcome, double prob) {
this.parent = parent;
this.outcome = outcome;
@@ -108,20 +117,22 @@ public class BeamSearch implements
SequenceClassificationModel, AutoCloseable {
this.size = parent.size + 1;
}
+ /**
+ * @return The outcomes on the path from the root to this node, in
sequence order.
+ */
private String[] outcomes() {
- String[] cached = outcomesCache;
- if (cached == null) {
- cached = new String[size];
- SearchNode node = this;
- for (int i = size - 1; i >= 0; i--) {
- cached[i] = node.outcome;
- node = node.parent;
- }
- outcomesCache = cached;
+ final String[] outcomes = new String[size];
+ SearchNode node = this;
+ for (int i = size - 1; i >= 0; i--) {
+ outcomes[i] = node.outcome;
+ node = node.parent;
}
- return cached;
+ return outcomes;
}
+ /**
+ * Orders nodes by descending score, mirroring {@link
Sequence#compareTo(Sequence)}.
+ */
@Override
public int compareTo(SearchNode other) {
return Double.compare(other.score, this.score);
@@ -250,8 +261,8 @@ public class BeamSearch implements
SequenceClassificationModel, AutoCloseable {
probs[j] = node.prob;
node = node.parent;
}
- // Sequence.add accumulates score += StrictMath.log(p) in the same order
as the chain,
- // so the rebuilt Sequence is bit-identical to one built directly during
the search.
+ // Sequence.add accumulates score += StrictMath.log(p) per element, so
rebuilding in
+ // chain order yields a score bit-identical to the node's accumulated
score.
final Sequence seq = new Sequence();
for (int j = 0; j < outs.length; j++) {
seq.add(outs[j], probs[j]);
diff --git a/opennlp-core/opennlp-runtime/BENCHMARKS.md
b/opennlp-core/opennlp-runtime/BENCHMARKS.md
index 6e6f8aa9c..5ba39bc50 100644
--- a/opennlp-core/opennlp-runtime/BENCHMARKS.md
+++ b/opennlp-core/opennlp-runtime/BENCHMARKS.md
@@ -129,11 +129,12 @@ cache provides measurable benefit.
validator). One op = one `bestSequence` call; invocations rotate through a
pool of 64 seeded inputs. The `sequenceLength` param (8/64/256) is the key
axis: the O(n^2) per-candidate outcome-list copying that the chain-node
-refactor eliminated only shows up on long sequences (NER chunks run 200+
-tokens). The `cacheSize` param (0/64) toggles the contexts cache. To produce
-the pre-refactor baseline, swap the pre-refactor `opennlp-ml-commons` jar
-onto the classpath ahead of the freshly built classes — the benchmark only
-uses the unchanged public API — and rerun.
+refactor (OPENNLP-1903) eliminated only shows up on long sequences (NER
+chunks run 200+ tokens). The `cacheSize` param (0/64) toggles the contexts
+cache. To produce the pre-refactor baseline, place an `opennlp-ml-commons`
+jar built before OPENNLP-1903 on the classpath ahead of the freshly built
+classes and rerun; the benchmark only uses public API that predates the
+refactor.
Results (Linux, JDK 25, 24 pinned cores of a 32-core box, 2 forks x 10
iterations; ops/s = decoded sequences per second):
@@ -153,9 +154,9 @@ iterations; ops/s = decoded sequences per second):
| post-refactor | 64 | 64 | 34,818 ± 232 | 347,266 ± 942 | 10.0x |
| post-refactor | 256 | 64 | 4,308 ± 27 | 50,203 ± 230 | 11.7x |
-Two readings. First, the refactor's win grows with sequence length —
+Two readings. First, the refactor's win grows with sequence length:
~2x single-thread at every length, and at 24 threads 2.3x (len 8),
-4.4x (len 64), and 9.1x (len 256) — the signature of an O(n^2) copy
+4.4x (len 64), and 9.1x (len 256), the signature of an O(n^2) copy
cost being removed. Second, the pre-refactor scaling collapses as
sequences lengthen (6.2x down to 2.5x on 24 threads: memory-write
saturation from per-candidate list copying), while post-refactor
@@ -166,7 +167,8 @@ the sequence-mechanics cost the refactor targets; with a
real maxent
model the eval compute is shared by both versions and the relative
single-thread win is smaller (~1.3x on ARM, roughly neutral on x86),
while the concurrent scaling gap is preserved (measured 5.8x -> 13.3x
-at saturation on 24 pinned cores with a real NER model).
+at saturation on 24 pinned cores with the SourceForge-era 1.5 English
+`en-ner-person.bin` model).
## JUnit Correctness Test
diff --git
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/ml/BeamSearchBenchmark.java
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/ml/BeamSearchBenchmark.java
index 85d77fe8a..18e2f8212 100644
---
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/ml/BeamSearchBenchmark.java
+++
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/ml/BeamSearchBenchmark.java
@@ -41,7 +41,6 @@ import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;
-import opennlp.tools.ml.model.MaxentModel;
import opennlp.tools.util.BeamSearchContextGenerator;
import opennlp.tools.util.SequenceValidator;
@@ -49,15 +48,15 @@ import opennlp.tools.util.SequenceValidator;
* JMH benchmark for {@link BeamSearch} on long input sequences.
* <p>
* One op = one {@code bestSequence} call on a synthetic token sequence of
- * {@code sequenceLength} tokens. Long sequences are what expose the O(n^2)
- * per-candidate outcome-list copying that the chain-node refactor eliminated;
- * the 5-10 token sentences used by the ME benchmarks would show nothing.
- * Only the pre-refactor public API is used
+ * {@code sequenceLength} tokens. Long sequences are what expose the quadratic
+ * per-candidate outcome-list copying removed with OPENNLP-1903; sentences of
+ * 5-10 tokens, as used by the ME benchmarks, show no measurable difference.
+ * Only public API that predates OPENNLP-1903 is used
* ({@code BeamSearch(int, MaxentModel, int)} and
* {@code bestSequence(T[], Object[], BeamSearchContextGenerator,
SequenceValidator)}),
- * so the same compiled class exercises both implementations: to produce the
- * baseline, swap the pre-refactor {@code opennlp-ml-commons} jar onto the
- * classpath ahead of the freshly built classes and rerun.
+ * so the same compiled class exercises both implementations: to produce a
+ * baseline, place an {@code opennlp-ml-commons} jar built before OPENNLP-1903
+ * on the classpath ahead of the freshly built classes and rerun.
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@@ -69,7 +68,9 @@ public class BeamSearchBenchmark {
private static final int BEAM_SIZE = 3;
private static final int NUM_INPUTS = 64;
private static final int VOCAB_SIZE = 17;
- private static final long INPUT_SEED = 0x5eedL;
+ /** Seed for both the input generator and the {@link SeededMaxentModel}. */
+ private static final long SEED = 0x5eedL;
+ private static final String[] MODEL_OUTCOMES = {"start", "cont", "other"};
private static final SequenceValidator<String> ACCEPT_ALL =
(i, input, outcomes, outcome) -> true;
@@ -90,10 +91,11 @@ public class BeamSearchBenchmark {
@Setup(Level.Trial)
public void create() {
- beamSearch = new BeamSearch(BEAM_SIZE, new SeededModel(), cacheSize);
+ beamSearch = new BeamSearch(BEAM_SIZE,
+ new SeededMaxentModel(MODEL_OUTCOMES, SEED), cacheSize);
contextGenerator = new TokenContextGenerator();
inputs = new String[NUM_INPUTS][];
- Random rnd = new Random(INPUT_SEED);
+ Random rnd = new Random(SEED);
for (int n = 0; n < NUM_INPUTS; n++) {
String[] input = new String[sequenceLength];
for (int i = 0; i < sequenceLength; i++) {
@@ -110,87 +112,6 @@ public class BeamSearchBenchmark {
}
}
- /**
- * A deterministic pseudo-random {@link MaxentModel}: the probability for an
- * outcome is derived from a hash of the joined context strings with
- * splitmix64-style mixing, so repeated evals of the same context return
- * identical values in (0.01, 0.99]. Values are intentionally not normalized.
- * The buffer contract is honored: {@code eval(context, probs)} writes into
- * the passed array and returns that same array.
- */
- static final class SeededModel implements MaxentModel {
-
- private final String[] outcomes = {"start", "cont", "other"};
-
- private double prob(String[] context, int outcomeIndex) {
- long h = 0x5eedL;
- for (String c : context) {
- h = mix(h, c.hashCode());
- }
- h = mix(h, outcomeIndex);
- // splitmix64 finalizer for avalanche
- h ^= h >>> 30;
- h *= 0xBF58476D1CE4E5B9L;
- h ^= h >>> 27;
- h *= 0x94D049BB133111EBL;
- h ^= h >>> 31;
- double u = (h >>> 11) * (1.0 / (1L << 53)); // [0, 1)
- return 0.01 + 0.98 * u; // (0.01, 0.99]
- }
-
- private static long mix(long h, long v) {
- return (h ^ (v + 0x9E3779B97F4A7C15L)) * 0x100000001B3L;
- }
-
- @Override
- public double[] eval(String[] context) {
- return eval(context, new double[outcomes.length]);
- }
-
- @Override
- public double[] eval(String[] context, double[] probs) {
- for (int i = 0; i < outcomes.length; i++) {
- probs[i] = prob(context, i);
- }
- return probs; // buffer contract: write into the passed array AND return
it
- }
-
- @Override
- public double[] eval(String[] context, float[] values) {
- return eval(context);
- }
-
- @Override
- public String getOutcome(int i) {
- return outcomes[i];
- }
-
- @Override
- public int getNumOutcomes() {
- return outcomes.length;
- }
-
- @Override
- public String getAllOutcomes(double[] outcomes) {
- return null;
- }
-
- @Override
- public String getBestOutcome(double[] outcomes) {
- return null;
- }
-
- @Override
- public int getIndex(String outcome) {
- for (int i = 0; i < outcomes.length; i++) {
- if (outcomes[i].equals(outcome)) {
- return i;
- }
- }
- return -1;
- }
- }
-
/**
* Derives contexts from the current token and the previous outcome, interned
* so identical context content maps to the same {@code String[]} instance
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/BeamSearchEquivalenceTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/BeamSearchEquivalenceTest.java
index 448f868f4..45efbc741 100644
---
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/BeamSearchEquivalenceTest.java
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/BeamSearchEquivalenceTest.java
@@ -28,9 +28,14 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import opennlp.tools.ml.model.MaxentModel;
import opennlp.tools.util.BeamSearchContextGenerator;
@@ -39,123 +44,53 @@ import opennlp.tools.util.Sequence;
import opennlp.tools.util.SequenceValidator;
/**
- * Equivalence tests for the {@code BeamSearch.bestSequences} refactor that
replaced
- * per-candidate {@link Sequence} copies with internal chain nodes ({@code
SearchNode}).
+ * Equivalence tests for the {@code BeamSearch.bestSequences} chain-node
implementation
+ * introduced with OPENNLP-1903.
* <p>
* Every test runs the current {@link BeamSearch} side by side with
- * {@link #referenceBestSequences}, a faithful port of the pre-refactor
implementation
- * (as of {@code HEAD~1}), and demands identical output: same number of
sequences,
- * identical outcome lists (order-sensitive), and bit-identical scores and
- * per-position probabilities.
+ * {@link #referenceBestSequences}, a faithful port of the per-candidate
+ * {@link Sequence}-copying implementation that OPENNLP-1903 replaced, and
demands
+ * identical output: same number of sequences, identical outcome lists
+ * (order-sensitive), and bit-identical scores and per-position probabilities.
*/
public class BeamSearchEquivalenceTest {
/** Mirror of the private {@code BeamSearch.ZERO_LOG} default threshold. */
private static final double ZERO_LOG = -100000;
- private static final int NUM_OUTCOMES = 4;
+ private static final String[] OUTCOMES = {"o0", "o1", "o2", "o3"};
private static final long MODEL_SEED = 0x5eedL;
- private static final int[] BEAM_SIZES = {1, 2, 3, 5, 10}; // 10 >
NUM_OUTCOMES on purpose
+ private static final int[] BEAM_SIZES = {1, 2, 3, 5, 10}; // 10 >
OUTCOMES.length on purpose
private static final int[] INPUT_LENGTHS = {0, 1, 2, 7, 33, 128};
private static final int[] CACHE_SIZES = {0, 64};
private static final SequenceValidator<String> ACCEPT_ALL =
(i, input, outcomes, outcome) -> true;
- //
---------------------------------------------------------------------------
- // Seeded pseudo-random model
- //
---------------------------------------------------------------------------
-
- /**
- * A {@link MaxentModel} whose probabilities are a deterministic
pseudo-random
- * function of the joined context strings and the outcome index. Repeated
evals of
- * the same context therefore return identical values. Values lie in (0, 1]
and are
- * intentionally not normalized. The {@code eval(context, probs)} buffer
contract is
- * honored: values are written into the passed array and that same array is
returned.
- */
- static final class SeededModel implements MaxentModel {
-
- private final String[] outcomes;
- private final long seed;
-
- SeededModel(int numOutcomes, long seed) {
- this.outcomes = new String[numOutcomes];
- for (int i = 0; i < numOutcomes; i++) {
- this.outcomes[i] = "o" + i;
- }
- this.seed = seed;
- }
-
- private double prob(String[] context, int outcomeIndex) {
- long h = seed;
- for (String c : context) {
- h = mix(h, c.hashCode());
- }
- h = mix(h, outcomeIndex);
- // splitmix64 finalizer for avalanche
- h ^= h >>> 30;
- h *= 0xBF58476D1CE4E5B9L;
- h ^= h >>> 27;
- h *= 0x94D049BB133111EBL;
- h ^= h >>> 31;
- double u = (h >>> 11) * (1.0 / (1L << 53)); // [0, 1)
- return 0.01 + 0.98 * u; // (0.01, 0.99]
- }
-
- private static long mix(long h, long v) {
- return (h ^ (v + 0x9E3779B97F4A7C15L)) * 0x100000001B3L;
- }
-
- @Override
- public double[] eval(String[] context) {
- return eval(context, new double[outcomes.length]);
- }
-
- @Override
- public double[] eval(String[] context, double[] probs) {
- for (int i = 0; i < outcomes.length; i++) {
- probs[i] = prob(context, i);
- }
- return probs; // buffer contract: write into the passed array AND return
it
- }
-
- @Override
- public double[] eval(String[] context, float[] values) {
- return eval(context);
- }
-
- @Override
- public String getOutcome(int i) {
- return outcomes[i];
- }
-
+ /** A {@link SequenceValidator} paired with a stable name for test display.
*/
+ record NamedValidator(String name, SequenceValidator<String> validator) {
@Override
- public int getNumOutcomes() {
- return outcomes.length;
- }
-
- @Override
- public String getAllOutcomes(double[] outcomes) {
- return null;
- }
-
- @Override
- public String getBestOutcome(double[] outcomes) {
- return null;
- }
-
- @Override
- public int getIndex(String outcome) {
- for (int i = 0; i < outcomes.length; i++) {
- if (outcomes[i].equals(outcome)) {
- return i;
- }
- }
- return -1;
+ public String toString() {
+ return name;
}
}
+ private static final List<NamedValidator> VALIDATORS = List.of(
+ new NamedValidator("rejectOneOutcome",
+ (i, input, outcomes, outcome) -> !"o2".equals(outcome)),
+ // Rejects every outcome at position 2: at that position the threshold
loop adds
+ // nothing, so the next.isEmpty() fallback runs (and also rejects
everything,
+ // killing the search for inputs longer than 2).
+ new NamedValidator("rejectAllAtPosition2",
+ (i, input, outcomes, outcome) -> i != 2),
+ // Rejects everything except "o3" at position 2: the fallback actually
populates
+ // next with the sub-threshold "o3" candidate whenever "o3" fell below
the min.
+ new NamedValidator("onlyO3AtPosition2",
+ (i, input, outcomes, outcome) -> i != 2 || "o3".equals(outcome)),
+ new NamedValidator("rejectEverything",
+ (i, input, outcomes, outcome) -> false));
+
//
---------------------------------------------------------------------------
// Context generator
//
---------------------------------------------------------------------------
@@ -184,23 +119,27 @@ public class BeamSearchEquivalenceTest {
return existing != null ? existing : ctx;
}
+ /**
+ * @return The number of {@link #getContext} invocations so far.
+ */
int callCount() {
return callCount.get();
}
}
//
---------------------------------------------------------------------------
- // Reference implementation: faithful port of the pre-refactor bestSequences
- // (git show HEAD~1:.../opennlp/tools/ml/BeamSearch.java)
+ // Reference implementation: faithful port of the bestSequences
implementation
+ // prior to OPENNLP-1903
//
---------------------------------------------------------------------------
/**
- * Port of the OLD {@code BeamSearch.bestSequences} control flow:
PriorityQueue over
- * {@link Sequence}, per-candidate {@code new Sequence(top, out, scores[p])}
copies,
- * tempScores sort/min, the {@code next.isEmpty()} advance-all-valid
fallback, the
- * queue swap, and the winner removal order. The cache path (a
- * {@code Cache<String[], double[]>} exactly like the old per-thread one) is
used
- * when {@code cacheSize > 0}; otherwise the uncached eval path is taken.
+ * Port of the {@code BeamSearch.bestSequences} control flow prior to
OPENNLP-1903:
+ * PriorityQueue over {@link Sequence}, per-candidate
+ * {@code new Sequence(top, out, scores[p])} copies, tempScores sort/min, the
+ * {@code next.isEmpty()} advance-all-valid fallback, the queue swap, and
the winner
+ * removal order. The cache path (a {@code Cache<String[], double[]>}
exactly like the
+ * per-thread one in {@link BeamSearch}) is used when {@code cacheSize > 0};
otherwise
+ * the uncached eval path is taken.
*/
static <T> Sequence[] referenceBestSequences(
final int numSequences, final T[] sequence, final Object[]
additionalContext,
@@ -208,7 +147,7 @@ public class BeamSearchEquivalenceTest {
final SequenceValidator<T> validator, final MaxentModel model,
final int beamSize, final int cacheSize) {
- // Local equivalents of the old per-thread CacheState.
+ // Local equivalents of the per-thread CacheState in BeamSearch.
final double[] probs = new double[model.getNumOutcomes()];
final double[] tempScores = new double[model.getNumOutcomes()];
final Cache<String[], double[]> cache = cacheSize > 0 ? new
Cache<>(cacheSize) : null;
@@ -303,6 +242,16 @@ public class BeamSearchEquivalenceTest {
// Helpers
//
---------------------------------------------------------------------------
+ /**
+ * @return A new model over {@link #OUTCOMES} seeded with {@link
#MODEL_SEED}.
+ */
+ private static MaxentModel model() {
+ return new SeededMaxentModel(OUTCOMES, MODEL_SEED);
+ }
+
+ /**
+ * @return A seeded pseudo-random token sequence of {@code length} tokens.
+ */
private static String[] randomInput(int length, long seed) {
Random rnd = new Random(seed);
String[] input = new String[length];
@@ -349,72 +298,120 @@ public class BeamSearchEquivalenceTest {
}
//
---------------------------------------------------------------------------
- // 1. Equivalence matrix: beam sizes x input lengths x cache sizes,
- // default (ZERO_LOG) threshold via the two-arg overload, accept-all
validator
+ // Parameter sources
//
---------------------------------------------------------------------------
- @Test
- void equivalenceAcrossBeamSizesLengthsAndCaches() {
- MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ /**
+ * @return The cartesian product of {@link #BEAM_SIZES}, {@link
#INPUT_LENGTHS}
+ * and {@link #CACHE_SIZES}.
+ */
+ static Stream<Arguments> beamLengthCacheMatrix() {
+ Stream.Builder<Arguments> cases = Stream.builder();
for (int beam : BEAM_SIZES) {
for (int length : INPUT_LENGTHS) {
- String[] input = randomInput(length, 1000L + length);
for (int cache : CACHE_SIZES) {
- String desc = caseDesc("matrix", beam, length, cache);
- SeededContextGenerator cg = new SeededContextGenerator();
-
- Sequence[] expected = referenceBestSequences(1, input, null, cg,
ACCEPT_ALL,
- model, beam, cache);
- Sequence[] actual = new BeamSearch(beam, model, cache)
- .bestSequences(1, input, null, cg, ACCEPT_ALL);
-
- assertSequencesEqual(expected, actual, desc);
- if (length == 0) {
- Assertions.assertEquals(0, cg.callCount(),
- desc + ": context generator must not be called for empty
input");
+ cases.add(Arguments.of(beam, length, cache));
+ }
+ }
+ }
+ return cases.build();
+ }
+
+ /**
+ * @return The cartesian product of {@link #VALIDATORS}, {@link #BEAM_SIZES},
+ * {@link #INPUT_LENGTHS} and {@link #CACHE_SIZES}.
+ */
+ static Stream<Arguments> validatorBeamLengthCacheMatrix() {
+ Stream.Builder<Arguments> cases = Stream.builder();
+ for (NamedValidator nv : VALIDATORS) {
+ for (int beam : BEAM_SIZES) {
+ for (int length : INPUT_LENGTHS) {
+ for (int cache : CACHE_SIZES) {
+ cases.add(Arguments.of(nv, beam, length, cache));
}
}
}
}
+ return cases.build();
+ }
+
+ /**
+ * @return The cartesian product of {@link #INPUT_LENGTHS} and {@link
#CACHE_SIZES}.
+ */
+ static Stream<Arguments> lengthCacheMatrix() {
+ Stream.Builder<Arguments> cases = Stream.builder();
+ for (int length : INPUT_LENGTHS) {
+ for (int cache : CACHE_SIZES) {
+ cases.add(Arguments.of(length, cache));
+ }
+ }
+ return cases.build();
+ }
+
+ /**
+ * @return All values of {@link #CACHE_SIZES}.
+ */
+ static IntStream cacheSizes() {
+ return IntStream.of(CACHE_SIZES);
}
//
---------------------------------------------------------------------------
- // 2. Equivalence with a tight minSequenceScore that actually filters
candidates
+ // 1. Equivalence matrix: beam sizes x input lengths x cache sizes,
+ // default (ZERO_LOG) threshold via the two-arg overload, accept-all
validator
//
---------------------------------------------------------------------------
- @Test
- void equivalenceWithTightMinSequenceScore() {
- MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
- for (int beam : BEAM_SIZES) {
- for (int length : INPUT_LENGTHS) {
- String[] input = randomInput(length, 2000L + length);
- for (int cache : CACHE_SIZES) {
- String desc = caseDesc("threshold", beam, length, cache);
-
- // Derive a threshold that bites: run uncapped, then cut between the
best
- // and worst candidate scores (or just above the best when only one
exists).
- Sequence[] uncapped = referenceBestSequences(beam, input, null,
- new SeededContextGenerator(), ACCEPT_ALL, model, beam, cache);
- final double threshold;
- if (uncapped.length == 0) {
- threshold = 0;
- } else {
- double best = uncapped[0].getScore();
- double worst = uncapped[uncapped.length - 1].getScore();
- threshold = (uncapped.length > 1 && worst < best)
- ? (best + worst) / 2.0 : best + 0.5;
- }
+ @ParameterizedTest(name = "beam={0}, len={1}, cache={2}")
+ @MethodSource("beamLengthCacheMatrix")
+ void equivalenceAcrossBeamSizesLengthsAndCaches(int beam, int length, int
cache) {
+ MaxentModel model = model();
+ String[] input = randomInput(length, 1000L + length);
+ String desc = caseDesc("matrix", beam, length, cache);
+ SeededContextGenerator cg = new SeededContextGenerator();
- Sequence[] expected = referenceBestSequences(1, input, null,
threshold,
- new SeededContextGenerator(), ACCEPT_ALL, model, beam, cache);
- Sequence[] actual = new BeamSearch(beam, model, cache)
- .bestSequences(1, input, null, threshold, new
SeededContextGenerator(),
- ACCEPT_ALL);
+ Sequence[] expected = referenceBestSequences(1, input, null, cg,
ACCEPT_ALL,
+ model, beam, cache);
+ Sequence[] actual = new BeamSearch(beam, model, cache)
+ .bestSequences(1, input, null, cg, ACCEPT_ALL);
- assertSequencesEqual(expected, actual, desc + ", threshold=" +
threshold);
- }
- }
+ assertSequencesEqual(expected, actual, desc);
+ if (length == 0) {
+ Assertions.assertEquals(0, cg.callCount(),
+ desc + ": context generator must not be called for empty input");
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // 2. Equivalence with a tight minSequenceScore that actually filters
candidates
+ //
---------------------------------------------------------------------------
+
+ @ParameterizedTest(name = "beam={0}, len={1}, cache={2}")
+ @MethodSource("beamLengthCacheMatrix")
+ void equivalenceWithTightMinSequenceScore(int beam, int length, int cache) {
+ MaxentModel model = model();
+ String[] input = randomInput(length, 2000L + length);
+ String desc = caseDesc("threshold", beam, length, cache);
+
+ // Derive a threshold that bites: run uncapped, then cut between the best
+ // and worst candidate scores (or just above the best when only one
exists).
+ Sequence[] uncapped = referenceBestSequences(beam, input, null,
+ new SeededContextGenerator(), ACCEPT_ALL, model, beam, cache);
+ final double threshold;
+ if (uncapped.length == 0) {
+ threshold = 0;
+ } else {
+ double best = uncapped[0].getScore();
+ double worst = uncapped[uncapped.length - 1].getScore();
+ threshold = (uncapped.length > 1 && worst < best)
+ ? (best + worst) / 2.0 : best + 0.5;
}
+
+ Sequence[] expected = referenceBestSequences(1, input, null, threshold,
+ new SeededContextGenerator(), ACCEPT_ALL, model, beam, cache);
+ Sequence[] actual = new BeamSearch(beam, model, cache)
+ .bestSequences(1, input, null, threshold, new SeededContextGenerator(),
+ ACCEPT_ALL);
+
+ assertSequencesEqual(expected, actual, desc + ", threshold=" + threshold);
}
//
---------------------------------------------------------------------------
@@ -422,54 +419,26 @@ public class BeamSearchEquivalenceTest {
// advance-all-valid fallback and the reject-everything empty-result path
//
---------------------------------------------------------------------------
- @Test
- void equivalenceWithRestrictiveValidators() {
- MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
-
- SequenceValidator<String> rejectOneOutcome =
- (i, input, outcomes, outcome) -> !"o2".equals(outcome);
- // Rejects every outcome at position 2: at that position the threshold
loop adds
- // nothing, so the next.isEmpty() fallback runs (and also rejects
everything,
- // killing the search for inputs longer than 2).
- SequenceValidator<String> rejectAllAtPosition2 =
- (i, input, outcomes, outcome) -> i != 2;
- // Rejects everything except "o3" at position 2: the fallback actually
populates
- // next with the sub-threshold "o3" candidate whenever "o3" fell below the
min.
- SequenceValidator<String> onlyO3AtPosition2 =
- (i, input, outcomes, outcome) -> i != 2 || "o3".equals(outcome);
- SequenceValidator<String> rejectEverything =
- (i, input, outcomes, outcome) -> false;
-
- record NamedValidator(String name, SequenceValidator<String> validator) {}
- List<NamedValidator> validators = List.of(
- new NamedValidator("rejectOneOutcome", rejectOneOutcome),
- new NamedValidator("rejectAllAtPosition2", rejectAllAtPosition2),
- new NamedValidator("onlyO3AtPosition2", onlyO3AtPosition2),
- new NamedValidator("rejectEverything", rejectEverything));
-
- for (NamedValidator nv : validators) {
- for (int beam : BEAM_SIZES) {
- for (int length : INPUT_LENGTHS) {
- String[] input = randomInput(length, 3000L + length);
- for (int cache : CACHE_SIZES) {
- String desc = caseDesc("validator-" + nv.name(), beam, length,
cache);
-
- Sequence[] expected = referenceBestSequences(1, input, null,
- new SeededContextGenerator(), nv.validator(), model, beam,
cache);
- Sequence[] actual = new BeamSearch(beam, model, cache)
- .bestSequences(1, input, null, new SeededContextGenerator(),
- nv.validator());
-
- assertSequencesEqual(expected, actual, desc);
- if ("rejectEverything".equals(nv.name()) && length > 0) {
- Assertions.assertEquals(0, actual.length,
- desc + ": reject-everything must yield an empty (non-null)
array");
- Assertions.assertEquals(0, expected.length,
- desc + ": reference reject-everything must also be empty");
- }
- }
- }
- }
+ @ParameterizedTest(name = "{0}, beam={1}, len={2}, cache={3}")
+ @MethodSource("validatorBeamLengthCacheMatrix")
+ void equivalenceWithRestrictiveValidators(NamedValidator nv, int beam, int
length,
+ int cache) {
+ MaxentModel model = model();
+ String[] input = randomInput(length, 3000L + length);
+ String desc = caseDesc("validator-" + nv.name(), beam, length, cache);
+
+ Sequence[] expected = referenceBestSequences(1, input, null,
+ new SeededContextGenerator(), nv.validator(), model, beam, cache);
+ Sequence[] actual = new BeamSearch(beam, model, cache)
+ .bestSequences(1, input, null, new SeededContextGenerator(),
+ nv.validator());
+
+ assertSequencesEqual(expected, actual, desc);
+ if ("rejectEverything".equals(nv.name()) && length > 0) {
+ Assertions.assertEquals(0, actual.length,
+ desc + ": reject-everything must yield an empty (non-null) array");
+ Assertions.assertEquals(0, expected.length,
+ desc + ": reference reject-everything must also be empty");
}
}
@@ -477,34 +446,31 @@ public class BeamSearchEquivalenceTest {
// 4. numSequences > 1: winner order and scores match the reference exactly
//
---------------------------------------------------------------------------
- @Test
- void multiWinnerOrderingMatchesReference() {
- MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ @ParameterizedTest(name = "len={0}, cache={1}")
+ @MethodSource("lengthCacheMatrix")
+ void multiWinnerOrderingMatchesReference(int length, int cache) {
+ MaxentModel model = model();
int beam = 5;
int numSequences = 3;
Object[] additionalContext = {"ac-ctx"};
- for (int length : INPUT_LENGTHS) {
- String[] input = randomInput(length, 4000L + length);
- for (int cache : CACHE_SIZES) {
- String desc = caseDesc("multiWinner[k=3]", beam, length, cache);
-
- Sequence[] expected = referenceBestSequences(numSequences, input,
- additionalContext, new SeededContextGenerator(), ACCEPT_ALL,
- model, beam, cache);
- Sequence[] actual = new BeamSearch(beam, model, cache)
- .bestSequences(numSequences, input, additionalContext,
- new SeededContextGenerator(), ACCEPT_ALL);
-
- assertSequencesEqual(expected, actual, desc);
- if (length > 0) {
- Assertions.assertEquals(numSequences, actual.length,
- desc + ": expected a full k-best list");
- // Winners must come out in non-increasing score order.
- for (int s = 1; s < actual.length; s++) {
- Assertions.assertTrue(actual[s - 1].getScore() >=
actual[s].getScore(),
- desc + ": winner order not non-increasing at index " + s);
- }
- }
+ String[] input = randomInput(length, 4000L + length);
+ String desc = caseDesc("multiWinner[k=3]", beam, length, cache);
+
+ Sequence[] expected = referenceBestSequences(numSequences, input,
+ additionalContext, new SeededContextGenerator(), ACCEPT_ALL,
+ model, beam, cache);
+ Sequence[] actual = new BeamSearch(beam, model, cache)
+ .bestSequences(numSequences, input, additionalContext,
+ new SeededContextGenerator(), ACCEPT_ALL);
+
+ assertSequencesEqual(expected, actual, desc);
+ if (length > 0) {
+ Assertions.assertEquals(numSequences, actual.length,
+ desc + ": expected a full k-best list");
+ // Winners must come out in non-increasing score order.
+ for (int s = 1; s < actual.length; s++) {
+ Assertions.assertTrue(actual[s - 1].getScore() >= actual[s].getScore(),
+ desc + ": winner order not non-increasing at index " + s);
}
}
}
@@ -521,7 +487,7 @@ public class BeamSearchEquivalenceTest {
final int beam = 3;
final int cache = 64;
- MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ MaxentModel model = model();
BeamSearch shared = new BeamSearch(beam, model, cache);
String[][] inputs = new String[numInputs][];
@@ -603,45 +569,44 @@ public class BeamSearchEquivalenceTest {
// winning Sequence are consistent with the model's eval outputs
//
---------------------------------------------------------------------------
- @Test
- void winnerMaterializationMatchesModelOutputs() {
- MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ @ParameterizedTest(name = "cache={0}")
+ @MethodSource("cacheSizes")
+ void winnerMaterializationMatchesModelOutputs(int cache) {
+ MaxentModel model = model();
String[] input = randomInput(7, 6000L);
int beam = 3;
- for (int cache : CACHE_SIZES) {
- String desc = "materialization[cache=" + cache + "]";
- BeamSearch bs = new BeamSearch(beam, model, cache);
- Sequence winner = bs.bestSequence(input, null, new
SeededContextGenerator(),
- ACCEPT_ALL);
- Assertions.assertNotNull(winner, desc);
- Assertions.assertEquals(input.length, winner.getSize(), desc + ": size");
-
- // The winner must equal the reference winner.
- Sequence refWinner = referenceBestSequences(1, input, null,
- new SeededContextGenerator(), ACCEPT_ALL, model, beam, cache)[0];
- Assertions.assertEquals(refWinner.getOutcomes(), winner.getOutcomes(),
- desc + ": outcomes vs reference");
-
- // Walk the winning path and recompute the expected probs/score
independently.
- List<String> outcomes = winner.getOutcomes();
- double[] probs = winner.getProbs();
- double expectedScore = 0d;
- SeededContextGenerator cg = new SeededContextGenerator();
- for (int i = 0; i < outcomes.size(); i++) {
- String[] prefix = outcomes.subList(0, i).toArray(new String[0]);
- String[] contexts = cg.getContext(i, input, prefix, new Object[0]);
- double[] eval = model.eval(contexts);
- int outcomeIndex = model.getIndex(outcomes.get(i));
- Assertions.assertTrue(outcomeIndex >= 0, desc + ": outcome known to
model");
- double expectedProb = eval[outcomeIndex];
-
- assertBitIdentical(expectedProb, probs[i], desc + ": getProbs()[" + i
+ "]");
- assertBitIdentical(expectedProb, winner.getProb(i),
- desc + ": getProb(" + i + ")");
- expectedScore += StrictMath.log(expectedProb);
- }
- assertBitIdentical(expectedScore, winner.getScore(), desc + ": score");
+ String desc = "materialization[cache=" + cache + "]";
+ BeamSearch bs = new BeamSearch(beam, model, cache);
+ Sequence winner = bs.bestSequence(input, null, new
SeededContextGenerator(),
+ ACCEPT_ALL);
+ Assertions.assertNotNull(winner, desc);
+ Assertions.assertEquals(input.length, winner.getSize(), desc + ": size");
+
+ // The winner must equal the reference winner.
+ Sequence refWinner = referenceBestSequences(1, input, null,
+ new SeededContextGenerator(), ACCEPT_ALL, model, beam, cache)[0];
+ Assertions.assertEquals(refWinner.getOutcomes(), winner.getOutcomes(),
+ desc + ": outcomes vs reference");
+
+ // Walk the winning path and recompute the expected probs/score
independently.
+ List<String> outcomes = winner.getOutcomes();
+ double[] probs = winner.getProbs();
+ double expectedScore = 0d;
+ SeededContextGenerator cg = new SeededContextGenerator();
+ for (int i = 0; i < outcomes.size(); i++) {
+ String[] prefix = outcomes.subList(0, i).toArray(new String[0]);
+ String[] contexts = cg.getContext(i, input, prefix, new Object[0]);
+ double[] eval = model.eval(contexts);
+ int outcomeIndex = model.getIndex(outcomes.get(i));
+ Assertions.assertTrue(outcomeIndex >= 0, desc + ": outcome known to
model");
+ double expectedProb = eval[outcomeIndex];
+
+ assertBitIdentical(expectedProb, probs[i], desc + ": getProbs()[" + i +
"]");
+ assertBitIdentical(expectedProb, winner.getProb(i),
+ desc + ": getProb(" + i + ")");
+ expectedScore += StrictMath.log(expectedProb);
}
+ assertBitIdentical(expectedScore, winner.getScore(), desc + ": score");
}
}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/SeededMaxentModel.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/SeededMaxentModel.java
new file mode 100644
index 000000000..1d2ddb6b2
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/SeededMaxentModel.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.ml;
+
+import opennlp.tools.ml.model.MaxentModel;
+
+/**
+ * A deterministic pseudo-random {@link MaxentModel} test fixture. The
probability of an
+ * outcome is derived from a hash of the joined context strings, the outcome
index and a
+ * fixed seed with splitmix64-style mixing, so repeated evals of the same
context return
+ * identical values in (0.01, 0.99]. Values are intentionally not normalized.
The
+ * {@code eval(context, probs)} buffer contract is honored: values are written
into the
+ * passed array and that same array is returned. Stateless and therefore
thread-safe.
+ */
+class SeededMaxentModel implements MaxentModel {
+
+ private final String[] outcomes;
+ private final long seed;
+
+ /**
+ * Initializes a {@link SeededMaxentModel} instance.
+ *
+ * @param outcomes The outcome labels; the outcome index is the array index.
+ * @param seed The seed all probabilities are derived from.
+ */
+ SeededMaxentModel(String[] outcomes, long seed) {
+ this.outcomes = outcomes;
+ this.seed = seed;
+ }
+
+ /**
+ * @return A pseudo-random probability in (0.01, 0.99], a pure function
+ * of {@code context}, {@code outcomeIndex} and the seed.
+ */
+ private double prob(String[] context, int outcomeIndex) {
+ long h = seed;
+ for (String c : context) {
+ h = mix(h, c.hashCode());
+ }
+ h = mix(h, outcomeIndex);
+ // splitmix64 finalizer for avalanche
+ h ^= h >>> 30;
+ h *= 0xBF58476D1CE4E5B9L;
+ h ^= h >>> 27;
+ h *= 0x94D049BB133111EBL;
+ h ^= h >>> 31;
+ double u = (h >>> 11) * (1.0 / (1L << 53)); // [0, 1)
+ return 0.01 + 0.98 * u; // (0.01, 0.99]
+ }
+
+ /**
+ * @return {@code h} combined with {@code v} FNV-style.
+ */
+ private static long mix(long h, long v) {
+ return (h ^ (v + 0x9E3779B97F4A7C15L)) * 0x100000001B3L;
+ }
+
+ @Override
+ public double[] eval(String[] context) {
+ return eval(context, new double[outcomes.length]);
+ }
+
+ @Override
+ public double[] eval(String[] context, double[] probs) {
+ for (int i = 0; i < outcomes.length; i++) {
+ probs[i] = prob(context, i);
+ }
+ return probs; // buffer contract: write into the passed array AND return it
+ }
+
+ @Override
+ public double[] eval(String[] context, float[] values) {
+ return eval(context);
+ }
+
+ @Override
+ public String getOutcome(int i) {
+ return outcomes[i];
+ }
+
+ @Override
+ public int getNumOutcomes() {
+ return outcomes.length;
+ }
+
+ @Override
+ public String getAllOutcomes(double[] outcomes) {
+ return null;
+ }
+
+ @Override
+ public String getBestOutcome(double[] outcomes) {
+ return null;
+ }
+
+ @Override
+ public int getIndex(String outcome) {
+ for (int i = 0; i < outcomes.length; i++) {
+ if (outcomes[i].equals(outcome)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+}