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 b6ebe235b OPENNLP-1903: Add behavioral equivalence tests for the
BeamSearch chain-node refactor
b6ebe235b is described below
commit b6ebe235b420c3edb4f55fcaa38de00657c04e09
Author: Kristian Rickert <[email protected]>
AuthorDate: Mon Aug 3 01:39:21 2026 -0400
OPENNLP-1903: Add behavioral equivalence tests for the BeamSearch
chain-node refactor
Ports the pre-refactor bestSequences algorithm inline as a reference and
asserts bit-identical behavior (outcome order, scores, per-position
probs via Double.doubleToRawLongBits) across:
- 5 beam sizes x 6 input lengths x 2 cache sizes (incl. beam >
numOutcomes, zero-length input)
- tight minSequenceScore thresholds that actually filter candidates
- restrictive validators, including the next.isEmpty() advance-all-valid
fallback and the reject-everything path
- k-best winner ordering with numSequences > 1
- 8-thread concurrent decoding vs serial reference (384 verifications)
- winner materialization: getProbs/getProb/getScore against independent
StrictMath.log accumulation
372 seeded equivalence combinations, all bit-identical; BeamSearchTest
and the namefind/postag/chunker/lemmatizer suites remain green.
---
.../tools/ml/BeamSearchEquivalenceTest.java | 647 +++++++++++++++++++++
1 file changed, 647 insertions(+)
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
new file mode 100644
index 000000000..448f868f4
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/ml/BeamSearchEquivalenceTest.java
@@ -0,0 +1,647 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.PriorityQueue;
+import java.util.Queue;
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.ml.model.MaxentModel;
+import opennlp.tools.util.BeamSearchContextGenerator;
+import opennlp.tools.util.Cache;
+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}).
+ * <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.
+ */
+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 long MODEL_SEED = 0x5eedL;
+
+ private static final int[] BEAM_SIZES = {1, 2, 3, 5, 10}; // 10 >
NUM_OUTCOMES 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];
+ }
+
+ @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;
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // Context generator
+ //
---------------------------------------------------------------------------
+
+ /**
+ * Builds contexts from the index, the token, the previous outcome and the
first
+ * additional-context element. Identical context content is interned to the
same
+ * {@code String[]} instance so the identity-keyed contexts {@link Cache} in
+ * {@link BeamSearch} actually produces hits. Thread-safe.
+ */
+ static final class SeededContextGenerator implements
BeamSearchContextGenerator<String> {
+
+ private final ConcurrentHashMap<String, String[]> intern = new
ConcurrentHashMap<>();
+ private final AtomicInteger callCount = new AtomicInteger();
+
+ @Override
+ public String[] getContext(int index, String[] sequence,
+ String[] priorDecisions, Object[]
additionalContext) {
+ callCount.incrementAndGet();
+ String prev = index > 0 ? priorDecisions[index - 1] : "<s>";
+ String ac = additionalContext != null && additionalContext.length > 0
+ ? String.valueOf(additionalContext[0]) : "-";
+ String[] ctx = {"ix=" + index, "tok=" + sequence[index], "prev=" + prev,
"ac=" + ac};
+ String key = String.join("", ctx);
+ String[] existing = intern.putIfAbsent(key, ctx);
+ return existing != null ? existing : ctx;
+ }
+
+ int callCount() {
+ return callCount.get();
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // Reference implementation: faithful port of the pre-refactor bestSequences
+ // (git show HEAD~1:.../opennlp/tools/ml/BeamSearch.java)
+ //
---------------------------------------------------------------------------
+
+ /**
+ * 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.
+ */
+ static <T> Sequence[] referenceBestSequences(
+ final int numSequences, final T[] sequence, final Object[]
additionalContext,
+ final double minSequenceScore, final BeamSearchContextGenerator<T> cg,
+ final SequenceValidator<T> validator, final MaxentModel model,
+ final int beamSize, final int cacheSize) {
+
+ // Local equivalents of the old per-thread CacheState.
+ 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;
+
+ Queue<Sequence> prev = new PriorityQueue<>(beamSize);
+ Queue<Sequence> next = new PriorityQueue<>(beamSize);
+ Queue<Sequence> tmp;
+ prev.add(new Sequence());
+
+ Object[] context = additionalContext;
+ if (context == null) {
+ context = new Object[0];
+ }
+
+ for (int i = 0; i < sequence.length; i++) {
+ final int sz = StrictMath.min(beamSize, prev.size());
+
+ for (int sc = 0; prev.size() > 0 && sc < sz; sc++) {
+ final Sequence top = prev.remove();
+ final List<String> tmpOutcomes = top.getOutcomes();
+ final String[] outcomes = tmpOutcomes.toArray(new String[0]);
+ final String[] contexts = cg.getContext(i, sequence, outcomes,
context);
+ final double[] scores;
+ if (cache != null) {
+ scores = cache.computeIfAbsent(contexts, c -> {
+ double[] res = model.eval(c, probs);
+ double[] copy = new double[res.length];
+ System.arraycopy(res, 0, copy, 0, res.length);
+ return copy;
+ });
+ } else {
+ scores = model.eval(contexts, probs);
+ }
+
+ System.arraycopy(scores, 0, tempScores, 0, scores.length);
+ Arrays.sort(tempScores);
+
+ final double min = tempScores[StrictMath.max(0, scores.length -
beamSize)];
+
+ for (int p = 0; p < scores.length; p++) {
+ if (scores[p] >= min) {
+ final String out = model.getOutcome(p);
+ if (validator.validSequence(i, sequence, outcomes, out)) {
+ final Sequence ns = new Sequence(top, out, scores[p]);
+ if (ns.getScore() > minSequenceScore) {
+ next.add(ns);
+ }
+ }
+ }
+ }
+
+ if (next.isEmpty()) { // if no advanced sequences, advance all valid
+ for (int p = 0; p < scores.length; p++) {
+ final String out = model.getOutcome(p);
+ if (validator.validSequence(i, sequence, outcomes, out)) {
+ final Sequence ns = new Sequence(top, out, scores[p]);
+ if (ns.getScore() > minSequenceScore) {
+ next.add(ns);
+ }
+ }
+ }
+ }
+ }
+
+ // make prev = next; and re-init next (reuse existing prev set once
cleared)
+ prev.clear();
+ tmp = prev;
+ prev = next;
+ next = tmp;
+ }
+
+ final int numSeq = StrictMath.min(numSequences, prev.size());
+ final Sequence[] topSequences = new Sequence[numSeq];
+
+ for (int seqIndex = 0; seqIndex < numSeq; seqIndex++) {
+ topSequences[seqIndex] = prev.remove();
+ }
+
+ return topSequences;
+ }
+
+ /** Reference twin of the two-arg overload (default {@code ZERO_LOG}
threshold). */
+ static <T> Sequence[] referenceBestSequences(
+ int numSequences, T[] sequence, Object[] additionalContext,
+ BeamSearchContextGenerator<T> cg, SequenceValidator<T> validator,
+ MaxentModel model, int beamSize, int cacheSize) {
+ return referenceBestSequences(numSequences, sequence, additionalContext,
ZERO_LOG,
+ cg, validator, model, beamSize, cacheSize);
+ }
+
+ //
---------------------------------------------------------------------------
+ // Helpers
+ //
---------------------------------------------------------------------------
+
+ private static String[] randomInput(int length, long seed) {
+ Random rnd = new Random(seed);
+ String[] input = new String[length];
+ for (int i = 0; i < length; i++) {
+ input[i] = "t" + rnd.nextInt(17);
+ }
+ return input;
+ }
+
+ private static void assertBitIdentical(double expected, double actual,
String what) {
+ Assertions.assertEquals(Double.doubleToRawLongBits(expected),
+ Double.doubleToRawLongBits(actual),
+ what + " (expected=" + expected + ", actual=" + actual + ")");
+ }
+
+ private static void assertSequencesEqual(Sequence[] expected, Sequence[]
actual,
+ String caseDesc) {
+ Assertions.assertNotNull(actual, caseDesc + ": result array must not be
null");
+ Assertions.assertEquals(expected.length, actual.length,
+ caseDesc + ": number of returned sequences");
+ for (int s = 0; s < expected.length; s++) {
+ String seqDesc = caseDesc + ", sequence[" + s + "]";
+ Assertions.assertEquals(expected[s].getOutcomes(),
actual[s].getOutcomes(),
+ seqDesc + ": outcomes");
+ assertBitIdentical(expected[s].getScore(), actual[s].getScore(),
+ seqDesc + ": score");
+ Assertions.assertEquals(expected[s].getSize(), actual[s].getSize(),
+ seqDesc + ": size");
+ double[] expectedProbs = expected[s].getProbs();
+ double[] actualProbs = actual[s].getProbs();
+ Assertions.assertEquals(expectedProbs.length, actualProbs.length,
+ seqDesc + ": probs length");
+ for (int p = 0; p < expectedProbs.length; p++) {
+ assertBitIdentical(expectedProbs[p], actualProbs[p],
+ seqDesc + ": prob[" + p + "]");
+ assertBitIdentical(expected[s].getProb(p), actual[s].getProb(p),
+ seqDesc + ": getProb(" + p + ")");
+ }
+ }
+ }
+
+ private static String caseDesc(String test, int beam, int length, int cache)
{
+ return test + "[beam=" + beam + ", len=" + length + ", cache=" + cache +
"]";
+ }
+
+ //
---------------------------------------------------------------------------
+ // 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 equivalenceAcrossBeamSizesLengthsAndCaches() {
+ MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ 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");
+ }
+ }
+ }
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // 2. Equivalence with a tight minSequenceScore that actually filters
candidates
+ //
---------------------------------------------------------------------------
+
+ @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;
+ }
+
+ 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);
+ }
+ }
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // 3. Equivalence under restrictive validators, including the next.isEmpty()
+ // 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");
+ }
+ }
+ }
+ }
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // 4. numSequences > 1: winner order and scores match the reference exactly
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void multiWinnerOrderingMatchesReference() {
+ MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ 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);
+ }
+ }
+ }
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // 5. Concurrency determinism: one shared BeamSearch, 8 worker threads
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void concurrentResultsMatchSerialReference() throws Exception {
+ final int numInputs = 64;
+ final int rounds = 4; // 64 x 4 = 256 decode evaluations across the pool
+ final int numThreads = 8;
+ final int beam = 3;
+ final int cache = 64;
+
+ MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ BeamSearch shared = new BeamSearch(beam, model, cache);
+
+ String[][] inputs = new String[numInputs][];
+ for (int n = 0; n < numInputs; n++) {
+ inputs[n] = randomInput(n, 5000L + n); // lengths 0..63
+ }
+ SeededContextGenerator cg = new SeededContextGenerator();
+
+ // Serial reference results, one per input.
+ Sequence[][] reference = new Sequence[numInputs][];
+ for (int n = 0; n < numInputs; n++) {
+ reference[n] = referenceBestSequences(1, inputs[n], null, cg, ACCEPT_ALL,
+ model, beam, cache);
+ }
+
+ // Run 1: 8 workers, each with a disjoint subset of inputs, `rounds`
passes each.
+ Sequence[][][] runResults = new Sequence[rounds][numInputs][];
+ ExecutorService pool = Executors.newFixedThreadPool(numThreads);
+ try {
+ List<Future<?>> futures = new ArrayList<>();
+ for (int w = 0; w < numThreads; w++) {
+ final int worker = w;
+ futures.add(pool.submit(() -> {
+ for (int round = 0; round < rounds; round++) {
+ for (int n = worker; n < numInputs; n += numThreads) {
+ runResults[round][n] = shared.bestSequences(1, inputs[n], null,
cg,
+ ACCEPT_ALL);
+ }
+ }
+ }));
+ }
+ for (Future<?> f : futures) {
+ f.get();
+ }
+ } finally {
+ pool.shutdown();
+ }
+
+ for (int round = 0; round < rounds; round++) {
+ for (int n = 0; n < numInputs; n++) {
+ assertSequencesEqual(reference[n], runResults[round][n],
+ "concurrent[round=" + round + ", input=" + n + ", len=" +
inputs[n].length
+ + "]");
+ }
+ }
+
+ // Run 2: the SAME input set twice concurrently; the two passes must agree
with
+ // each other (and with the serial reference).
+ Sequence[][] passA = new Sequence[numInputs][];
+ Sequence[][] passB = new Sequence[numInputs][];
+ ExecutorService pool2 = Executors.newFixedThreadPool(numThreads);
+ try {
+ Future<?> fa = pool2.submit(() -> {
+ for (int n = 0; n < numInputs; n++) {
+ passA[n] = shared.bestSequences(1, inputs[n], null, cg, ACCEPT_ALL);
+ }
+ });
+ Future<?> fb = pool2.submit(() -> {
+ for (int n = numInputs - 1; n >= 0; n--) { // reverse order, still
racing passA
+ passB[n] = shared.bestSequences(1, inputs[n], null, cg, ACCEPT_ALL);
+ }
+ });
+ fa.get();
+ fb.get();
+ } finally {
+ pool2.shutdown();
+ }
+
+ for (int n = 0; n < numInputs; n++) {
+ assertSequencesEqual(passA[n], passB[n],
+ "concurrent-agreement[input=" + n + "]");
+ assertSequencesEqual(reference[n], passA[n],
+ "concurrent-vs-reference[input=" + n + "]");
+ }
+ }
+
+ //
---------------------------------------------------------------------------
+ // 6. Winner materialization: outcomes, per-position probs and score of the
+ // winning Sequence are consistent with the model's eval outputs
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void winnerMaterializationMatchesModelOutputs() {
+ MaxentModel model = new SeededModel(NUM_OUTCOMES, MODEL_SEED);
+ 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");
+ }
+ }
+}