Skip to content

Commit 5a93795

Browse files
committed
feat(eval): support calibrated classical value readouts
Add optional monotone, context-aware, and concentration-based post-processing for freestyle classical values while preserving inactive behavior. Stage config publication, rebuild concentration history safely across reloads, and extend parity reconstruction through the shared runtime path.
1 parent a5ba278 commit 5a93795

7 files changed

Lines changed: 342 additions & 66 deletions

File tree

Rapfi/config.cpp

Lines changed: 111 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333

3434
#include <algorithm>
3535
#include <array>
36-
#include <cpptoml.h>
3736
#include <cmath>
37+
#include <cpptoml.h>
3838
#include <cstdint>
3939
#include <cstring>
4040
#include <fstream>
@@ -64,15 +64,13 @@ constexpr uint32_t ClassicalModelDrawHeadVersion = 14;
6464
constexpr uint32_t ModelPolicyTableCount = 1;
6565
constexpr uint32_t ModelPolicyContextCount = Evaluation::PolicyStoredContextCount;
6666
constexpr uint32_t ModelBlendComponentCount = 0;
67-
constexpr uint32_t ModelDrawHeadParameterCount =
68-
Evaluation::ClassicalDrawHead::ParameterCount;
67+
constexpr uint32_t ModelDrawHeadParameterCount = Evaluation::ClassicalDrawHead::ParameterCount;
6968
constexpr uint32_t PolicyCrossPatternCount = Evaluation::PolicyStoredPatternCount;
7069
constexpr uint32_t PolicyCrossPayloadBytes =
7170
ModelPolicyContextCount * PolicyCrossPatternCount * PolicyCrossPatternCount * sizeof(Score);
7271
constexpr uint32_t ClassicalModelCompactP3PayloadBytes = PolicyCrossPayloadBytes;
7372
constexpr uint32_t ClassicalModelDrawHeadPayloadBytes =
74-
PolicyCrossPayloadBytes + sizeof(uint32_t)
75-
+ ModelDrawHeadParameterCount * sizeof(double);
73+
PolicyCrossPayloadBytes + sizeof(uint32_t) + ModelDrawHeadParameterCount * sizeof(double);
7674
constexpr double ClassicalDrawHeadCoefficientLimit = 64.0;
7775

7876
static_assert(sizeof(double) == 8 && std::numeric_limits<double>::is_iec559);
@@ -94,14 +92,16 @@ float ScalingFactor = 200.0f;
9492
// Classical evaluation and score tables
9593
// Note that Renju has asymmetry eval and score
9694

97-
Eval EVALS[RULE_NB + 1][PCODE_NB];
98-
Eval EVALS_THREAT[RULE_NB + 1][THREAT_NB];
99-
MoveScorePair P4SCORES[RULE_NB + 1][PCODE_NB];
100-
ClassicalDrawHead CLASSICAL_DRAW_HEAD;
95+
Eval EVALS[RULE_NB + 1][PCODE_NB];
96+
Eval EVALS_THREAT[RULE_NB + 1][THREAT_NB];
97+
MoveScorePair P4SCORES[RULE_NB + 1][PCODE_NB];
98+
ClassicalDrawHead CLASSICAL_DRAW_HEAD;
10199
std::array<float, VALUE_EVAL_MAX - VALUE_EVAL_MIN + 1> CLASSICAL_DECISIVE_WIN_RATE {};
102-
Score POLICY_CROSS[RULE_NB + 1][PolicyContextCount][PATTERN4_NB][PATTERN4_NB];
103-
uint8_t POLICY_CROSS_ACTIVE_MASK[RULE_NB + 1] = {};
104-
bool POLICY_CROSS_PRESENT = false;
100+
ClassicalValueReadout CLASSICAL_VALUE_READOUT;
101+
std::array<Value, VALUE_EVAL_MAX - VALUE_EVAL_MIN + 1> CLASSICAL_VALUE_READOUT_CACHE {};
102+
Score POLICY_CROSS[RULE_NB + 1][PolicyContextCount][PATTERN4_NB][PATTERN4_NB];
103+
uint8_t POLICY_CROSS_ACTIVE_MASK[RULE_NB + 1] = {};
104+
bool POLICY_CROSS_PRESENT = false;
105105

