summaryrefslogtreecommitdiff
path: root/src/fhcollect.c
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-25 09:55:30 -0700
committerScott Gasch <[email protected]>2026-10-25 09:55:30 -0700
commit874ef0478078ef4eb436af7d2b68a353f61f4d70 (patch)
treee9954c289393c64d88402beff04637b52732ca73 /src/fhcollect.c
parent5f901d737b17c5f99b6e456d6c0c7d5e3c093dc4 (diff)
Learned FH% logistic-regression model (alpha full, beta whole-node-only)
Replaces the data.c histogram tables (g_uJointFHThousandths/ g_uBetaFHThousandths and their _NNUE counterparts) with two quantized logistic regressions as the default FH%-prediction mechanism under FH_USE_REGRESSION_MODEL=1, trained against real collected search data (fhcollect.c) per docs/REGRESSION.md. Alpha side is a full per-move regression, validated against the old table (0.119pp weighted mean deviation across 10,684 matched cells). Beta side is whole-node-only (matching the old table's own scope -- no functionality lost), refit on the NO_MOVE population after an earlier mixed-tier fit saturated and over-pruned; its skip threshold (FH_BETA_SKIP_THRESHOLD=99000) was confirmed by direct self-play sweeps, not just ECM solve-count intuition (see docs/REGRESSION.md's "Category A vs B" discussion). Gated by an overnight SPRT match (regression vs HEAD tables, same NNUE eval, sd8): at 4120 games, llr=+2.53 (never dipped negative, peaked +2.8), score=0.517, candidate reaching comparable depth at higher nps/ebf than head. Did not cross the H1=+2.94 SPRT bound, but the trend across the full run is unambiguous and the marginal value of waiting out the remaining games was judged not worth it. The data.c tables are left in place as the inactive #else branch of FH_USE_REGRESSION_MODEL for now; a follow-up commit will flip the build default and remove them once this commit's head_reference baseline and ECM numbers are recorded. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/fhcollect.c')
-rw-r--r--src/fhcollect.c574
1 files changed, 574 insertions, 0 deletions
diff --git a/src/fhcollect.c b/src/fhcollect.c
new file mode 100644
index 0000000..780a26d
--- /dev/null
+++ b/src/fhcollect.c
@@ -0,0 +1,574 @@
+/*
+
+ fhcollect.c
+
+ Per-thread binary record emission for the learned FH% regression
+ collection (FH_REGRESSION_COLLECTION) -- see docs/REGRESSION.md.
+ Replaces the old FH_TABLE_COLLECTION histogram design: each logged
+ event (a whole-node row, or one row per move/null-move-probe tried
+ at an eligible node) is written directly to a per-thread binary
+ file, buffered in FH_COLLECT_BUFFER_RECORDS-sized batches to avoid
+ a syscall per record. No cross-thread state and no merge-back --
+ each thread's file is independent; the offline Python training
+ pipeline concatenates/loads them together, not this process.
+
+*/
+#include "chess.h"
+
+#ifdef FH_REGRESSION_COLLECTION
+
+// Registry of every writer that's ever been opened, purely so
+// FhCollectCloseAll (called once from MainProgramCleanup at real
+// process shutdown) can find and flush every thread's file without
+// main.c needing to hold a pointer to any SEARCHER_THREAD_CONTEXT --
+// the root thread's own ctx is a function-static local inside root.c's
+// Think()/Ponder(), not reachable from main.c directly.
+#define FH_COLLECT_MAX_WRITERS (64)
+static FH_COLLECT_WRITER *g_rgpFhCollectWriters[FH_COLLECT_MAX_WRITERS];
+static ULONG g_uFhCollectWriterCount = 0;
+
+
+void
+FhCollectOpen(SEARCHER_THREAD_CONTEXT *ctx, ULONG uThreadNumber)
+/**
+
+Routine description:
+
+ Open this thread's collection file if it isn't already open, and
+ register its writer so FhCollectCloseAll can find it at shutdown.
+ Safe to call more than once for the same ctx -- a no-op if the
+ file is already open. This matters because ctx is frequently a
+ reused, repeatedly-reinitialized struct (root.c's Think/Ponder use
+ a function-static SEARCHER_THREAD_CONTEXT across many searches in
+ one process) -- InitializeSearcherContext preserves an already-open
+ pFile across its own memset specifically so this function only
+ ever does real work once per thread's lifetime.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx,
+ ULONG uThreadNumber -- names the file; the root/main thread is 0,
+ helper threads are split.c's uMyId + 1
+
+Return value:
+
+ void
+
+**/
+{
+ FH_COLLECT_WRITER *w = &ctx->sFhCollectWriter;
+ CHAR szPath[SMALL_STRING_LEN_CHAR];
+
+ if (NULL != w->pFile)
+ {
+ return;
+ }
+ sprintf(szPath, "fhcollect_thread%02lu.bin", (unsigned long)uThreadNumber);
+ w->pFile = fopen(szPath, "ab");
+ if (NULL == w->pFile)
+ {
+ Bug("FhCollectOpen: unable to open '%s' for append.\n", szPath);
+ return;
+ }
+ w->uBufferedCount = 0;
+ if (g_uFhCollectWriterCount < FH_COLLECT_MAX_WRITERS)
+ {
+ g_rgpFhCollectWriters[g_uFhCollectWriterCount++] = w;
+ }
+ else
+ {
+ // Not fatal -- this writer just won't be proactively flushed
+ // by FhCollectCloseAll; its buffered tail (up to
+ // FH_COLLECT_BUFFER_RECORDS - 1 records) would only be lost if
+ // the process is killed rather than exiting cleanly through
+ // MainProgramCleanup. FH_COLLECT_MAX_WRITERS (64) comfortably
+ // exceeds any realistic MAX_SPLITS + 1, so this should never
+ // actually fire.
+ Bug("FhCollectOpen: FH_COLLECT_MAX_WRITERS exceeded -- this "
+ "writer won't be flushed by FhCollectCloseAll.\n");
+ }
+}
+
+
+void
+FhCollectFlush(SEARCHER_THREAD_CONTEXT *ctx)
+/**
+
+Routine description:
+
+ Write out this thread's currently-buffered records, if any, and
+ reset the buffer. Does not close the file.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx
+
+Return value:
+
+ void
+
+**/
+{
+ FH_COLLECT_WRITER *w = &ctx->sFhCollectWriter;
+
+ if ((NULL != w->pFile) && (w->uBufferedCount > 0))
+ {
+ fwrite(w->rgBuffer, sizeof(FH_COLLECT_RECORD), w->uBufferedCount, w->pFile);
+ w->uBufferedCount = 0;
+ }
+}
+
+
+void
+FhCollectEmit(SEARCHER_THREAD_CONTEXT *ctx, const FH_COLLECT_RECORD *prec)
+/**
+
+Routine description:
+
+ Buffer one record for this thread, flushing the whole batch to
+ disk once FH_COLLECT_BUFFER_RECORDS have accumulated. Opens the
+ file lazily on first use if it isn't already open.
+
+ Deliberately NOT opened eagerly from InitializeSearcherContext: a
+ helper thread's ctx->uThreadNumber isn't set to its real value
+ until immediately AFTER that call returns (split.c calls
+ InitializeSearcherContext(NULL, ctx) then sets
+ ctx->uThreadNumber = uMyId + 1 on the very next line) -- opening
+ eagerly inside InitializeSearcherContext would race and every
+ helper thread's first-ever open would collide on thread number 0.
+ By the time any real search activity reaches this function,
+ uThreadNumber is already correct for both the root thread (stays 0
+ throughout) and every helper thread.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx,
+ const FH_COLLECT_RECORD *prec
+
+Return value:
+
+ void
+
+**/
+{
+ FH_COLLECT_WRITER *w = &ctx->sFhCollectWriter;
+
+ // Subsampling: keep roughly 1-in-N events, chosen independently
+ // per event via randomMT() (mersenne.c) -- cheap, unbiased, and
+ // preserves the exact joint continuous feature vector of every
+ // KEPT row (unlike aggregating/bucketizing, which would
+ // reintroduce the combinatorial cardinality problem this whole
+ // design moved off the old table to avoid -- see
+ // docs/REGRESSION.md's "On subsampling vs. aggregation" section).
+ // Both denominators read once per process, not per call; unset or
+ // invalid means no subsampling (denom 1, keep everything).
+ //
+ // TIER-DEPENDENT, not uniform (2026-10-24 session finding): the
+ // beta-side per-move population (a real move/null-move/hash-move
+ // tried after a QUICK_NULL_MOVE/static-cutoff check already said
+ // this node looks won) is structurally RARE -- null-move or one
+ // of the first moves resolves the cutoff the overwhelming
+ // majority of the time, so actually reaching deeper into the move
+ // list at an already-winning node is scarce almost by
+ // construction, not something that gets proportionally more
+ // common the longer collection runs. It's ALSO the one population
+ // with no historical analog at all to fall back on (the old
+ // table's beta side was whole-node-only, never per-move -- see
+ // docs/REGRESSION.md). Applying the same aggressive denominator
+ // there as to the overwhelmingly common alpha-side leftover
+ // population was actively discarding an already-scarce, already-
+ // irreplaceable signal for no real disk benefit (this rare
+ // population's total volume is small regardless of its sampling
+ // rate) -- so it gets its own, much gentler (default: none)
+ // denominator instead. NO_MOVE/NULL/HASH on the beta side are NOT
+ // rare this way (they fire at essentially every eligible node,
+ // same as the alpha side) and stay on the common rate.
+ static ULONG uSampleDenom = 0;
+ static ULONG uRareSampleDenom = 0;
+ if (0 == uSampleDenom)
+ {
+ CHAR *pszDenom = getenv("FH_COLLECT_SAMPLE_DENOM");
+ uSampleDenom = (pszDenom && atol(pszDenom) > 0) ?
+ (ULONG)atol(pszDenom) : 1;
+ }
+ if (0 == uRareSampleDenom)
+ {
+ CHAR *pszRareDenom = getenv("FH_COLLECT_SAMPLE_DENOM_RARE");
+ uRareSampleDenom = (pszRareDenom && atol(pszRareDenom) > 0) ?
+ (ULONG)atol(pszRareDenom) : 1;
+ }
+ {
+ // "Common" = has real old-table backing AND dominates raw
+ // volume; everything else is "rare" and gets the gentle
+ // denominator instead, on EITHER table (2026-10-24 session
+ // finding, generalized from beta-only after Scott's own
+ // question -- alpha's HASH/GOOD_CAPTURE/KILLER/COUNTERMOVE/
+ // NULL are exactly as old-table-absent as beta's, since
+ // _ScoreAllMoves structurally excludes good captures and any
+ // killer/countermove-bonus-bearing move from the
+ // fIsLeftoverMove population the old alpha table actually
+ // measured -- LEFTOVER_QUIET and LOSING_CAPTURE both stay
+ // below GOOD_MOVE and so were both really covered historically,
+ // but nothing else was, on either side).
+ FLAG fIsCommon;
+ if (FHC_TABLE_ALPHA == prec->bTable)
+ {
+ fIsCommon = (FHC_TIER_NO_MOVE == prec->bTier) ||
+ (FHC_TIER_LEFTOVER_QUIET == prec->bTier) ||
+ (FHC_TIER_LOSING_CAPTURE == prec->bTier);
+ }
+ else
+ {
+ fIsCommon = (FHC_TIER_NO_MOVE == prec->bTier);
+ }
+ // Depth-dependent, on top of tier (2026-10-24 follow-up
+ // finding): a large REMAINING depth means this node sits
+ // close to the root, where the branching factor makes nodes
+ // structurally scarce compared to the shallow-remaining-depth
+ // population near the leaves. Applying the common tier's
+ // aggressive denominator uniformly across depth discards this
+ // already-scarce deep tail at the SAME rate as the
+ // overwhelmingly abundant shallow population -- so letting
+ // collection run longer scales both up together and never
+ // improves the deep tail's SHARE of the data. This is exactly
+ // what the beta-side rigorous spot-check kept showing even
+ // after hours of additional collection (depth-bucketed error
+ // climbing from ~2pp at shallow depths to 37pp at the deepest,
+ // with matched-cell sample counts (n_new) in the single/low
+ // double digits there throughout). Fix: once remaining depth
+ // passes a few plies, fall back to the gentle/rare
+ // denominator regardless of tier -- cheap, since this
+ // population is naturally small on its own.
+ if (fIsCommon && ((prec->uRemainingDepthTicks / ONE_PLY) >= 3))
+ {
+ fIsCommon = FALSE;
+ }
+ ULONG uEffectiveDenom = fIsCommon ? uSampleDenom : uRareSampleDenom;
+ if ((uEffectiveDenom > 1) && ((randomMT() % uEffectiveDenom) != 0))
+ {
+ return;
+ }
+ }
+
+ if (NULL == w->pFile)
+ {
+ FhCollectOpen(ctx, ctx->uThreadNumber);
+ if (NULL == w->pFile)
+ {
+ return; // open failed, already Bug()'d inside FhCollectOpen
+ }
+ }
+ w->rgBuffer[w->uBufferedCount++] = *prec;
+ if (w->uBufferedCount >= FH_COLLECT_BUFFER_RECORDS)
+ {
+ FhCollectFlush(ctx);
+ }
+}
+
+
+void
+FhCollectClose(SEARCHER_THREAD_CONTEXT *ctx)
+/**
+
+Routine description:
+
+ Flush and close this one thread's file, and deregister it. Most
+ callers want FhCollectCloseAll (used by MainProgramCleanup)
+ instead; this per-ctx version exists for any future caller that
+ tracks its own ctx directly and wants to close it before process
+ shutdown.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx
+
+Return value:
+
+ void
+
+**/
+{
+ FH_COLLECT_WRITER *w = &ctx->sFhCollectWriter;
+ ULONG u;
+
+ if (NULL == w->pFile)
+ {
+ return;
+ }
+ FhCollectFlush(ctx);
+ fclose(w->pFile);
+ w->pFile = NULL;
+ for (u = 0; u < g_uFhCollectWriterCount; u++)
+ {
+ if (g_rgpFhCollectWriters[u] == w)
+ {
+ g_rgpFhCollectWriters[u] = g_rgpFhCollectWriters[--g_uFhCollectWriterCount];
+ break;
+ }
+ }
+}
+
+
+void
+FhCollectCloseAll(void)
+/**
+
+Routine description:
+
+ Flush and close every registered writer -- call once, from
+ MainProgramCleanup, at real process shutdown, so no thread's final
+ buffered-but-unflushed records (up to FH_COLLECT_BUFFER_RECORDS - 1
+ of them, per thread) are silently dropped.
+
+Parameters:
+
+ void
+
+Return value:
+
+ void
+
+**/
+{
+ ULONG u;
+
+ for (u = 0; u < g_uFhCollectWriterCount; u++)
+ {
+ FH_COLLECT_WRITER *w = g_rgpFhCollectWriters[u];
+ if (NULL != w->pFile)
+ {
+ if (w->uBufferedCount > 0)
+ {
+ fwrite(w->rgBuffer, sizeof(FH_COLLECT_RECORD), w->uBufferedCount, w->pFile);
+ w->uBufferedCount = 0;
+ }
+ fclose(w->pFile);
+ w->pFile = NULL;
+ }
+ }
+ g_uFhCollectWriterCount = 0;
+}
+
+
+void
+FhCollectNodeEntry(SEARCHER_THREAD_CONTEXT *ctx,
+ FH_COLLECT_PENDING *pp,
+ SCORE iEval, SCORE iAlpha, SCORE iBeta,
+ ULONG uDepthTicks,
+ SCORE iImprove, SCORE iEnemyOrOwnImprove,
+ NODE_TYPE eNodeTypeGuess,
+ UINT64 u64PositionSig,
+ ULONG uTotalNonPawnMaterial)
+/**
+
+Routine description:
+
+ Decide this node's collection eligibility (iEval <= iAlpha -> alpha
+ table, iEval >= iBeta -> beta table, otherwise ineligible -- a real
+ PV node, out of scope for this collection per docs/REGRESSION.md)
+ and stash everything FhCollectMoveTried/FhCollectNodeExit will need
+ later into *pp. Does NOT emit anything yet -- the whole-node
+ (FHC_TIER_NO_MOVE) row's label isn't known until the real move
+ loop finishes, so its actual FhCollectEmit call happens in
+ FhCollectNodeExit, not here.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx,
+ FH_COLLECT_PENDING *pp,
+ SCORE iEval, SCORE iAlpha, SCORE iBeta,
+ ULONG uDepthTicks,
+ SCORE iImprove, SCORE iEnemyOrOwnImprove -- the beta table's own
+ second improve axis (enemy-improving); unused/ignored for an
+ alpha-side node, which has only one improve axis
+ NODE_TYPE eNodeTypeGuess -- the caller's PARENT-derived guess, not
+ the refined one (caller's responsibility, see chess.h's
+ FH_COLLECT_PENDING comment)
+ UINT64 u64PositionSig,
+ ULONG uTotalNonPawnMaterial
+
+Return value:
+
+ void
+
+**/
+{
+ (void)(ctx);
+
+ memset(pp, 0, sizeof(*pp));
+ if (iEval <= iAlpha)
+ {
+ pp->fEligible = TRUE;
+ pp->bTable = FHC_TABLE_ALPHA;
+ pp->iGap = (SCORE)(iAlpha - iEval);
+ }
+ else if (iEval >= iBeta)
+ {
+ pp->fEligible = TRUE;
+ pp->bTable = FHC_TABLE_BETA;
+ pp->iGap = (SCORE)(iEval - iBeta);
+ }
+ else
+ {
+ // A real PV node (alpha < eval < beta) -- out of scope for
+ // this collection, see docs/REGRESSION.md's "Scope" section.
+ pp->fEligible = FALSE;
+ return;
+ }
+ pp->bNodeTypeGuess = (UCHAR)eNodeTypeGuess;
+ pp->iImprove = iImprove;
+ pp->iEnemyOrOwnImprove = iEnemyOrOwnImprove;
+ pp->uRemainingDepthTicks = uDepthTicks;
+ pp->u64PositionSig = u64PositionSig;
+ pp->uGoodCapturesTriedNoCutoff = 0;
+ pp->uTotalNonPawnMaterial = uTotalNonPawnMaterial;
+}
+
+
+void
+FhCollectMoveTried(SEARCHER_THREAD_CONTEXT *ctx,
+ FH_COLLECT_PENDING *pp,
+ FH_COLLECT_TIER eTier,
+ SCORE iScore, SCORE iBeta,
+ ULONG uMoveOrdinal, SCORE iMoveValue,
+ ULONG uMoveFhPctThousandths, ULONG uMoveFhAttempts,
+ FLAG fMovesKnown, ULONG uTotalLegalMoves)
+/**
+
+Routine description:
+
+ Emit one record for a single move (or the null-move probe, or the
+ hash move) just tried at an eligible node, now that its own score
+ is known -- unlike the whole-node row, an individual move's label
+ (did IT resolve >= iBeta) is available immediately, no deferred
+ commit needed.
+
+ uGoodCapturesTriedNoCutoff is read into the emitted record BEFORE
+ being updated for this move, so the value a reader sees on any one
+ record reflects captures tried strictly before it, not including
+ it -- matching what would actually be knowable while this move was
+ being decided, were this feeding a live reduction/pruning decision
+ instead of just being logged.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx,
+ FH_COLLECT_PENDING *pp,
+ FH_COLLECT_TIER eTier,
+ SCORE iScore, SCORE iBeta,
+ ULONG uMoveOrdinal, SCORE iMoveValue,
+ ULONG uMoveFhPctThousandths, ULONG uMoveFhAttempts,
+ FLAG fMovesKnown, ULONG uTotalLegalMoves -- FALSE/0 for a move tried
+ before real move generation (the null-move probe, the hash
+ move -- see docs/REGRESSION.md's moves_known/imputation design)
+
+Return value:
+
+ void
+
+**/
+{
+ FH_COLLECT_RECORD rec;
+
+ if (FALSE == pp->fEligible)
+ {
+ return;
+ }
+
+ memset(&rec, 0, sizeof(rec));
+ rec.u64PositionSig = pp->u64PositionSig;
+ rec.iGap = pp->iGap;
+ rec.iMoveValue = iMoveValue;
+ rec.iImprove = pp->iImprove;
+ rec.iEnemyOrOwnImprove = pp->iEnemyOrOwnImprove;
+ rec.uRemainingDepthTicks = pp->uRemainingDepthTicks;
+ rec.uMoveOrdinal = uMoveOrdinal;
+ rec.uTotalLegalMoves = (TRUE == fMovesKnown) ? uTotalLegalMoves : 0;
+ rec.uMoveFhPctThousandths = uMoveFhPctThousandths;
+ rec.uMoveFhAttempts = uMoveFhAttempts;
+ rec.uGoodCapturesTriedNoCutoff = pp->uGoodCapturesTriedNoCutoff;
+ rec.uTotalNonPawnMaterial = pp->uTotalNonPawnMaterial;
+ rec.bTable = pp->bTable;
+ rec.bTier = (UCHAR)eTier;
+ rec.bNodeTypeGuess = pp->bNodeTypeGuess;
+ rec.bMovesKnown = (TRUE == fMovesKnown) ? 1 : 0;
+ rec.bLabel = (iScore >= iBeta) ? 1 : 0;
+ FhCollectEmit(ctx, &rec);
+
+ if ((FHC_TIER_GOOD_CAPTURE == eTier) && (iScore < iBeta))
+ {
+ pp->uGoodCapturesTriedNoCutoff++;
+ }
+}
+
+
+void
+FhCollectNodeExit(SEARCHER_THREAD_CONTEXT *ctx,
+ FH_COLLECT_PENDING *pp,
+ SCORE iBestScore, SCORE iBeta, FLAG fAborted)
+/**
+
+Routine description:
+
+ Emit the whole-node (FHC_TIER_NO_MOVE) row stashed by
+ FhCollectNodeEntry, now that iBestScore's real fate against iBeta
+ is known. Skipped entirely on an aborted/incomplete search
+ (iBestScore may not reflect a fully-searched node in that case, and
+ logging it as ground truth would bias the model toward whatever
+ got searched before the clock/node-limit fired) or if the node was
+ never eligible to begin with.
+
+Parameters:
+
+ SEARCHER_THREAD_CONTEXT *ctx,
+ FH_COLLECT_PENDING *pp,
+ SCORE iBestScore, SCORE iBeta, FLAG fAborted
+
+Return value:
+
+ void
+
+**/
+{
+ FH_COLLECT_RECORD rec;
+
+ if ((FALSE == pp->fEligible) || (TRUE == fAborted) ||
+ (!IS_VALID_SCORE(iBestScore)))
+ {
+ return;
+ }
+
+ memset(&rec, 0, sizeof(rec));
+ rec.u64PositionSig = pp->u64PositionSig;
+ rec.iGap = pp->iGap;
+ rec.iMoveValue = 0;
+ rec.iImprove = pp->iImprove;
+ rec.iEnemyOrOwnImprove = pp->iEnemyOrOwnImprove;
+ rec.uRemainingDepthTicks = pp->uRemainingDepthTicks;
+ rec.uMoveOrdinal = 0;
+ // Total legal moves is genuinely unknown to production at the
+ // point this whole-node prediction would actually run (before the
+ // move loop) -- logged as unknown here too, even though this
+ // collection build happens to know it by now (the real loop has
+ // already run by end:), so training data matches what's really
+ // available at inference time. See docs/REGRESSION.md.
+ rec.uTotalLegalMoves = 0;
+ rec.uMoveFhPctThousandths = 0;
+ rec.uGoodCapturesTriedNoCutoff = pp->uGoodCapturesTriedNoCutoff;
+ rec.uTotalNonPawnMaterial = pp->uTotalNonPawnMaterial;
+ rec.bTable = pp->bTable;
+ rec.bTier = FHC_TIER_NO_MOVE;
+ rec.bNodeTypeGuess = pp->bNodeTypeGuess;
+ rec.bMovesKnown = FALSE;
+ rec.bLabel = (iBestScore >= iBeta) ? 1 : 0;
+ FhCollectEmit(ctx, &rec);
+}
+
+
+#endif // FH_REGRESSION_COLLECTION
+// FhClassifyMoveTier itself moved to searchsup.c (always compiled,
+// not gated by FH_REGRESSION_COLLECTION) -- fhmodel.c's production
+// inference needs it unconditionally too, and it's pure/stateless (no
+// dependency on anything collection-specific), so there's no reason
+// to duplicate it or leave it gated. See searchsup.c for the
+// implementation and chess.h for the now-unconditional declaration.