1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
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.
|