// fhmodel.c -- production inference for the FH% logistic-regression // models. See fhmodel.h and docs/REGRESSION.md. // // Fully quantized: every runtime computation here is integer // fixed-point (Q16 for table/final-sum arithmetic, Q30 for the // handful of raw-feature weights -- see fhmodel_weights.h's header // comment for why two scales). No floats, no log()/exp() calls. #include "chess.h" #include "fhmodel_weights.h" // Mate-score window gate -- see fhmodel_weights.h's // FHM_MATE_GAP_THRESHOLD (matches load_records.py's // MATE_GAP_THRESHOLD exactly) and docs/REGRESSION.md's mate-window // note. A `gap` this large means the static eval is itself encoding // a detected mate score compared against an ordinary cp-scale // window -- a structurally different regime this model was never // trained on (those rows were excluded entirely, not just clipped) // and must not extrapolate into. static INLINE INT _FhmLog1pLookup(const INT *table, ULONG uMaxIndex, ULONG uValue) { ULONG uClamped = (uValue > uMaxIndex) ? uMaxIndex : uValue; return(table[uClamped]); } // Converts a Q16 fixed-point logit into the sigmoid*100000 return // convention, via table lookup only (no exp() call). Linearly maps // the clamped logit into the table's index range. static ULONG _FhmLogitQ16ToThousandths(INT iLogitQ16) { INT64 iRangeQ16 = (INT64)(FHM_SIGMOID_LOGIT_RANGE * (1 << FHM_Q_TABLE)); INT64 iClamped = iLogitQ16; INT64 iIndex; if (iClamped < -iRangeQ16) iClamped = -iRangeQ16; if (iClamped > iRangeQ16) iClamped = iRangeQ16; // index = (clamped + range) / (2*range) * (TABLE_SIZE - 1) iIndex = ((iClamped + iRangeQ16) * (FHM_SIGMOID_TABLE_SIZE - 1)) / (2 * iRangeQ16); if (iIndex < 0) iIndex = 0; if (iIndex >= FHM_SIGMOID_TABLE_SIZE) iIndex = FHM_SIGMOID_TABLE_SIZE - 1; return((ULONG)g_rgiSigmoidThousandthsQ16[iIndex]); } // Shared node-level logit (Q16), common to both tables' identical // 7-feature node-level set. iGap is the caller's own "how far past // the relevant edge" quantity (always >= 0 by construction -- see // callers), already confirmed <= FHM_MATE_GAP_THRESHOLD by the // caller's own gate before this is ever reached. static INT _FhmNodeLogitQ16(const INT *rgiLogGap, const INT *rgiLogGapOverflow, const INT *rgiLogDepth, const INT *rgiLogMaterial, const INT *rgiNodeTypeWeight, INT iWImprove, INT iWEnemyImprove, INT iWImproveXEnemy, ULONG uGapCap, SCORE iGap, ULONG uDepth, SCORE iImprovement, SCORE iEnemyImprovement, ULONG uTotalNonPawnMaterial, NODE_TYPE eNodeTypeGuess) { ULONG uGapNonNeg = (iGap > 0) ? (ULONG)iGap : 0; ULONG uGapClipped = (uGapNonNeg < uGapCap) ? uGapNonNeg : uGapCap; ULONG uGapOverflow = (uGapNonNeg > uGapCap) ? (uGapNonNeg - uGapCap) : 0; ULONG uDepthPlies = uDepth / ONE_PLY; INT64 iSumQ16 = 0; iSumQ16 += _FhmLog1pLookup(rgiLogGap, uGapCap, uGapClipped); iSumQ16 += _FhmLog1pLookup(rgiLogGapOverflow, FHM_MATE_GAP_THRESHOLD - uGapCap, uGapOverflow); iSumQ16 += _FhmLog1pLookup(rgiLogDepth, FHM_DEPTH_PLIES_MAX, uDepthPlies); iSumQ16 += _FhmLog1pLookup(rgiLogMaterial, FHM_MATERIAL_MAX, uTotalNonPawnMaterial); iSumQ16 += rgiNodeTypeWeight[(ULONG)eNodeTypeGuess]; // Raw (non-tabulated) terms: Q30 weight * raw integer value, // rescaled down to the shared Q16 accumulation frame. iSumQ16 += ((INT64)iWImprove * (INT64)iImprovement) >> FHM_RAW_SHIFT; iSumQ16 += ((INT64)iWEnemyImprove * (INT64)iEnemyImprovement) >> FHM_RAW_SHIFT; iSumQ16 += ((INT64)iWImproveXEnemy * ((INT64)iImprovement * (INT64)iEnemyImprovement)) >> FHM_RAW_SHIFT; return((INT)iSumQ16); } FHM_NODE_BASE FhModelAlphaNodeBaseQ16(IN SCORE iEval, IN SCORE iAlpha, IN ULONG uDepth, IN SCORE iImprovement, IN SCORE iEnemyImprovement, IN ULONG uTotalNonPawnMaterial, IN NODE_TYPE eNodeTypeGuess) { SCORE iGap = (SCORE)(iAlpha - iEval); INT iLogitQ16; if (iGap > FHM_MATE_GAP_THRESHOLD) { return((FHM_NODE_BASE)FHM_MATE_GATED); } iLogitQ16 = _FhmNodeLogitQ16( g_rgiAlphaLogGapQ16, g_rgiAlphaLogGapOverflowQ16, g_rgiAlphaLogDepthQ16, g_rgiAlphaLogMaterialQ16, g_rgiAlphaNodeTypeWeightQ16, FHM_ALPHA_W_IMPROVE_Q30, FHM_ALPHA_W_ENEMY_IMPROVE_Q30, FHM_ALPHA_W_IMPROVE_X_ENEMY_Q30, FHM_GAP_CAP_ALPHA, iGap, uDepth, iImprovement, iEnemyImprovement, uTotalNonPawnMaterial, eNodeTypeGuess); iLogitQ16 += FHM_ALPHA_INTERCEPT_Q16; return((FHM_NODE_BASE)iLogitQ16); } ULONG FhModelAlphaMoveThousandths(IN FHM_NODE_BASE nodeBase, IN SCORE iMoveValue, IN ULONG uMoveOrdinal, IN ULONG uMoveFhPctThousandths, IN ULONG uMoveFhAttempts, IN FH_COLLECT_TIER eTier) { INT64 iSumQ16 = (INT64)(INT)nodeBase; iSumQ16 += _FhmLog1pLookup(g_rgiAlphaLogOrdinalQ16, FHM_ORDINAL_MAX, uMoveOrdinal); iSumQ16 += _FhmLog1pLookup(g_rgiAlphaLogFhAttemptsQ16, FHM_FH_ATTEMPTS_MAX, uMoveFhAttempts); iSumQ16 += g_rgiAlphaTierWeightQ16[(ULONG)eTier]; iSumQ16 += ((INT64)FHM_ALPHA_W_MOVE_VALUE_Q30 * (INT64)iMoveValue) >> FHM_RAW_SHIFT; iSumQ16 += ((INT64)FHM_ALPHA_W_MOVE_FH_PCT_Q30 * (INT64)uMoveFhPctThousandths) >> FHM_RAW_SHIFT; return(_FhmLogitQ16ToThousandths((INT)iSumQ16)); } // Whole-node-only -- see fhmodel.h. Move-level terms fixed at exactly // the defaults a FHC_TIER_NO_MOVE training row had (fhcollect.c's // FhCollectNodeExit: iMoveValue=0, uMoveOrdinal=0, // uMoveFhPctThousandths=0, uMoveFhAttempts=0) -- log1p(0)==0 and // 0*weight==0 for all of them, so this collapses to the node logit + // tier weight + intercept, but computed via the same lookup path as // the alpha function for consistency. ULONG FhModelBetaPredictedFHThousandths(IN SCORE iEval, IN SCORE iBeta, IN ULONG uDepth, IN SCORE iImprovement, IN SCORE iEnemyImprovement, IN ULONG uTotalNonPawnMaterial, IN NODE_TYPE eNodeTypeGuess) { SCORE iGap = (SCORE)(iEval - iBeta); INT64 iSumQ16; if (iGap > FHM_MATE_GAP_THRESHOLD) { return(MOVE_FH_ESTIMATE_NO_DATA); } iSumQ16 = _FhmNodeLogitQ16( g_rgiBetaLogGapQ16, g_rgiBetaLogGapOverflowQ16, g_rgiBetaLogDepthQ16, g_rgiBetaLogMaterialQ16, g_rgiBetaNodeTypeWeightQ16, FHM_BETA_W_IMPROVE_Q30, FHM_BETA_W_ENEMY_IMPROVE_Q30, FHM_BETA_W_IMPROVE_X_ENEMY_Q30, FHM_GAP_CAP_BETA, iGap, uDepth, iImprovement, iEnemyImprovement, uTotalNonPawnMaterial, eNodeTypeGuess); iSumQ16 += g_rgiBetaTierWeightQ16[(ULONG)FHC_TIER_NO_MOVE]; iSumQ16 += FHM_BETA_INTERCEPT_Q16; return(_FhmLogitQ16ToThousandths((INT)iSumQ16)); } // See fhmodel.h's comment: this engine's own convention for "is this // the hash move" is x==0 (confirmed by grep -- there's no separately // stored mvHash compared against mv anywhere in search.c's move // loop), so this does NOT reuse FhClassifyMoveTier's mv-vs-mvHash // comparison (which would need a real mvHash, not available here) -- // it inlines the same capture/killer/countermove decode logic that // function uses for everything past the hash check. FH_COLLECT_TIER FhModelTierForMove(IN ULONG x, IN MOVE mv, IN SCORE iMoveSortValue) { FLAG fIsCaptureOrPromotion; if (0 == x) { return(FHC_TIER_HASH); } fIsCaptureOrPromotion = IS_CAPTURE_OR_PROMOTION(mv); if (0 != fIsCaptureOrPromotion) { return((iMoveSortValue >= SORT_THESE_FIRST) ? FHC_TIER_GOOD_CAPTURE : FHC_TIER_LOSING_CAPTURE); } if (0 != (iMoveSortValue & (FIRST_KILLER | SECOND_KILLER | THIRD_KILLER | FOURTH_KILLER))) { return(FHC_TIER_KILLER); } if (COUNTERMOVE_TIER_BONUS == (iMoveSortValue & COUNTERMOVE_TIER_BONUS)) { return(FHC_TIER_COUNTERMOVE); } return(FHC_TIER_LEFTOVER_QUIET); }