Aleph-w 3.0
A C++ Library for Data Structures and Algorithms
Loading...
Searching...
No Matches
adversarial_search_test.cc
Go to the documentation of this file.
1/*
2 Aleph_w
3
4 Data structures & Algorithms
5 version 2.0.0b
6 https://github.com/lrleon/Aleph-w
7
8 This file is part of Aleph-w library
9
10 Copyright (c) 2002-2026 Leandro Rabindranath Leon
11
12 Permission is hereby granted, free of charge, to any person obtaining a copy
13 of this software and associated documentation files (the "Software"), to deal
14 in the Software without restriction, including without limitation the rights
15 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16 copies of the Software, and to permit persons to whom the Software is
17 furnished to do so, subject to the following conditions:
18
19 The above copyright notice and this permission notice shall be included in all
20 copies or substantial portions of the Software.
21
22 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28 SOFTWARE.
29*/
30
31#include <cstdint>
32#include <memory>
33#include <stdexcept>
34#include <string>
35
36#include <gtest/gtest.h>
37
38#include <ah-errors.H>
39#include <State_Search.H>
40
41using namespace Aleph;
42
43namespace
44{
45
46struct ArtificialGameState
47{
48 size_t depth = 0;
49 size_t code = 1;
50 int player = 1;
51};
52
53struct ArtificialGameMove
54{
55 size_t next_code = 1;
56 char label = 'A';
57};
58
59class ArtificialGameDomain
60{
61public:
62 using State = ArtificialGameState;
63 using Move = ArtificialGameMove;
64 using Score = int;
65 using State_Key = std::uint64_t;
66
67 bool is_terminal(const State &state) const
68 {
69 return state.depth == 2;
70 }
71
72 Score evaluate(const State &state) const
73 {
74 return root_score(state.code)*state.player;
75 }
76
77 [[nodiscard]] State_Key state_key(const State &state) const noexcept
78 {
79 return (static_cast<State_Key>(state.code) << 1) |
80 static_cast<State_Key>(state.player > 0 ? 1 : 0);
81 }
82
83 void apply(State &state, const Move &move) const
84 {
85 state.code = move.next_code;
86 state.player = -state.player;
87 ++state.depth;
88 }
89
90 void undo(State &state, const Move&) const
91 {
92 --state.depth;
93 state.player = -state.player;
94 state.code /= 2;
95 }
96
97 template <typename Visitor>
98 bool for_each_successor(const State &state, Visitor visit) const
99 {
100 if (state.depth >= 2)
101 return true;
102
103 switch (state.code)
104 {
105 case 1:
106 if (not visit(Move{2, 'A'}))
107 return false;
108 return visit(Move{3, 'B'});
109
110 case 2:
111 if (not visit(Move{4, 'a'}))
112 return false;
113 return visit(Move{5, 'b'});
114
115 case 3:
116 if (not visit(Move{6, 'a'}))
117 return false;
118 return visit(Move{7, 'b'});
119
120 default:
121 return true;
122 }
123 }
124
125private:
126 [[nodiscard]] static Score root_score(const size_t code) noexcept
127 {
128 switch (code)
129 {
130 case 2: return 6;
131 case 3: return 1;
132 case 4: return 3;
133 case 5: return 5;
134 case 6: return 2;
135 case 7: return 4;
136 default: return 0;
137 }
138 }
139};
140
141struct TicTacToeState
142{
143 Array<int> board;
144 int player = 1;
145 size_t moves_played = 0;
146
147 TicTacToeState() : board(size_t(9), 0)
148 {
149 // empty
150 }
151};
152
153class TicTacToeDomain
154{
155public:
156 struct Move
157 {
158 size_t cell = 0;
159
160 [[nodiscard]] bool operator == (const Move &) const noexcept = default;
161 };
162
163 using State = TicTacToeState;
164 using Score = int;
165 using State_Key = std::uint64_t;
166 using Move_Key = size_t;
167
168 bool is_terminal(const State &state) const
169 {
170 return winner(state) != 0 or state.moves_played == 9;
171 }
172
173 Score evaluate(const State &state) const
174 {
175 const int win = winner(state);
176 if (win != 0)
177 return win == state.player ? 100 : -100;
178
179 if (state.moves_played == 9)
180 return 0;
181
182 return heuristic(state, state.player) - heuristic(state, -state.player);
183 }
184
185 [[nodiscard]] State_Key state_key(const State &state) const noexcept
186 {
187 State_Key key = static_cast<State_Key>(state.player > 0 ? 1 : 2);
188 for (size_t i = 0; i < state.board.size(); ++i)
189 {
190 key *= 3;
191 if (state.board[i] == 1)
192 key += 1;
193 else if (state.board[i] == -1)
194 key += 2;
195 }
196
197 return key;
198 }
199
200 [[nodiscard]] Move_Key move_key(const Move &move) const noexcept
201 {
202 return move.cell;
203 }
204
205 void apply(State &state, const Move &move) const
206 {
207 state.board[move.cell] = state.player;
208 state.player = -state.player;
209 ++state.moves_played;
210 }
211
212 void undo(State &state, const Move &move) const
213 {
214 --state.moves_played;
215 state.player = -state.player;
216 state.board[move.cell] = 0;
217 }
218
219 template <typename Visitor>
220 bool for_each_successor(const State &state, Visitor visit) const
221 {
222 static constexpr size_t order[] = {1, 3, 5, 7, 0, 2, 6, 8, 4};
223
224 for (const size_t cell : order)
225 if (state.board[cell] == 0 and not visit(Move{cell}))
227
228 return true;
229 }
230
231private:
232 [[nodiscard]] static int winner(const State &state) noexcept
233 {
234 static constexpr size_t lines[8][3] = {
235 {0, 1, 2}, {3, 4, 5}, {6, 7, 8},
236 {0, 3, 6}, {1, 4, 7}, {2, 5, 8},
237 {0, 4, 8}, {2, 4, 6}
238 };
239
240 for (const auto &line : lines)
241 {
242 const int a = state.board[line[0]];
243 if (a != 0 and a == state.board[line[1]] and a == state.board[line[2]])
244 return a;
245 }
246
247 return 0;
248 }
249
250 [[nodiscard]] static Score heuristic(const State &state, const int player) noexcept
251 {
252 static constexpr size_t lines[8][3] = {
253 {0, 1, 2}, {3, 4, 5}, {6, 7, 8},
254 {0, 3, 6}, {1, 4, 7}, {2, 5, 8},
255 {0, 4, 8}, {2, 4, 6}
256 };
257
258 Score total = 0;
259 for (const auto &line : lines)
260 {
261 size_t mine = 0;
262 size_t empty = 0;
263 bool blocked = false;
264
265 for (const size_t cell : line)
266 if (state.board[cell] == player)
267 ++mine;
268 else if (state.board[cell] == 0)
269 ++empty;
270 else
271 {
272 blocked = true;
273 break;
274 }
275
276 if (blocked)
277 continue;
278
279 if (mine == 2 and empty == 1)
280 total += 10;
281 else if (mine == 1 and empty == 2)
282 total += 1;
283 }
284
285 return total;
286 }
287};
288
291
292std::string path_signature(const SearchPath<ArtificialGameMove> &path)
293{
294 std::string out;
295 for (const auto &move : path)
296 out.push_back(move.label);
297 return out;
298}
299
300template <typename Move, typename Score>
302 const AdversarialTraceEventKind kind)
303{
304 size_t count = 0;
305 collector.events().for_each(
306 [&](const AdversarialTraceEvent<Move, Score> &event)
307 {
308 if (event.kind == kind)
309 ++count;
310 });
311 return count;
312}
313
314TicTacToeState make_tictactoe_state(const char *layout, const int player)
315{
316 TicTacToeState state;
317 state.player = player;
318
319 for (size_t i = 0; i < 9; ++i)
320 switch (layout[i])
321 {
322 case 'X':
323 state.board[i] = 1;
324 ++state.moves_played;
325 break;
326
327 case 'O':
328 state.board[i] = -1;
329 ++state.moves_played;
330 break;
331
332 default:
333 state.board[i] = 0;
334 }
335
336 return state;
337}
338
349template <typename Domain>
350[[nodiscard]] typename Domain::Score
351replay_pv(Domain &domain,
352 typename Domain::State state,
354{
355 for (const auto &move : pv)
356 domain.apply(state, move);
357
358 const auto leaf_value = domain.evaluate(state);
359 return (pv.size() % 2 == 0) ? leaf_value : -leaf_value;
360}
361
362} // end namespace
363
365{
366 auto result = negamax_search(ArtificialGameDomain{}, ArtificialGameState{});
367
368 EXPECT_TRUE(result.exhausted());
369 EXPECT_EQ(result.value, 3);
370 EXPECT_EQ(path_signature(result.principal_variation), "Aa");
371 EXPECT_EQ(result.stats.visited_states, 7u);
372 EXPECT_EQ(result.stats.expanded_states, 3u);
373}
374
376{
377 auto negamax = negamax_search(ArtificialGameDomain{}, ArtificialGameState{});
378 auto alpha_beta = alpha_beta_search(ArtificialGameDomain{}, ArtificialGameState{});
379
380 EXPECT_EQ(alpha_beta.value, negamax.value);
381 EXPECT_EQ(path_signature(alpha_beta.principal_variation), "Aa");
382 EXPECT_LT(alpha_beta.stats.visited_states, negamax.stats.visited_states);
383 EXPECT_GT(alpha_beta.stats.alpha_beta_cutoffs, 0u);
384}
385
387{
388 SearchLimits limits;
389 limits.max_depth = 1;
390
391 auto result = negamax_search(ArtificialGameDomain{}, ArtificialGameState{}, {}, limits);
392
393 EXPECT_TRUE(result.exhausted());
394 EXPECT_EQ(result.value, 6);
395 EXPECT_EQ(path_signature(result.principal_variation), "A");
396 EXPECT_EQ(result.stats.pruned_by_depth, 2u);
397}
398
400{
401 const TicTacToeState state = make_tictactoe_state("XX.OO....", 1);
402
403 auto negamax = negamax_search(TicTacToeDomain{}, state);
404 auto alpha_beta = alpha_beta_search(TicTacToeDomain{}, state);
405
406 EXPECT_EQ(negamax.value, 100);
407 EXPECT_EQ(alpha_beta.value, 100);
408 ASSERT_TRUE(alpha_beta.has_principal_variation());
409 EXPECT_EQ(alpha_beta.first_move().cell, 2u);
410 EXPECT_EQ(negamax.first_move().cell, alpha_beta.first_move().cell);
411}
412
414{
415 TicTacToeState state;
416
417 auto negamax = negamax_search(TicTacToeDomain{}, state);
418 auto alpha_beta = alpha_beta_search(TicTacToeDomain{}, state);
419
420 EXPECT_EQ(negamax.value, 0);
421 EXPECT_EQ(alpha_beta.value, negamax.value);
422 ASSERT_TRUE(negamax.has_principal_variation());
423 ASSERT_TRUE(alpha_beta.has_principal_variation());
424 EXPECT_EQ(alpha_beta.first_move().cell, negamax.first_move().cell);
425 EXPECT_LT(alpha_beta.stats.visited_states, negamax.stats.visited_states);
426 EXPECT_GT(alpha_beta.stats.alpha_beta_cutoffs, 0u);
427}
428
430{
432 policy.strategy = ExplorationPolicy::Strategy::Best_First;
433
434 Negamax<ArtificialGameDomain> negamax(ArtificialGameDomain{}, policy);
435 Alpha_Beta<ArtificialGameDomain> alpha_beta(ArtificialGameDomain{}, policy);
436
437 EXPECT_THROW((void) negamax.search(ArtificialGameState{}), std::invalid_argument);
438 EXPECT_THROW((void) alpha_beta.search(ArtificialGameState{}), std::invalid_argument);
439}
440
442{
443 TicTacToeState state;
444
445 Alpha_Beta<TicTacToeDomain> plain_engine(TicTacToeDomain{});
446 auto plain = plain_engine.search(state);
447
449 ordered_policy.move_ordering = MoveOrderingMode::Estimated_Score;
450 ordered_policy.use_killer_moves = true;
451 ordered_policy.use_history_heuristic = true;
452
454 auto ordered = ordered_engine.search(state);
455
456 EXPECT_EQ(ordered.value, plain.value);
457 ASSERT_TRUE(plain.has_principal_variation());
458 ASSERT_TRUE(ordered.has_principal_variation());
459 EXPECT_LT(ordered.stats.visited_states, plain.stats.visited_states);
460 EXPECT_EQ(ordered.first_move().cell, 4u);
461 EXPECT_GT(ordered.stats.alpha_beta_cutoffs, 0u);
462 EXPECT_GT(ordered.stats.move_ordering.ordered_batches, 0u);
463 EXPECT_GT(ordered.stats.move_ordering.ordered_moves, 0u);
464 EXPECT_GT(ordered.stats.move_ordering.priority_estimates, 0u);
465}
466
468{
469 TicTacToeState state;
470 Negamax<TicTacToeDomain> engine(TicTacToeDomain{});
471
472 auto without_tt = engine.search(state);
473
474 using TT = AdversarialTranspositionTable<TicTacToeDomain::State_Key,
475 TicTacToeDomain::Move,
476 TicTacToeDomain::Score>;
477 TT table;
478 auto with_tt = engine.search(state, table);
479
480 EXPECT_EQ(with_tt.value, without_tt.value);
481 ASSERT_TRUE(with_tt.has_principal_variation());
482 ASSERT_TRUE(without_tt.has_principal_variation());
483 EXPECT_EQ(with_tt.first_move().cell, without_tt.first_move().cell);
484 EXPECT_LT(with_tt.stats.visited_states, without_tt.stats.visited_states);
485 EXPECT_GT(with_tt.stats.transpositions.hits, 0u);
486 EXPECT_GT(with_tt.stats.transpositions.cutoffs, 0u);
487 EXPECT_GT(table.stats().stores, 0u);
488}
489
491{
492 TicTacToeState state;
493 Alpha_Beta<TicTacToeDomain> engine(TicTacToeDomain{});
494
495 auto without_tt = engine.search(state);
496
497 using TT = AdversarialTranspositionTable<TicTacToeDomain::State_Key,
498 TicTacToeDomain::Move,
499 TicTacToeDomain::Score>;
500 TT table;
501 auto with_tt = engine.search(state, table);
502
503 EXPECT_EQ(with_tt.value, without_tt.value);
504 ASSERT_TRUE(with_tt.has_principal_variation());
505 ASSERT_TRUE(without_tt.has_principal_variation());
506 EXPECT_EQ(with_tt.first_move().cell, without_tt.first_move().cell);
507 EXPECT_LT(with_tt.stats.visited_states, without_tt.stats.visited_states);
508 EXPECT_GT(with_tt.stats.transpositions.hits, 0u);
509 EXPECT_GT(with_tt.stats.transpositions.cutoffs, 0u);
510 EXPECT_GT(table.stats().stores, 0u);
511}
512
514{
515 SearchLimits limits;
516 limits.max_depth = 2;
517
518 auto fixed = negamax_search(ArtificialGameDomain{}, ArtificialGameState{}, {}, limits);
519 auto iterative = iterative_deepening_negamax_search(ArtificialGameDomain{},
520 ArtificialGameState{},
521 {},
522 limits);
523
524 ASSERT_TRUE(iterative.has_iterations());
525 ASSERT_EQ(iterative.iterations.size(), 2u);
526 EXPECT_EQ(iterative.completed_iterations, 2u);
527
528 EXPECT_EQ(iterative.iterations[0].depth, 1u);
529 EXPECT_EQ(iterative.iterations[0].result.value, 6);
530 EXPECT_EQ(path_signature(iterative.iterations[0].result.principal_variation), "A");
531
532 EXPECT_EQ(iterative.iterations[1].depth, 2u);
533 EXPECT_EQ(iterative.iterations[1].result.value, fixed.value);
534 EXPECT_EQ(path_signature(iterative.iterations[1].result.principal_variation), "Aa");
535
536 EXPECT_EQ(iterative.result.value, fixed.value);
537 EXPECT_EQ(path_signature(iterative.result.principal_variation), "Aa");
538 EXPECT_GT(iterative.total_stats.visited_states, fixed.stats.visited_states);
539}
540
542{
543 SearchLimits limits;
544 limits.max_depth = 2;
545
548 options.depth_step = 1;
549 options.aspiration.half_window = 1;
550 options.aspiration.growth = 1;
551
552 auto fixed = alpha_beta_search(ArtificialGameDomain{}, ArtificialGameState{}, {}, limits);
553 auto iterative = iterative_deepening_alpha_beta_search(ArtificialGameDomain{},
554 ArtificialGameState{},
555 {},
556 limits,
557 options);
558
559 ASSERT_EQ(iterative.iterations.size(), 2u);
560 EXPECT_EQ(iterative.result.value, fixed.value);
561 EXPECT_EQ(path_signature(iterative.result.principal_variation), "Aa");
562 EXPECT_TRUE(iterative.iterations[1].used_aspiration_window);
563 EXPECT_GT(iterative.iterations[1].aspiration_researches, 0u);
564 EXPECT_GT(iterative.aspiration_researches, 0u);
565}
566
568{
569 SearchLimits limits;
570 limits.max_depth = 2;
571
574 options.depth_step = 1;
575 options.aspiration.half_window = 1;
576 options.aspiration.growth = 1;
577
579 auto iterative = iterative_deepening_alpha_beta_search(ArtificialGameDomain{},
580 ArtificialGameState{},
581 trace,
582 {},
583 limits,
584 options);
585
586 EXPECT_EQ(iterative.result.value, 3);
587 EXPECT_GT(trace.size(), 0u);
588 EXPECT_EQ(count_trace_events(trace, AdversarialTraceEventKind::Iteration_Begin), 2u);
589 EXPECT_EQ(count_trace_events(trace, AdversarialTraceEventKind::Iteration_End), 2u);
590 EXPECT_GT(count_trace_events(trace, AdversarialTraceEventKind::Alpha_Beta_Cutoff), 0u);
591 EXPECT_GT(count_trace_events(trace, AdversarialTraceEventKind::Aspiration_Retry), 0u);
592}
593
595{
596 TicTacToeState state;
597
598 SearchLimits limits;
599 limits.max_depth = 9;
600
602 ordered_policy.move_ordering = MoveOrderingMode::Estimated_Score;
603 ordered_policy.use_killer_moves = true;
604 ordered_policy.use_history_heuristic = true;
605
608 options.depth_step = 1;
609 options.aspiration.half_window = 4;
610 options.aspiration.growth = 4;
611
612 auto without_tt = iterative_deepening_alpha_beta_search(TicTacToeDomain{},
613 state,
615 limits,
616 options);
617
618 using TT = AdversarialTranspositionTable<TicTacToeDomain::State_Key,
619 TicTacToeDomain::Move,
620 TicTacToeDomain::Score>;
621 TT table;
622 auto with_tt = iterative_deepening_alpha_beta_search(TicTacToeDomain{},
623 state,
624 table,
626 limits,
627 options);
628
629 EXPECT_EQ(with_tt.result.value, without_tt.result.value);
630 ASSERT_TRUE(with_tt.result.has_principal_variation());
631 ASSERT_TRUE(without_tt.result.has_principal_variation());
632 EXPECT_EQ(with_tt.result.first_move().cell, without_tt.result.first_move().cell);
633 EXPECT_LT(with_tt.total_stats.visited_states, without_tt.total_stats.visited_states);
634 EXPECT_GT(table.stats().stores, 0u);
635}
636
637// --- search_with_window tests ---
638
640{
641 Alpha_Beta<ArtificialGameDomain> engine(ArtificialGameDomain{});
642
643 auto full = engine.search(ArtificialGameState{});
644 auto windowed = engine.search_with_window(ArtificialGameState{}, -1000, 1000);
645
646 EXPECT_EQ(windowed.value, full.value);
647 EXPECT_EQ(path_signature(windowed.principal_variation),
648 path_signature(full.principal_variation));
649}
650
652{
653 Alpha_Beta<ArtificialGameDomain> engine(ArtificialGameDomain{});
654
655 auto full = engine.search(ArtificialGameState{});
656 auto windowed = engine.search_with_window(ArtificialGameState{},
657 full.value - 1, full.value + 1);
658
659 EXPECT_EQ(windowed.value, full.value);
660 ASSERT_TRUE(windowed.has_principal_variation());
661}
662
664{
665 Alpha_Beta<ArtificialGameDomain> engine(ArtificialGameDomain{});
666
667 auto full = engine.search(ArtificialGameState{});
668 auto windowed = engine.search_with_window(ArtificialGameState{},
669 full.value + 5, full.value + 10);
670
671 EXPECT_LE(windowed.value, full.value + 5);
672}
673
675{
676 Alpha_Beta<ArtificialGameDomain> engine(ArtificialGameDomain{});
677
678 auto full = engine.search(ArtificialGameState{});
679 auto windowed = engine.search_with_window(ArtificialGameState{},
680 full.value - 10, full.value - 5);
681
682 EXPECT_GE(windowed.value, full.value - 5);
683}
684
686{
687 const TicTacToeState state = make_tictactoe_state("XX.OO....", 1);
688
689 Alpha_Beta<TicTacToeDomain> engine(TicTacToeDomain{});
690 auto full = engine.search(state);
691 auto windowed = engine.search_with_window(state, -200, 200);
692
693 EXPECT_EQ(windowed.value, full.value);
694 ASSERT_TRUE(windowed.has_principal_variation());
695 EXPECT_EQ(windowed.first_move().cell, full.first_move().cell);
696}
697
699{
700 const TicTacToeState state = make_tictactoe_state("XX.OO....", 1);
701
702 Alpha_Beta<TicTacToeDomain> engine(TicTacToeDomain{});
703 auto full = engine.search(state);
704
705 using TT = AdversarialTranspositionTable<TicTacToeDomain::State_Key,
706 TicTacToeDomain::Move,
707 TicTacToeDomain::Score>;
708 TT table;
709 auto windowed = engine.search_with_window(state, table, -200, 200);
710
711 EXPECT_EQ(windowed.value, full.value);
712 ASSERT_TRUE(windowed.has_principal_variation());
713 EXPECT_EQ(windowed.first_move().cell, full.first_move().cell);
714 EXPECT_GT(table.stats().stores, 0u);
715}
716
718{
719 TicTacToeState state;
720
721 Alpha_Beta<TicTacToeDomain> engine(TicTacToeDomain{});
722 auto wide = engine.search_with_window(state, -200, 200);
723 auto narrow = engine.search_with_window(state, -1, 1);
724
725 EXPECT_LE(narrow.stats.visited_states, wide.stats.visited_states);
726}
727
728// ---------------------------------------------------------------------------
729// Principal Variation Replay Verification (Recommendation 6)
730// ---------------------------------------------------------------------------
731
733{
734 ArtificialGameDomain domain;
735 auto result = negamax_search(domain, ArtificialGameState{});
736
737 ASSERT_TRUE(result.has_principal_variation());
738 const auto replayed = replay_pv(domain, ArtificialGameState{},
739 result.principal_variation);
740 EXPECT_EQ(replayed, result.value);
741}
742
744{
745 ArtificialGameDomain domain;
746 auto result = alpha_beta_search(domain, ArtificialGameState{});
747
748 ASSERT_TRUE(result.has_principal_variation());
749 const auto replayed = replay_pv(domain, ArtificialGameState{},
750 result.principal_variation);
751 EXPECT_EQ(replayed, result.value);
752}
753
755{
756 ArtificialGameDomain domain;
757 SearchLimits limits;
758 limits.max_depth = 1;
759
760 auto result = negamax_search(domain, ArtificialGameState{}, {}, limits);
761
762 ASSERT_TRUE(result.has_principal_variation());
763 const auto replayed = replay_pv(domain, ArtificialGameState{},
764 result.principal_variation);
765 EXPECT_EQ(replayed, result.value);
766}
767
769{
770 TicTacToeDomain domain;
771 TicTacToeState state;
772
773 auto result = negamax_search(domain, state);
774
775 ASSERT_TRUE(result.has_principal_variation());
776 const auto replayed = replay_pv(domain, state, result.principal_variation);
777 EXPECT_EQ(replayed, result.value);
778}
779
781{
782 TicTacToeDomain domain;
783 TicTacToeState state;
784
785 auto result = alpha_beta_search(domain, state);
786
787 ASSERT_TRUE(result.has_principal_variation());
788 const auto replayed = replay_pv(domain, state, result.principal_variation);
789 EXPECT_EQ(replayed, result.value);
790}
791
793{
794 TicTacToeDomain domain;
795 const TicTacToeState state = make_tictactoe_state("XX.OO....", 1);
796
797 auto negamax = negamax_search(domain, state);
798 auto alpha_beta = alpha_beta_search(domain, state);
799
800 ASSERT_TRUE(negamax.has_principal_variation());
801 ASSERT_TRUE(alpha_beta.has_principal_variation());
802 EXPECT_EQ(replay_pv(domain, state, negamax.principal_variation), negamax.value);
803 EXPECT_EQ(replay_pv(domain, state, alpha_beta.principal_variation), alpha_beta.value);
804}
805
807{
808 TicTacToeDomain domain;
809 TicTacToeState state;
810
811 using TT = AdversarialTranspositionTable<TicTacToeDomain::State_Key,
812 TicTacToeDomain::Move,
813 TicTacToeDomain::Score>;
814 TT table;
816 auto result = engine.search(state, table);
817
818 ASSERT_TRUE(result.has_principal_variation());
819 const auto replayed = replay_pv(domain, state, result.principal_variation);
820 EXPECT_EQ(replayed, result.value);
821}
822
824{
825 ArtificialGameDomain domain;
826 SearchLimits limits;
827 limits.max_depth = 2;
828
830 ArtificialGameState{},
831 {},
832 limits);
833
834 ASSERT_TRUE(iterative.has_iterations());
835 for (size_t i = 0; i < iterative.iterations.size(); ++i)
836 {
837 const auto &iter = iterative.iterations[i];
838 ASSERT_TRUE(iter.result.has_principal_variation())
839 << "Iteration " << i << " has no PV";
840 const auto replayed = replay_pv(domain, ArtificialGameState{},
841 iter.result.principal_variation);
842 EXPECT_EQ(replayed, iter.result.value)
843 << "PV replay mismatch at iteration " << i
844 << " (depth " << iter.depth << ")";
845 }
846
847 ASSERT_TRUE(iterative.result.has_principal_variation());
848 const auto final_replayed = replay_pv(domain, ArtificialGameState{},
849 iterative.result.principal_variation);
850 EXPECT_EQ(final_replayed, iterative.result.value);
851}
852
854{
855 ArtificialGameDomain domain;
856 SearchLimits limits;
857 limits.max_depth = 2;
858
861 options.depth_step = 1;
862 options.aspiration.half_window = 1;
863 options.aspiration.growth = 1;
864
866 ArtificialGameState{},
867 {},
868 limits,
869 options);
870
871 ASSERT_TRUE(iterative.result.has_principal_variation());
872 const auto replayed = replay_pv(domain, ArtificialGameState{},
873 iterative.result.principal_variation);
874 EXPECT_EQ(replayed, iterative.result.value);
875
876 for (size_t i = 0; i < iterative.iterations.size(); ++i)
877 {
878 const auto &iter = iterative.iterations[i];
879 ASSERT_TRUE(iter.result.has_principal_variation())
880 << "Iteration " << i << " has no PV";
881 const auto r = replay_pv(domain, ArtificialGameState{},
882 iter.result.principal_variation);
883 EXPECT_EQ(r, iter.result.value)
884 << "PV replay mismatch at iteration " << i;
885 }
886}
887
889{
890 TicTacToeDomain domain;
891 const TicTacToeState state = make_tictactoe_state("XX.OO....", 1);
892
894 auto result = engine.search_with_window(state, -200, 200);
895
896 ASSERT_TRUE(result.has_principal_variation());
897 const auto replayed = replay_pv(domain, state, result.principal_variation);
898 EXPECT_EQ(replayed, result.value);
899}
900
901// ===========================================================================
902// H1: Exception-safety tests for Negamax and Alpha_Beta
903// ===========================================================================
904
905namespace
906{
907
908struct AdversarialThrowState
909{
910 std::shared_ptr<bool> undo_called;
911 size_t node = 0;
912};
913
914struct AdversarialThrowMove
915{
916 bool throw_on_apply = false;
917};
918
919// Domain where apply() throws — undo must NOT be called.
920struct ThrowingApplyGameDomain
921{
922 using State = AdversarialThrowState;
923 using Move = AdversarialThrowMove;
924 using Score = int;
925
926 bool is_terminal(const State &state) const
927 {
928 return state.node >= 1;
929 }
930
931 Score evaluate(const State &) const
932 {
933 return 0;
934 }
935
936 void apply(State &state, const Move &move) const
937 {
938 if (move.throw_on_apply)
939 ah_runtime_error() << "apply failed";
940 state.node = 1;
941 }
942
943 void undo(State &state, const Move &) const
944 {
945 *state.undo_called = true;
946 state.node = 0;
947 }
948
949 template <typename Visitor>
950 bool for_each_successor(const State &state, Visitor visit) const
951 {
952 if (state.node != 0)
953 return true;
954 return visit(Move{true});
955 }
956};
957
958// Domain where apply() succeeds but the child evaluation (is_terminal/evaluate)
959// throws — undo must be called to restore state.
960struct ThrowingPostApplyGameDomain
961{
962 using State = AdversarialThrowState;
963 using Move = AdversarialThrowMove;
964 using Score = int;
965
966 bool is_terminal(const State &state) const
967 {
968 if (state.node == 1)
969 ah_runtime_error() << "post-apply is_terminal failed";
970 return false;
971 }
972
973 Score evaluate(const State &) const
974 {
975 return 0;
976 }
977
978 void apply(State &state, const Move &) const
979 {
980 state.node = 1;
981 }
982
983 void undo(State &state, const Move &) const
984 {
985 *state.undo_called = true;
986 state.node = 0;
987 }
988
989 template <typename Visitor>
990 bool for_each_successor(const State &state, Visitor visit) const
991 {
992 if (state.node != 0)
993 return true;
994 return visit(Move{false});
995 }
996};
997
998// Domain that always evaluates to 0 (forced draw at every leaf).
999struct ForcedDrawGameDomain
1000{
1001 using State = ArtificialGameState;
1002 using Move = ArtificialGameMove;
1003 using Score = int;
1004
1005 bool is_terminal(const State &state) const
1006 {
1007 return state.depth == 2;
1008 }
1009
1010 Score evaluate(const State &) const
1011 {
1012 return 0;
1013 }
1014
1015 void apply(State &state, const Move &move) const
1016 {
1017 state.code = move.next_code;
1018 state.player = -state.player;
1019 ++state.depth;
1020 }
1021
1022 void undo(State &state, const Move &) const
1023 {
1024 --state.depth;
1025 state.player = -state.player;
1026 state.code /= 2;
1027 }
1028
1029 template <typename Visitor>
1030 bool for_each_successor(const State &state, Visitor visit) const
1031 {
1032 if (state.depth >= 2)
1033 return true;
1034 if (not visit(ArtificialGameMove{2, 'L'}))
1035 return false;
1036 return visit(ArtificialGameMove{3, 'R'});
1037 }
1038};
1039
1040// Domain where a non-terminal node has no successors (for_each_successor is
1041// a no-op even though is_terminal returns false). The engine must treat such
1042// a node as a de-facto terminal and evaluate it.
1043struct EmptySuccessorGameDomain
1044{
1045 using State = ArtificialGameState;
1046 using Move = ArtificialGameMove;
1047 using Score = int;
1048
1049 bool is_terminal(const State &) const
1050 {
1051 return false; // explicitly NOT terminal
1052 }
1053
1054 Score evaluate(const State &) const
1055 {
1056 return 42;
1057 }
1058
1059 void apply(State &, const Move &) const
1060 {
1061 // never called
1062 }
1063
1064 void undo(State &, const Move &) const
1065 {
1066 // never called
1067 }
1068
1069 template <typename Visitor>
1070 bool for_each_successor(const State &, Visitor) const
1071 {
1072 return true; // no successors generated
1073 }
1074};
1075
1076// Domain where evaluate() returns score_ceiling (the extreme positive value).
1077// Used to verify the engine handles boundary scores without overflow.
1078struct ExtremeScoreGameDomain
1079{
1080 using State = ArtificialGameState;
1081 using Move = ArtificialGameMove;
1082 using Score = int;
1083
1084 bool is_terminal(const State &state) const
1085 {
1086 return state.depth >= 1;
1087 }
1088
1089 Score evaluate(const State &state) const
1090 {
1091 // root-child evaluates to ceiling from root player's perspective
1092 return adversarial_search_detail::score_ceiling<Score>() * state.player;
1093 }
1094
1095 void apply(State &state, const Move &move) const
1096 {
1097 state.code = move.next_code;
1098 state.player = -state.player;
1099 ++state.depth;
1100 }
1101
1102 void undo(State &state, const Move &) const
1103 {
1104 --state.depth;
1105 state.player = -state.player;
1106 state.code /= 2;
1107 }
1108
1109 template <typename Visitor>
1110 bool for_each_successor(const State &state, Visitor visit) const
1111 {
1112 if (state.depth >= 1)
1113 return true;
1114 return visit(ArtificialGameMove{2, 'A'});
1115 }
1116};
1117
1118// ---------------------------------------------------------------------------
1119// Domain that satisfies IncrementalEvaluator (evaluate_after) so the fast
1120// path in Alpha_Beta::collect_ordered_moves is exercised.
1121// Reuses the ArtificialGameDomain tree shape and leaf values.
1122// ---------------------------------------------------------------------------
1123class IncrementalEvalGameDomain
1124{
1125public:
1126 using State = ArtificialGameState;
1127 using Move = ArtificialGameMove;
1128 using Score = int;
1129
1130 mutable size_t evaluate_after_calls = 0;
1131
1132 bool is_terminal(const State &state) const
1133 {
1134 return state.depth == 2;
1135 }
1136
1137 Score evaluate(const State &state) const
1138 {
1139 return root_score(state.code) * state.player;
1140 }
1141
1142 Score evaluate_after(const State &state, const Move &move) const
1143 {
1144 ++evaluate_after_calls;
1145 // Compute child score without mutating state: simulate apply then evaluate.
1146 const int child_player = -state.player;
1147 return root_score(move.next_code) * child_player;
1148 }
1149
1150 void apply(State &state, const Move &move) const
1151 {
1152 state.code = move.next_code;
1153 state.player = -state.player;
1154 ++state.depth;
1155 }
1156
1157 void undo(State &state, const Move &) const
1158 {
1159 --state.depth;
1160 state.player = -state.player;
1161 state.code /= 2;
1162 }
1163
1164 template <typename Visitor>
1165 bool for_each_successor(const State &state, Visitor visit) const
1166 {
1167 if (state.depth >= 2)
1168 return true;
1169
1170 switch (state.code)
1171 {
1172 case 1:
1173 if (not visit(Move{2, 'A'}))
1174 return false;
1175 return visit(Move{3, 'B'});
1176
1177 case 2:
1178 if (not visit(Move{4, 'a'}))
1179 return false;
1180 return visit(Move{5, 'b'});
1181
1182 case 3:
1183 if (not visit(Move{6, 'a'}))
1184 return false;
1185 return visit(Move{7, 'b'});
1186
1187 default:
1188 return true;
1189 }
1190 }
1191
1192private:
1193 [[nodiscard]] static Score root_score(const size_t code) noexcept
1194 {
1195 switch (code)
1196 {
1197 case 2: return 6;
1198 case 3: return 1;
1199 case 4: return 3;
1200 case 5: return 5;
1201 case 6: return 2;
1202 case 7: return 4;
1203 default: return 0;
1204 }
1205 }
1206};
1207
1208} // end namespace
1209
1210// ---------------------------------------------------------------------------
1211// H1: apply() throws — undo must not be called
1212// ---------------------------------------------------------------------------
1213
1215{
1216 auto undo_called = std::make_shared<bool>(false);
1217 ThrowingApplyGameDomain domain;
1218 SearchLimits limits;
1219 limits.max_depth = 4;
1220
1221 Negamax<ThrowingApplyGameDomain> engine(domain, {}, limits);
1222 EXPECT_THROW((void) engine.search(AdversarialThrowState{undo_called, 0}),
1223 std::runtime_error);
1224 EXPECT_FALSE(*undo_called);
1225}
1226
1228{
1229 auto undo_called = std::make_shared<bool>(false);
1230 ThrowingApplyGameDomain domain;
1231 SearchLimits limits;
1232 limits.max_depth = 4;
1233
1234 Alpha_Beta<ThrowingApplyGameDomain> engine(domain, {}, limits);
1235 EXPECT_THROW((void) engine.search(AdversarialThrowState{undo_called, 0}),
1236 std::runtime_error);
1237 EXPECT_FALSE(*undo_called);
1238}
1239
1240// ---------------------------------------------------------------------------
1241// H1: post-apply exception — undo must be called
1242// ---------------------------------------------------------------------------
1243
1245{
1246 auto undo_called = std::make_shared<bool>(false);
1247 ThrowingPostApplyGameDomain domain;
1248 SearchLimits limits;
1249 limits.max_depth = 4;
1250
1251 Negamax<ThrowingPostApplyGameDomain> engine(domain, {}, limits);
1252 EXPECT_THROW((void) engine.search(AdversarialThrowState{undo_called, 0}),
1253 std::runtime_error);
1254 EXPECT_TRUE(*undo_called);
1255}
1256
1258{
1259 auto undo_called = std::make_shared<bool>(false);
1260 ThrowingPostApplyGameDomain domain;
1261 SearchLimits limits;
1262 limits.max_depth = 4;
1263
1265 EXPECT_THROW((void) engine.search(AdversarialThrowState{undo_called, 0}),
1266 std::runtime_error);
1267 EXPECT_TRUE(*undo_called);
1268}
1269
1270// ---------------------------------------------------------------------------
1271// H4: forced draw — both engines return value == 0
1272// ---------------------------------------------------------------------------
1273
1275{
1276 ForcedDrawGameDomain domain;
1277 SearchLimits limits;
1278 limits.max_depth = 2;
1279
1280 auto negamax = negamax_search(domain, ArtificialGameState{}, {}, limits);
1281 auto ab = alpha_beta_search(domain, ArtificialGameState{}, {}, limits);
1282
1283 EXPECT_EQ(negamax.value, 0);
1284 EXPECT_EQ(ab.value, 0);
1285 EXPECT_TRUE(negamax.exhausted());
1286 EXPECT_TRUE(ab.exhausted());
1287}
1288
1289// ---------------------------------------------------------------------------
1290// H4: empty successor list in a non-terminal — treated as de-facto terminal
1291// ---------------------------------------------------------------------------
1292
1294{
1295 EmptySuccessorGameDomain domain;
1296 SearchLimits limits;
1297 limits.max_depth = 4;
1298
1299 auto negamax = negamax_search(domain, ArtificialGameState{}, {}, limits);
1300 auto ab = alpha_beta_search(domain, ArtificialGameState{}, {}, limits);
1301
1302 // The root has no successors, so it is evaluated directly.
1303 // evaluate() returns 42 (from the side-to-move's perspective at depth 0,
1304 // player==1), so the root score should be 42.
1305 EXPECT_EQ(negamax.value, 42);
1306 EXPECT_EQ(ab.value, 42);
1307 EXPECT_GT(negamax.stats.terminal_states, 0u);
1308 EXPECT_GT(ab.stats.terminal_states, 0u);
1309}
1310
1311// ---------------------------------------------------------------------------
1312// H4: extreme score (score_ceiling) — no overflow
1313// ---------------------------------------------------------------------------
1314
1316{
1317 ExtremeScoreGameDomain domain;
1318 SearchLimits limits;
1319 limits.max_depth = 2;
1320
1321 auto negamax = negamax_search(domain, ArtificialGameState{}, {}, limits);
1322 auto ab = alpha_beta_search(domain, ArtificialGameState{}, {}, limits);
1323
1324 // score_ceiling is the max representable "normal" score; both engines
1325 // must return a finite, positive value.
1326 const int ceiling = adversarial_search_detail::score_ceiling<int>();
1327 EXPECT_GT(negamax.value, 0);
1328 EXPECT_LE(negamax.value, ceiling);
1329 EXPECT_EQ(ab.value, negamax.value);
1330 EXPECT_TRUE(negamax.has_principal_variation());
1331}
1332
1333// ---------------------------------------------------------------------------
1334// H5: IncrementalEvaluator fast path — evaluate_after() is used for ordering
1335// ---------------------------------------------------------------------------
1336
1338{
1339 IncrementalEvalGameDomain domain;
1340 ExplorationPolicy policy;
1341 policy.move_ordering = MoveOrderingMode::Estimated_Score;
1342 SearchLimits limits;
1343 limits.max_depth = 2;
1344
1345 Alpha_Beta<IncrementalEvalGameDomain> engine(domain, policy, limits);
1346 auto result = engine.search(ArtificialGameState{});
1347
1348 // The tree has the same structure as ArtificialGameDomain, so the minimax
1349 // value must match: max(min(3,5), min(2,4)) = max(3,2) = 3.
1350 EXPECT_EQ(result.value, 3);
1351 EXPECT_TRUE(result.exhausted());
1352
1353 // evaluate_after must have been called at least once (the fast path).
1354 EXPECT_GT(engine.domain().evaluate_after_calls, 0u);
1355
1356 // Move ordering stats must show that priority estimates were computed.
1357 EXPECT_GT(result.stats.move_ordering.priority_estimates, 0u);
1358}
1359
1361{
1362 // Verify that IncrementalEvaluator produces the same result as legacy
1363 // apply()/evaluate() ordering (ArtificialGameDomain).
1364 ArtificialGameDomain legacy_domain;
1365 IncrementalEvalGameDomain incr_domain;
1366
1367 ExplorationPolicy policy;
1368 policy.move_ordering = MoveOrderingMode::Estimated_Score;
1369 SearchLimits limits;
1370 limits.max_depth = 2;
1371
1372 auto legacy = alpha_beta_search(legacy_domain, ArtificialGameState{}, policy, limits);
1373 auto incr = alpha_beta_search(incr_domain, ArtificialGameState{}, policy, limits);
1374
1375 EXPECT_EQ(legacy.value, incr.value);
1376 EXPECT_EQ(legacy.stats.terminal_states, incr.stats.terminal_states);
1377}
Umbrella header for the implicit state-space search framework.
Exception handling system with formatted messages for Aleph-w.
#define ah_runtime_error()
Throws std::runtime_error unconditionally.
Definition ah-errors.H:282
Collector that stores trace events in an Aleph list.
Definition Negamax.H:273
Adversarial search engine with Alpha-Beta pruning.
Definition Alpha_Beta.H:71
Result search_with_window(State initial_state, const Score alpha, const Score beta)
Execute Alpha-Beta with an explicit root window.
Definition Alpha_Beta.H:177
static ExplorationPolicy default_policy() noexcept
Return the default exploration policy for Alpha-Beta.
Definition Alpha_Beta.H:97
Result search(State initial_state)
Execute Alpha-Beta from initial_state.
Definition Alpha_Beta.H:155
Simple dynamic array with automatic resizing and functional operations.
Definition tpl_array.H:139
Pure Negamax search over an implicit game tree.
Definition Negamax.H:840
static ExplorationPolicy default_policy() noexcept
Return the default exploration policy for adversarial search.
Definition Negamax.H:862
Result search(State initial_state)
Execute Negamax from initial_state.
Definition Negamax.H:928
Transposition-table container built on top of Aleph hash maps.
#define TEST(name)
Main namespace for Aleph-w library functions.
Definition ah-arena.H:89
and
Check uniqueness with explicit hash + equality functors.
bool operator==(const DynList< T > &l1, const DynList< T > &l2)
Equality operator for DynList.
Divide_Conquer_DP_Result< Cost > divide_and_conquer_partition_dp(const size_t groups, const size_t n, Transition_Cost_Fn transition_cost, const Cost inf=dp_optimization_detail::default_inf< Cost >())
Optimize partition DP using divide-and-conquer optimization.
auto iterative_deepening_alpha_beta_search(Domain domain, typename Domain::State initial_state, Tracer &tracer, ExplorationPolicy policy=Alpha_Beta< Domain >::default_policy(), SearchLimits limits={}, AdversarialIterativeDeepeningOptions< typename Domain::Score > options={})
Iterative deepening over Alpha-Beta with optional aspiration windows.
Definition Alpha_Beta.H:882
std::string code(Node *root)
Compute a string with the Lukasiewicz`s word of a tree.
auto iterative_deepening_negamax_search(Domain domain, typename Domain::State initial_state, ExplorationPolicy policy=Negamax< Domain >::default_policy(), SearchLimits limits={}, AdversarialIterativeDeepeningOptions< typename Domain::Score > options={})
Iterative deepening over Negamax without transposition table.
Definition Negamax.H:1317
auto alpha_beta_search(Domain domain, typename Domain::State initial_state, ExplorationPolicy policy=Alpha_Beta< Domain >::default_policy(), SearchLimits limits={})
Convenience wrapper for one-shot Alpha-Beta search.
Definition Alpha_Beta.H:797
AdversarialTraceEventKind
Trace event kinds emitted by adversarial search engines.
Definition Negamax.H:209
@ Domain
Preserve the order emitted by for_each_successor().
auto negamax_search(Domain domain, typename Domain::State initial_state, ExplorationPolicy policy=Negamax< Domain >::default_policy(), SearchLimits limits={})
Convenience wrapper for one-shot Negamax search.
Definition Negamax.H:1233
Itor::difference_type count(const Itor &beg, const Itor &end, const T &value)
Count elements equal to a value.
Definition ahAlgo.H:127
static struct argp_option options[]
Definition ntreepic.C:1886
#define TT
Definition ran_array.c:29
Controls for adversarial iterative deepening.
Definition Negamax.H:329
size_t initial_depth
First horizon to search.
Definition Negamax.H:330
One trace event produced by an adversarial search.
Definition Negamax.H:226
AdversarialTraceEventKind kind
Definition Negamax.H:230
Exploration controls shared across engines.
Strategy strategy
Traversal strategy.
MoveOrderingMode move_ordering
Successor-ordering mode.
Hard bounds applied by the search engine.
size_t max_depth
Maximum expansion depth.
static mt19937 engine
gsl_rng * r