Conversation
Add MalomAPI_cpp following C++17 standards with no external dependencies. Examples of API usage can be found in perfect_test.cpp file. sec_val_path can be passed via argv[1].
|
Thank you very much for the PR! We will be able to review it a bit later, because we are a bit busy at the moment. |
|
Thank you for letting me know! I understand that you're busy. If it's possible, could the review be completed by November 9th? I'm planning to release a new version of Mill app by New Year's Day 2024. I appreciate your time and effort. |
ggevay
left a comment
There was a problem hiding this comment.
We are sorry for not getting back to you sooner! We have started reviewing the pull request, and have found a few minor issues. Overall it looks good! We'll continue reviewing soon.
| double val; | ||
| }; | ||
|
|
||
| // const double WRGMInf = 2; // Is this good? |
There was a problem hiding this comment.
(The WRGM stuff is not needed in the C++ API. This was part of some old experimental code.)
There was a problem hiding this comment.
WRGMInf: Based on your feedback and upon reviewing the code, I agree that WRGMInf seems to be part of old experimental code and is not required in the current C++ API. I will proceed to remove it to make the code cleaner and more maintainable.
BTW, Struct MoveValuePair: Regarding the struct MoveValuePair, it is currently not in use, and I am considering its removal as well. Would you recommend deleting it for the sake of code cleanliness, or might there be potential debugging or development scenarios where it could be useful?
There was a problem hiding this comment.
Indeed, you can remove MoveValuePair too.
There was a problem hiding this comment.
Thanks for taking care of removing WRGM in calcitem/Sanmill@b91a8df. You could also push this commit to this pull request (i.e., to the calcitem:cpp-api branch).
There was a problem hiding this comment.
Proceeding with modifications at https://github.com/calcitem/Sanmill/commits/master/src/perfect for ease of testing. Expect to port these changes to calcitem:cpp-api within a month. Your input on these modifications, including the commit changing Hungarian to English, is welcome. Thank you.
|
|
||
| // const double WRGMInf = 2; // Is this good? | ||
|
|
||
| std::mutex evalLock; |
There was a problem hiding this comment.
There is also an evalLock in perfect_player.cpp, which seems to be a different variable. We recommend keeping only the other one (which is a global variable), because this lock should be shared between instances of this class. This is because this code calls some other code that is not thread-safe, for example Wrappers::WSector::hash manipulates loaded_hashes, which is a static variable. (Note that EvalLock is Shared in the original VB code.)
There was a problem hiding this comment.
Thank you for highlighting the locking mechanisms in PerfectPlayer::eval and Wrappers::WSector::hash. After reviewing the code, I will remove the evalLock from PerfectPlayer::eval and keep the global evalLock.
However, I'm considering how to handle locking in Wrappers::WSector::hash. The function manipulates loaded_hashes, a static variable that necessitates some thread safety.
-
Option 1: Use the same global
evalLockfor both functions. This would ensure thread safety but might introduce performance bottlenecks if these functions are frequently accessed concurrently. -
Option 2: Use separate locks for each function. This would improve performance but could introduce complexity and the risk of deadlocks if not carefully managed.
-
Option 3: Avoid locking altogether in
Wrappers::WSector::hash. This would be risky unless I can be sure this function is never accessed concurrently, or the logic permits eventual consistency.
Since I'm not fully aware of the logical structure and how these functions might interact in a multi-threaded environment, I would appreciate your input on the most suitable locking strategy.
There was a problem hiding this comment.
Option 3 seems ok to us, because Wrappers::WSector::hash is only called from PerfectPlayer::eval, which has a lock. If you would like to, you can add a comment on Wrappers::WSector::hash that it should never be called by anything else than PerfectPlayer::eval.
Alternatively, Option 2 seems also fine. Deadlocks can only occur if there are code paths that acquire two locks in different orders, but this can't happen here, since Wrappers::WSector::hash is not going to call into PerfectPlayer, so the two locks would always be called in the order of first evalLock and then hashLock. Note that Option 2 has some performance cost, which might be important if you will later want to extract evaluations en masse.
|
|
||
| auto iter = secs.find(id_val); | ||
| if (iter == secs.end()) { | ||
| throw std::runtime_error("Key not found in secs"); |
There was a problem hiding this comment.
This happens when there is no corresponding database file. However, in other parts of the code, you are printing an error msg that indicates that a database file was not found when catching std::out_of_range, so you might want to throw that here instead of std::runtime_error.
There was a problem hiding this comment.
Thank you for your code review and suggestions.
You recommended using std::out_of_range for throwing exceptions when no corresponding database file is found, aiming for consistency with other parts of the code. This is a reasonable suggestion, especially when considering readability and consistency.
However, I believe it is more appropriate to keep the current std::runtime_error for this specific scenario for the following reasons:
-
Semantic Accuracy:
std::out_of_rangeis generally used to indicate that an access was made outside the bounds of a container. In this case, not finding a key is more an issue of database integrity or data availability rather than an "out of range" issue. -
Maintainability and Extensibility: Using
std::runtime_errorallows us to handle this type of exception in the future. For example, we could introduce new exception types as subtypes ofstd::runtime_error. -
Error Diagnosis:
std::runtime_errorcan offer broader context information, which is more useful for unpredictable runtime errors.
For these reasons, I'm inclined to retain std::runtime_error in this scenario. Thank you again for your thoughtful suggestions, and I'm open to further talks if you have additional points or rebuttals.
There was a problem hiding this comment.
The important thing to consider here is that you need to throw the same type of exception that you are catching where you want to handle this error. More specifically, MalomSolutionAccess::getBestMove is catching std::out_of_range, so the thrown exception should match that catch block.
Also note that PerfectPlayer::getSec already throws std::out_of_range in the same situation.
- Semantic Accuracy:
std::out_of_rangeis generally used to indicate that an access was made outside the bounds of a container. In this case, not finding a key is more an issue of database integrity or data availability rather than an "out of range" issue.
MalomSolutionAccess::getBestMove catches the exception thrown here and converts it to an exception that is semantically meaningful to an external caller of the API. This means that the exception thrown here in perfect_player.cpp is just an internal exception, so it seems ok if it is meaningful only internally to the API.
As you also mentioned, it might make sense to introduce a custom exception for this error, and throw it at perfect_api.cpp:95. This could make it easier for callers of the API to differentiate this error from other, unpredictable errors.
There was a problem hiding this comment.
Thank you for the detailed feedback. I'd like to propose moving away from exception handling in this case and switching to a return-value-based mechanism. Exception handling, while useful for catching errors in a structured manner, can impact performance for several reasons:
-
Code Size: Exception handling can increase the binary size due to the extra code required to implement the try-catch blocks.
-
Stack Unwinding: When an exception is thrown, the stack must be unwound, which can be a computationally expensive operation.
-
Branch Prediction: Modern CPUs optimize for predictable branching. Exception handling can disrupt this, leading to performance penalties.
-
Cache Efficiency: The extra code and data structures involved in exception handling may lead to less efficient cache usage.
-
Debugging Overhead: While not a runtime performance concern, the complexity introduced by exceptions can make debugging more difficult.
Given the performance-sensitive nature of our applications, a return-value-based approach could offer a more efficient way to handle errors. This would make it easier for API callers to handle errors without the overhead associated with exceptions.
There was a problem hiding this comment.
We’d like to avoid switching from exception handling to a return-value-based mechanism, because we believe that the required time investment would not be worth the performance gains. Note that each read from our database involves several random reads from files, which takes time on the order of milliseconds or tens of milliseconds. Compared to these file reads, exception handling has negligible overhead.
There was a problem hiding this comment.
@calcitem, I find it curious that you previously argued for enabling the future use of new exception subtypes for better maintainability in your previous comment in this thread.
Consider filtering out the irrelevant parts of the output of ChatGPT.
There was a problem hiding this comment.
Resolved in calcitem/Sanmill@52cb036. Synchronization with this repository is required.
The mutex evalLock, previously used in the PerfectPlayer class for thread safety, has been removed. The global evalLock will be used instead to handle thread safety across multiple functions, ensuring a unified locking mechanism. Removed WRGMInf variable from the C++ API as it was part of old experimental code and is no longer needed. Related discussion can be found in ggevay/malom#3 (review) Change-Id: Ibf81593e2410184412d61f44728234c1f858a03c
| "from the starting position."); | ||
| } | ||
|
|
||
| deinitializeIfNeeded(); |
There was a problem hiding this comment.
Given the performance-sensitive nature of our applications, we'd recommend against calling deinitializeIfNeeded() in every call of getBestMove, because in this case the initializeIfNeeded() call at the beginning of the function would have to actually perform the initialization every time, which would have a non-trivial performance cost.
|
|
||
| if (pp == nullptr) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
The Rules::cleanup(); line should be after this if statement to avoid double deletions if deinitializeIfNeeded is called multiple times. (The "ifNeeded" part suggests that the function is idempotent, i.e., it should be ok to call it redundantly.)
|
|
||
| void MalomSolutionAccess::setVariantStripped() | ||
| { | ||
| // copy-paste from Rules.cpp, but references to Main stripped |
There was a problem hiding this comment.
We recommend deleting this comment, as it's not really true for the C++ version. (There is no Rules.cpp, it's not really a copy-paste in the C++ version, and the other function also doesn't have references to Main in the C++ version.)
| T PerfectPlayer::chooseRandom(const std::vector<T> &l) | ||
| { | ||
| std::random_device rd; | ||
| std::mt19937 gen(rd()); |
There was a problem hiding this comment.
Given the performance-sensitive nature of our applications, we'd recommend making both the std::random_device rd and the std::mt19937 gen static variables, so that they are created only once. Creating these is expensive, see https://stackoverflow.com/a/72577343
Note that in the original Visual Basic code, the variable for the random generator is static (Shared in Visual Basic).
There was a problem hiding this comment.
I've implemented your suggestion to make std::random_device and std::mt19937 static in the chooseRandom function. This change resulted in a performance improvement of approximately 0.02%. Thank you for your valuable input.
|
(We will be quite busy in the next 2-3 weeks, so we'll continue the review later. Thank you for your patience!) |
|
Dear Gabor, I am writing to share some observations and seek your guidance regarding a specific aspect of the AI behavior in your application. I've been thoroughly analyzing the code and encountered an interesting scenario that I believe merits your expert opinion. I am referring to the std::vector<AdvancedMove> PerfectPlayer::goodMoves(const GameState &s)
{
return allMaxBy(std::function<Wrappers::gui_eval_elem2(AdvancedMove)>(
[this, &s](AdvancedMove m) { return moveValue(s, m); }),
getMoveList(s),
Wrappers::gui_eval_elem2::min_value(getSec(s)));
}Further investigation led me to the template <typename T, typename K>
std::vector<T> PerfectPlayer::allMaxBy(std::function<K(T)> f,
const std::vector<T> &l, K minValue)
{
std::vector<T> r;
K ma = minValue;
for (auto &m : l) {
K e = f(m);
if (e > ma) {
ma = e;
r.clear();
r.push_back(m);
} else if (e == ma) {
r.push_back(m);
}
}
return r;
}My attention then turned to the bool operator>(const gui_eval_elem2 &b) const
{
return this->compare(b) > 0;
}
int compare(const gui_eval_elem2 &o) const
{
assert(s == o.s);
if (!ignore_DD) {
if (key1 != o.key1)
return key1 < o.key1 ? -1 : 1;
else if (key1 < 0)
return key2 < o.key2 ? -1 : 1;
else if (key1 > 0)
return key2 > o.key2 ? -1 : 1;
else
return 0;
} else {
auto a1 = to_eval_elem2().corr(s ? s->sval : virt_unique_sec_val());
auto a2 = o.to_eval_elem2().corr(o.s ? o.s->sval :
virt_unique_sec_val());
drop_DD(a1);
drop_DD(a2);
if (a1.key1 != a2.key1)
return a1.key1 < a2.key1 ? -1 : 1;
else if (a1.key1 < 0)
return a1.key2 < a2.key2 ? -1 : 1;
else if (a1.key1 > 0)
return a2.key2 < a1.key2 ? -1 : 1;
else
return 0;
}
}Given the intricate nature of the database's internal design, I find myself unable to conclusively determine if the behavior is as expected. To further elucidate the issue, I modified the std::vector<AdvancedMove> PerfectPlayer::goodMoves(const GameState &s)
{
auto moveList = getMoveList(s);
std::cout << "Move list size: " << moveList.size()
<< std::endl;
std::function<Wrappers::gui_eval_elem2(AdvancedMove)> evalFunction =
[this, &s](AdvancedMove m) {
auto value = moveValue(s, m);
std::cout << "Evaluating move from " << m.from << " to " << m.to
<< " with score: " << value.toString()
<< std::endl;
return value;
};
auto bestMoves = allMaxBy(evalFunction, moveList,
Wrappers::gui_eval_elem2::min_value(getSec(s)));
std::cout << "Number of best moves: " << bestMoves.size() << std::endl;
return bestMoves;
}In a test scenario where the white player's first move is at b4, the AI (black player) consistently moves to b2. Interestingly, when using your VB.net GUI application, the AI seems capable of making random moves. The debug output for the moves evaluated is as follows: From my understanding, I was expecting that the "best moves" would include all moves with a score not equal to Could you kindly provide some insights into whether this behavior aligns with the intended design of the AI? Your expertise would be greatly appreciated in clarifying this matter. My current temporary solution is to modify the following function to look like this: template <typename T, typename K>
std::vector<T> PerfectPlayer::allMaxBy(std::function<K(T)> f,
const std::vector<T> &l, K minValue)
{
std::vector<T> r;
if (gameOptions.getShufflingEnabled()) {
bool foundW = false;
bool foundD = false;
for (auto &m : l) {
K e = f(m);
std::string eStr = e.toString();
if (eStr[0] == 'W') {
if (!foundW) {
r.clear();
foundW = true;
}
r.push_back(m);
} else if (!foundW && eStr[0] != 'L') {
if (!foundD) {
r.clear();
foundD = true;
}
r.push_back(m);
} else if (!foundW && !foundD && eStr[0] == 'L') {
r.push_back(m);
}
}
} else {
K ma = minValue;
for (auto &m : l) {
K e = f(m);
if (e > ma) {
ma = e;
r.clear();
r.push_back(m);
} else if (e == ma) {
r.push_back(m);
}
}
}
return r;
}Thank you very much for your time and assistance. I look forward to your valuable feedback. Merry Christmas! |
|
Dear Calcitem, The problem is originating from this and similar lines in the translated In the original code, this line is as follows: This is different from the translation: .net’s Merry Christmas! |
| int GetHashCode() { return (W << 0) | (B << 4) | (WF << 8) | (BF << 12); } | ||
|
|
||
| private: | ||
| static int64_t factorial(int n) |
There was a problem hiding this comment.
Unfortunately, int64_t won't be large enough for this. nCr(n,r) calls factorial(n), and size() calls nCr(24 - W, B), where W can be as small as 3. So the factorial function needs to be able to compute factorial(21), which is 51090942171709440000, which doesn't fit in int64_t, or even uint64_t, since 2^64 = 18446744073709551616. (Note that the original code's BigInteger is an arbitrary-precision integer type in .net.)
You might be able to simply delete these functions (factorial, nCr, size) from the C++ code (and the sector_sizes variable). If I remember correctly, size() is used only by the Controller project, but not the GUI or the API. (I can't easily verify this right now, because I don't have that computer with me where I have my development environment installed.)
There was a problem hiding this comment.
We've confirmed that the API does not use the mentioned functions and variables. They will be removed in the next update.
| struct gui_eval_elem2 | ||
| { | ||
| private: | ||
| // azert cannot be valid, because it cannot contain a count (as asserted by |
There was a problem hiding this comment.
"azert cannot be valid, because it cannot contain a count"
-->
"could not be simply val instead of sec_val, because val cannot contain a count"
| eval_elem2 to_eval_elem2() const { return eval_elem2 {key1, key2}; } | ||
|
|
||
| public: | ||
| // The nezo point of key1 is s. However, if s is null, then |
I would greatly appreciate your insights and guidance on a few aspects that are unclear to me. 1. Code Modifications: I have made the following changes to a function in C++: Before Modification: int compare(const gui_eval_elem2 &o) const
{
assert(s == o.s);
if (!ignore_DD) {
if (key1 != o.key1)
return key1 < o.key1 ? -1 : 1;
else if (key1 < 0)
return key2 < o.key2 ? -1 : 1;
else if (key1 > 0)
return key2 > o.key2 ? -1 : 1;
else
return 0;
} else {
auto a1 = to_eval_elem2().corr(s ? s->sval : virt_unique_sec_val());
auto a2 = o.to_eval_elem2().corr(o.s ? o.s->sval :
virt_unique_sec_val());
drop_DD(a1);
drop_DD(a2);
if (a1.key1 != a2.key1)
return a1.key1 < a2.key1 ? -1 : 1;
else if (a1.key1 < 0)
return a1.key2 < a2.key2 ? -1 : 1;
else if (a1.key1 > 0)
return a2.key2 < a1.key2 ? -1 : 1;
else
return 0;
}
}After Modification: int compare(const gui_eval_elem2 &o) const
{
assert(s == o.s);
if (!ignore_DD) { // What's ignore_DD?
if (key1 != o.key1)
return key1 < o.key1 ? -1 : 1;
else if (key1 < 0)
return key2 == o.key2 ? 0 : (key2 < o.key2 ? -1 : 1);
else if (key1 > 0)
return key2 == o.key2 ? 0 : (key2 > o.key2 ? -1 : 1);
else
return 0;
} else {
auto a1 = to_eval_elem2().corr(s ? s->sval : virt_unique_sec_val());
auto a2 = o.to_eval_elem2().corr(o.s ? o.s->sval :
virt_unique_sec_val());
drop_DD(a1);
drop_DD(a2);
if (a1.key1 != a2.key1)
return a1.key1 < a2.key1 ? -1 : 1;
else if (a1.key1 < 0)
return a1.key2 == a2.key2 ? 0 : (a1.key2 < a2.key2 ? -1 : 1);
else if (a1.key1 > 0)
return a2.key2 == a1.key2 ? 0 : (a2.key2 < a1.key2 ? -1 : 1);
else
return 0;
}
}2. Evaluation of Moves: When White's first move is b4 and playing as Black, the evaluation results are as follows: I am particularly puzzled as to why only the scores labeled 3. Interpretation of the Evaluation Results: I have encountered some difficulties in understanding the meaning of
4. Clarification Request on The original text of
Could you please provide a detailed explanation, especially regarding the evaluation results like Thank you very much for your time and assistance. BTW. In the coming days, the Beta channel on Play Store will be updating the Sanmill App. This update will enable users to download and import the perfect database you developed, by following the guide at https://github.com/calcitem/Sanmill/wiki/Perfect-Database. When it's available, I would greatly appreciate it if you could check to see if the behavior meets expectations. Thank you very much for your time. |
If the skill level is greater than 15, then the range of move choices for the perfect database is narrower, aiming to end the game with an equal number of pieces for both players. If the skill level is less than 15, although the AI's move choices also result in a draw or a win, it does not pursue having more remaining pieces than the opponent. The final effect should be based on the conclusions discussed in ggevay/malom#3. Change-Id: I96c93015f9d823cb1a76d4657cf9fffe585ab094
Modified the `chooseRandom` function to declare `std::random_device` and `std::mt19937` as static variables. This change ensures that these objects are initialized only once, reducing the overhead of repeatedly creating them on each function call. This optimization is expected to improve performance by approximately 0.02%. This aligns with practices in ggevay's original Visual Basic code, where the random generator was a static (Shared) variable. Reference: ggevay/malom#3 (comment) Change-Id: Ic3b461caf52f533d4bbd5dc29ada65172d5c4450
…:eval In the PerfectPlayer::eval method, the exception thrown when a key is not found in the 'secs' map has been changed from std::runtime_error to std::out_of_range. This aligns with the handling of similar exceptions elsewhere in the code, providing consistency and clearer error messaging about missing database files. Reference: ggevay/malom#3 (comment) Change-Id: Ic860087ebf2d7e714b58f0c2ad78c05250434152
This commit adjusts the deinitializeIfNeeded method in MalomSolutionAccess to enhance performance and ensure safety. The check for a null pointer (pp == nullptr) is moved to the beginning of the method. This change prevents unnecessary cleanup calls and potential double deletion issues, improving both the performance and robustness of the code. By returning early when pp is null, we avoid redundant cleanup operations and align with the intended idempotent nature of the function. Reference: ggevay/malom#3 (comment) Change-Id: I6b77af22f99ad500217f1a70145144b33c1f20fb
This commit updates the comment preceding the deinitializeIfNeeded() call in the getBestMove function. The revised comment more clearly articulates the performance implications of frequent initialization and deinitialization in a performance-sensitive environment. It emphasizes the need for a more efficient approach to managing initialization cycles, thereby guiding future optimizations and code maintenance efforts. Reference: ggevay/malom#3 (comment) Change-Id: I0f8ed192d5a40f2485d7e7819c42e553c265909f
We recommend deleting this comment, as it's not really true for the C++ version. (There is no Rules.cpp, it's not really a copy-paste in the C++ version, and the other function also doesn't have references to Main in the C++ version.) https: //github.com/ggevay/malom/pull/3#discussion_r1349747293 Change-Id: I7ab924fc96cd67cc3e4b9417510094a5f46d4898
|
"DD" means "distinguishing draws", which refers to our heuristic way to achieve an ultra-strong solution (as opposed to a strong solution): our retrograde analysis is based on the assumption that there are no turn limits, so a draw is actually a cycle or a combination of cycles in the graph of game states; we differentiate draws based on how many stones each player has in such a cycle. For the meaning of the phrases "ultra-strong solution" and "strong solution", see our website, or our paper:
The
To make the program’s move selection take into account how many steps it takes to win or lose (in the paper, DTW refers to this number of steps), you can simply use our comparison function. The “number of steps it takes to draw” is an ambiguous term; the definition we used in our program is a bit tricky, see our definition in subsection IV-B-2 of our paper. For displaying the DTW to your users, you could look at (If the The code in the commit calcitem/Sanmill@b906d31 has a bug in the The second one should be negated. (Note that the corresponding code in your GitHub comment is correct.) Note that the commit message of calcitem/Sanmill@b906d31 seems to suggest that the ultra-strong solution strives to win by as large stone difference as possible, but this is not the case. The ultra-strong solution differs from the strong solution only for draw positions. For wins and losses, the strong solution makes the same moves as the ultra-strong solution.
Yes, this is correct for the considered state, because stone advantage cannot be achieved in that state. |
Morabaraba occasional drawsCalcitem:
GG:
Regarding the issue of occasional draws in self-play mode, I have successfully replicated the issue, and the details are as follows: Reproduction steps: Configure as follows: General Settings:
Rule Settings:
Switch to AI Vs AI mode and start a self-play game. The game resulted in a draw: A simpler way to reproduce the draw is: Copy the following content to the clipboard. Then click: Game -> Import game Click "Move -> Move now" We will see a draw occur. In https://github.com/calcitem/Sanmill/blob/master/src/perfect/perfect_common.h, we can see on Line 44: #define DD // distinguish draws (ultra) //comment or uncommentWith the DD switch turned on, it should recognize a draw. Open Settings -> AI -> Ignore draw distinguishing info in the databases: OFF Players -> First: Human Players -> Second: Human It is found that in the 12th round, the move returned by Perfect Database is This indicates there might be a bug in the code adaptation, but I am unsure which part of the code should be checked first or how to debug it. Could you please provide some guidance? Thank you! |



Add MalomAPI_cpp following C++17 standards with no external dependencies.
Examples of API usage can be found in
perfect_test.cppfile.sec_val_path can be passed via argv[1].