106106
void resetClassicalDrawHead()
107107
{
@@ -130,6 +130,38 @@ float classicalDecisiveWinRate(Value rawValue)
130130
return CLASSICAL_DECISIVE_WIN_RATE[int(rawValue) - VALUE_EVAL_MIN];
131131
}
132132

133+
void refreshClassicalValueReadoutCache()
134+
{
135+
static constexpr std::array<double, ClassicalValueReadout::KnotCount> XKnots =
136+
{0.0, 0.5, 1.0, 2.0, 4.0, 8.0};
137+
const auto &yKnots = CLASSICAL_VALUE_READOUT.knots;
138+
const double scale = ScalingFactor;
139+
140+
for (int raw = VALUE_EVAL_MIN; raw <= VALUE_EVAL_MAX; raw++) {
141+
double x = std::abs(double(raw)) / scale;
142+
double y;
143+
if (x >= XKnots.back())
144+
y = yKnots.back() + x - XKnots.back();
145+
else {
146+
size_t hi = 1;
147+
while (x > XKnots[hi])
148+
hi++;
149+
size_t lo = hi - 1;
150+
double t = (x - XKnots[lo]) / (XKnots[hi] - XKnots[lo]);
151+
y = yKnots[lo] + t * (yKnots[hi] - yKnots[lo]);
152+
}
153+
double mapped = std::copysign(y * scale, double(raw));
154+
CLASSICAL_VALUE_READOUT_CACHE[raw - VALUE_EVAL_MIN] =
155+
Value(std::clamp<long long>(std::llround(mapped), VALUE_EVAL_MIN, VALUE_EVAL_MAX));
156+
}
157+
}
158+
159+
Value mapClassicalValue(Value rawValue)
160+
{
161+
assert(VALUE_EVAL_MIN <= rawValue && rawValue <= VALUE_EVAL_MAX);
162+
return CLASSICAL_VALUE_READOUT_CACHE[int(rawValue) - VALUE_EVAL_MIN];
163+
}
164+
133165
void resetPolicyCross()
134166
{
135167
std::memset(POLICY_CROSS, 0, sizeof(POLICY_CROSS));
@@ -183,11 +215,12 @@ GeneralConfig GeneralCfg;
183215
/// Nothing is published until loadConfig's commit step.
184216
struct PendingConfig
185217
{
186-
GeneralConfig general = GeneralCfg;
187-
Search::SearchConfig search = Search::SearchCfg;
188-
Search::TimeConfig time = Search::TimeCfg;
189-
Database::DatabaseConfig database = Database::DatabaseCfg;
190-
Evaluation::EvaluatorConfig eval = Evaluation::EvalCfg;
218+
GeneralConfig general = GeneralCfg;
219+
Search::SearchConfig search = Search::SearchCfg;
220+
Search::TimeConfig time = Search::TimeCfg;
221+
Database::DatabaseConfig database = Database::DatabaseCfg;
222+
Evaluation::EvaluatorConfig eval = Evaluation::EvalCfg;
223+
Evaluation::ClassicalValueReadout valueReadout = Evaluation::CLASSICAL_VALUE_READOUT;
191224

192225
/// "[search] default_searcher" was present: switch the searcher at commit.
193226
std::optional<std::string> searcherName;
@@ -276,11 +309,14 @@ bool Config::loadConfig(std::istream &configStream)
276309
}
277310

278311
// Commit: publish the parsed structs, then apply the engine effects.
279-
GeneralCfg = pending.general;
280-
Search::SearchCfg = pending.search;
281-
Search::TimeCfg = pending.time;
282-
Database::DatabaseCfg = pending.database;
283-
Evaluation::EvalCfg = pending.eval;
312+
GeneralCfg = pending.general;
313+
Search::SearchCfg = pending.search;
314+
Search::TimeCfg = pending.time;
315+
Database::DatabaseCfg = pending.database;
316+
Evaluation::EvalCfg = pending.eval;
317+
Evaluation::CLASSICAL_VALUE_READOUT = pending.valueReadout;
318+
if (Evaluation::CLASSICAL_VALUE_READOUT.knotsActive)
319+
Evaluation::refreshClassicalValueReadoutCache();
284320

285321
// The searcher switch precedes the TT resize: setupSearcher carries the
286322
// old searcher's memory limit onto the new one, and the resize then
@@ -309,7 +345,7 @@ void Config::readRequirement(const cpptoml::table &t)
309345
{
310346
auto [major, minor, revision] = getVersionNumbers();
311347
uint64_t rapVer = ((uint64_t)major << 32) | ((uint64_t)minor << 16) | (uint64_t)revision;
312-
auto composeVersion = [](const std::vector<int64_t> &ver, const char *key) {
348+
auto composeVersion = [](const std::vector<int64_t> &ver, const char *key) {
313349
if (ver.size() != 3)
314350
throw std::runtime_error(std::string("illegal ") + key);
315351
for (int64_t component : ver)
@@ -509,6 +545,12 @@ void Config::readModel(const cpptoml::table &t, PendingConfig &pending)
509545
const Rule Rules[] = {FREESTYLE, STANDARD, RENJU};
510546
const char *RuleName[] = {"freestyle", "standard", "renju"};
511547

548+
uint64_t nextRevision = Evaluation::CLASSICAL_VALUE_READOUT.revision + 1;
549+
if (nextRevision == 0)
550+
nextRevision = 1;
551+
pending.valueReadout = {};
552+
pending.valueReadout.revision = nextRevision;
553+
512554
std::string modelPath = t.get_as<std::string>("binary_file").value_or("");
513555
if (!modelPath.empty()) {
514556
if (!Command::loadModelFromFile(modelPath))
@@ -607,10 +649,50 @@ void Config::readModel(const cpptoml::table &t, PendingConfig &pending)
607649
double configuredScalingFactor =
608650
t.get_as<double>("scaling_factor").value_or(Evaluation::ScalingFactor);
609651
float runtimeScalingFactor = static_cast<float>(configuredScalingFactor);
610-
if (Evaluation::isClassicalDrawHeadActive()
652+
auto readoutKnots = t.get_array_of<double>("value_readout_knots");
653+
if (readoutKnots) {
654+
if (readoutKnots->size() != Evaluation::ClassicalValueReadout::KnotCount)
655+
throw std::runtime_error("value_readout_knots must contain 6 values");
656+
for (size_t i = 0; i < readoutKnots->size(); i++) {
657+
double value = (*readoutKnots)[i];
658+
if (!std::isfinite(value) || value < 0.0 || value > 64.0 || (i == 0 && value != 0.0)
659+
|| (i != 0 && value < (*readoutKnots)[i - 1]))
660+
throw std::runtime_error("value_readout_knots must be finite, monotone, start at "
661+
"zero, and not exceed 64");
662+
pending.valueReadout.knots[i] = value;
663+
}
664+
pending.valueReadout.knotsActive = true;
665+
}
666+
667+
auto readoutContext = t.get_array_of<double>("value_readout_context");
668+
if (readoutContext) {
669+
if (!readoutKnots)
670+
throw std::runtime_error("value_readout_context requires value_readout_knots");
671+
if (readoutContext->size() != Evaluation::ClassicalValueReadout::ContextCount)
672+
throw std::runtime_error("value_readout_context must contain 4 values");
673+
for (size_t i = 0; i < readoutContext->size(); i++) {
674+
double value = (*readoutContext)[i];
675+
if (!std::isfinite(value) || std::abs(value) > 8.0)
676+
throw std::runtime_error(
677+
"value_readout_context values must be finite and within [-8, 8]");
678+
pending.valueReadout.context[i] = value;
679+
}
680+
pending.valueReadout.contextActive =
681+
std::any_of(pending.valueReadout.context.begin(),
682+
pending.valueReadout.context.end(),
683+
[](double coefficient) { return coefficient != 0.0; });
684+
}
685+
686+
double concentration = t.get_as<double>("value_concentration").value_or(0.0);
687+
if (!std::isfinite(concentration) || std::abs(concentration) > 64.0)
688+
throw std::runtime_error("value_concentration must be finite and within [-64, 64]");
689+
pending.valueReadout.concentration = concentration;
690+
691+
if ((Evaluation::isClassicalDrawHeadActive() || pending.valueReadout.knotsActive
692+
|| pending.valueReadout.concentration != 0.0)
611693
&& (!std::isfinite(runtimeScalingFactor) || runtimeScalingFactor <= 0.0f))
612694
throw std::runtime_error(
613-
"classical draw head requires a finite positive scaling_factor");
695+
"classical value post-processing requires a finite positive scaling_factor");
614696
Evaluation::ScalingFactor = runtimeScalingFactor;
615697
if (Evaluation::isClassicalDrawHeadActive())
616698
Evaluation::refreshClassicalDrawHeadCache();
@@ -950,8 +1032,7 @@ bool Config::loadModel(std::istream &inStream)
9501032
in->read(reinterpret_cast<char *>(&componentCount), sizeof(componentCount));
9511033
in->read(reinterpret_cast<char *>(&patternCount), sizeof(patternCount));
9521034
if (!*in || magic != ClassicalModelExtensionMagic
953-
|| (version != ClassicalModelCompactP3Version
954-
&& version != ClassicalModelDrawHeadVersion)
1035+
|| (version != ClassicalModelCompactP3Version && version != ClassicalModelDrawHeadVersion)
9551036
|| tableCount != ModelPolicyTableCount || contextCount != ModelPolicyContextCount
9561037
|| patternCount != PolicyCrossPatternCount)
9571038
return false;
@@ -1040,11 +1121,11 @@ void Config::exportModel(std::ostream &outStream)
10401121
writeDrawHead ? ClassicalModelDrawHeadVersion : ClassicalModelCompactP3Version;
10411122
uint32_t payloadBytes =
10421123
writeDrawHead ? ClassicalModelDrawHeadPayloadBytes : ClassicalModelCompactP3PayloadBytes;
1043-
uint32_t tableCount = ModelPolicyTableCount;
1044-
uint32_t contextCount = ModelPolicyContextCount;
1124+
uint32_t tableCount = ModelPolicyTableCount;
1125+
uint32_t contextCount = ModelPolicyContextCount;
10451126
uint32_t componentCount =
10461127
writeDrawHead ? ModelDrawHeadParameterCount : ModelBlendComponentCount;
1047-
uint32_t patternCount = PolicyCrossPatternCount;
1128+
uint32_t patternCount = PolicyCrossPatternCount;
10481129
out->write(reinterpret_cast<const char *>(&version), sizeof(version));
10491130
out->write(reinterpret_cast<const char *>(&payloadBytes), sizeof(payloadBytes));
10501131
out->write(reinterpret_cast<const char *>(&tableCount), sizeof(tableCount));

Rapfi/eval/eval.cpp

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919
#include "eval.h"
2020

21-
#include "evalconfig.h"
2221
#include "../game/board.h"
22+
#include "evalconfig.h"
2323
#include "evaluator.h"
2424

2525
#include <algorithm>
@@ -76,7 +76,8 @@ int makeThreatMask(const StateInfo &st, Color self)
7676
template <Rule R>
7777
inline Value evaluateThreat(const StateInfo &st, Color self)
7878
{
79-
return (Value)Evaluation::EVALS_THREAT[Evaluation::tableIndex(R, self)][makeThreatMask(st, self)];
79+
return (
80+
Value)Evaluation::EVALS_THREAT[Evaluation::tableIndex(R, self)][makeThreatMask(st, self)];
8081
}
8182

8283
/// Evaluates basic patterns on board.
@@ -92,8 +93,7 @@ inline int classicalEvalMargin(Value bound)
9293
float winLossRate = 2 * (Evaluation::valueToWinRate(bound) - 0.5f);
9394
float x = EvalCfg.marginWinLossScale * winLossRate;
9495
float x2 = x * x;
95-
return (int)(EvalCfg.marginScale
96-
* ::expf(-::powf(x2, EvalCfg.marginWinLossExponent)));
96+
return (int)(EvalCfg.marginScale * ::expf(-::powf(x2, EvalCfg.marginWinLossExponent)));
9797
}
9898

9999
} // namespace
@@ -115,7 +115,7 @@ Value evaluate(const Board &board, Value alpha, Value beta)
115115
Value threatEval = evaluateThreat<R>(st0, self);
116116
Value eval = std::clamp(basicEval + threatEval, VALUE_EVAL_MIN, VALUE_EVAL_MAX);
117117
Value classicalEval =
118-
isClassicalDrawHeadActive() ? computeClassicalValue(board, eval).value() : eval;
118+
isClassicalPostprocessActive(R) ? computeClassicalValue(board, R, eval).value() : eval;
119119

120120
if (board.evaluator()) {
121121
// Use evaluator eval if classical eval are in alpha-beta window margin
@@ -157,28 +157,66 @@ Value evaluate(const Board &board, Rule rule)
157157
}
158158

159159
Value eval = std::clamp(basicEval + threatEval, VALUE_EVAL_MIN, VALUE_EVAL_MAX);
160-
if (isClassicalDrawHeadActive())
161-
return computeClassicalValue(board, eval).value();
160+
if (isClassicalPostprocessActive(rule))
161+
return computeClassicalValue(board, rule, eval).value();
162162
return eval;
163163
}
164164
}
165165

166-
ValueType computeClassicalValue(const Board &board, Value rawValue)
166+
ValueType computeClassicalValue(const Board &board, Rule rule, Value rawValue)
167167
{
168+
int mask = 0;
169+
if (isClassicalDrawHeadActive() || isClassicalValueContextActive(rule))
170+
mask = makeThreatMask(board.stateInfo(), board.sideToMove());
171+
172+
Value decisiveValue = rawValue;
173+
if (isClassicalValueReadoutActive(rule)) {
174+
decisiveValue = mapClassicalValue(rawValue);
175+
if (isClassicalValueContextActive(rule)) {
176+
constexpr unsigned SelfBits = (1U << 1) | (1U << 3) | (1U << 4) | (1U << 5) | (1U << 6);
177+
constexpr unsigned OpponentBits =
178+
(1U << 0) | (1U << 2) | (1U << 7) | (1U << 8) | (1U << 9) | (1U << 10);
179+
const double u = double(board.nonPassMoveCount()) / board.cellCount();
180+
const bool selfForcing = unsigned(mask) & SelfBits;
181+
const bool opponentForcing = unsigned(mask) & OpponentBits;
182+
const auto &c = CLASSICAL_VALUE_READOUT.context;
183+
double gain = 1.0 + c[0] * u + c[1] * u * u
184+
+ c[2] * double(selfForcing || opponentForcing)
185+
+ c[3] * double(selfForcing && opponentForcing);
186+
gain = std::clamp(gain, 0.25, 4.0);
187+
decisiveValue = Value(std::clamp<long long>(std::llround(double(decisiveValue) * gain),
188+
VALUE_EVAL_MIN,
189+
VALUE_EVAL_MAX));
190+
}
191+
}
192+
193+
if (isClassicalConcentrationActive(rule)) {
194+
int64_t current = board.classicalValueConcentration(rule);
195+
int64_t previous = board.ply() > 0 ? board.classicalValueConcentration(rule, 1) : current;
196+
if (board.sideToMove() == WHITE) {
197+
current = -current;
198+
previous = -previous;
199+
}
200+
double averaged = 0.5 * (double(current) + double(previous));
201+
double residual = CLASSICAL_VALUE_READOUT.concentration * averaged
202+
/ (double(ScalingFactor) * board.cellCount());
203+
decisiveValue = Value(std::clamp<long long>(std::llround(double(decisiveValue) + residual),
204+
VALUE_EVAL_MIN,
205+
VALUE_EVAL_MAX));
206+
}
207+
168208
if (!isClassicalDrawHeadActive())
169-
return ValueType(rawValue);
209+
return ValueType(decisiveValue);
170210

171-
constexpr double QMax = 8.0;
211+
constexpr double QMax = 8.0;
172212
const double scale = ScalingFactor;
173213
const double area = board.cellCount();
174214
const double ell = ClassicalBoardLogSize[board.cellCount()];
175215
const double u = board.nonPassMoveCount() / area;
176-
const double q = std::min(std::abs(double(rawValue)) / scale, QMax);
216+
const double q = std::min(std::abs(double(decisiveValue)) / scale, QMax);
177217
const auto &b = CLASSICAL_DRAW_HEAD.coefficients;
178-
double drawLogit =
179-
b[0] + b[1] * ell + b[2] * u + b[3] * u * u + b[4] * ell * u + b[5] * q;
218+
double drawLogit = b[0] + b[1] * ell + b[2] * u + b[3] * u * u + b[4] * ell * u + b[5] * q;
180219

181-
int mask = makeThreatMask(board.stateInfo(), board.sideToMove());
182220
int selfClass = mask & (1 << 1) ? 3
183221
: mask & ((1 << 3) | (1 << 4)) ? 2
184222
: mask & ((1 << 5) | (1 << 6)) ? 1
@@ -196,8 +234,7 @@ ValueType computeClassicalValue(const Board &board, Value rawValue)
196234
if (mutual)
197235
drawLogit += b[13];
198236

199-
constexpr unsigned SelfBits =
200-
(1U << 1) | (1U << 3) | (1U << 4) | (1U << 5) | (1U << 6);
237+
constexpr unsigned SelfBits = (1U << 1) | (1U << 3) | (1U << 4) | (1U << 5) | (1U << 6);
201238
constexpr unsigned OpponentBits =
202239
(1U << 0) | (1U << 2) | (1U << 7) | (1U << 8) | (1U << 9) | (1U << 10);
203240
int selfMultiplicity = std::min(popcount(unsigned(mask) & SelfBits), 3);
@@ -218,12 +255,12 @@ ValueType computeClassicalValue(const Board &board, Value rawValue)
218255
return expPositive / (1.0 + expPositive);
219256
};
220257

221-
const double draw = sigmoid(drawLogit);
222-
const double decisiveWin = classicalDecisiveWinRate(rawValue);
223-
float drawProb = static_cast<float>(draw);
224-
float lossProb = static_cast<float>((1.0 - draw) * (1.0 - decisiveWin));
225-
float winProb = static_cast<float>((1.0 - draw) * decisiveWin);
226-
float inverseSum = 1.0f / (winProb + lossProb + drawProb);
258+
const double draw = sigmoid(drawLogit);
259+
const double decisiveWin = classicalDecisiveWinRate(decisiveValue);
260+
float drawProb = static_cast<float>(draw);
261+
float lossProb = static_cast<float>((1.0 - draw) * (1.0 - decisiveWin));
262+
float winProb = static_cast<float>((1.0 - draw) * decisiveWin);
263+
float inverseSum = 1.0f / (winProb + lossProb + drawProb);
227264
winProb *= inverseSum;
228265
lossProb *= inverseSum;
229266
drawProb *= inverseSum;

Rapfi/eval/eval.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Value evaluate(const Board &board, Rule rule);
3131

3232
class ValueType;
3333
ValueType computeEvaluatorValue(const Board &board);
34-
ValueType computeClassicalValue(const Board &board, Value rawValue);
34+
ValueType computeClassicalValue(const Board &board, Rule rule, Value rawValue);
3535

3636
/// EvalInfo struct contains all information needed to evaluate a position.
3737
struct EvalInfo

0 commit comments

Comments
 (0)