Skip to content

Commit 3b0d42b

Browse files
authored
Node removal / tree trimming for iterative deepening (time) searches (#63)
* Nodes can be removed from tree * Node removal wip * Move to searchTree * Added removal options * Node removal functional * Node removal tests * Better options * Updated negamax example for node remove * docs for node removal * Removed console log
1 parent c053860 commit 3b0d42b

File tree

6 files changed

+163
-6
lines changed

6 files changed

+163
-6
lines changed

examples/negamax_mancala.ts

+16-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const game = new mancala.mancala();
66
game.start();
77

88
// Perform a search to depth 4
9-
console.log("Search 1\n");
9+
console.log("Search 1: Depth 4\n");
1010
let root = new mx.Node(mx.NodeType.ROOT, game.clone(), 0, 0, mx.NodeAim.MAX, game.moves);
1111
let tree = new mx.Negamax(root);
1212
tree.CreateChildNode = mancala.createChildCallback;
@@ -15,7 +15,7 @@ tree.opts.method = mx.SearchMethod.DEPTH;
1515
console.log(tree.evaluate());
1616

1717
// Perform a search deepening with alpha-beta pruning to depth 4, printing the result at each depth
18-
console.log("\nSearch 2\n");
18+
console.log("\nSearch 2: Deepening with depth callback\n");
1919
root = new mx.Node(mx.NodeType.ROOT, game.clone(), 0, 0, mx.NodeAim.MAX, game.moves);
2020
tree = new mx.Negamax(root);
2121
tree.CreateChildNode = mancala.createChildCallback;
@@ -27,7 +27,7 @@ tree.depthCallback = (tree, result) => {
2727
console.log(tree.evaluate());
2828

2929
// Perform a time limited optimal search
30-
console.log("\nSearch 3\n");
30+
console.log("\nSearch 3: Optimal Time\n");
3131
root = new mx.Node(mx.NodeType.ROOT, game.clone(), 0, 0, mx.NodeAim.MAX, game.moves);
3232
tree = new mx.Negamax(root);
3333
tree.CreateChildNode = mancala.createChildCallback;
@@ -37,11 +37,23 @@ tree.opts.method = mx.SearchMethod.TIME;
3737
console.log(tree.evaluate());
3838

3939
// Select randomly from the best moves
40-
console.log("\nSearch 4\n");
40+
console.log("\nSearch 4: Random best selectiom\n");
4141
root = new mx.Node(mx.NodeType.ROOT, game.clone(), 0, 0, mx.NodeAim.MAX, game.moves);
4242
tree = new mx.Negamax(root);
4343
tree.CreateChildNode = mancala.createChildCallback;
4444
tree.opts.depth = 2;
4545
tree.opts.randomBest = true;
4646
tree.opts.method = mx.SearchMethod.DEPTH;
4747
console.log(tree.evaluate());
48+
49+
// Remove nodes after each depth when node count >= 1000
50+
console.log("\nSearch 5: Node removal\n");
51+
root = new mx.Node(mx.NodeType.ROOT, game.clone(), 0, 0, mx.NodeAim.MAX, game.moves);
52+
tree = new mx.Negamax(root);
53+
tree.CreateChildNode = mancala.createChildCallback;
54+
tree.opts.depth = 8;
55+
tree.opts.optimal = true;
56+
tree.opts.method = mx.SearchMethod.DEEPENING;
57+
tree.opts.removalMethod = mx.RemovalMethod.COUNT;
58+
tree.opts.removalCount = 1000;
59+
console.log(tree.evaluate());

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { Node, NodeAim, NodeType } from "./tree/node.js";
22
export { Tree } from "./tree/tree.js";
3-
export { SearchOpts, SearchExit, PruningType, SearchMethod, SearchResult } from "./tree/search.js";
3+
export { SearchOpts, SearchExit, PruningType, SearchMethod, SearchResult, RemovalMethod } from "./tree/search.js";
44
export { EvaluateNodeFunc, GetMovesFunc, CreateChildNodeFunc, GetScoresFunc } from "./tree/interfaces.js";
55
export { Negamax } from "./negamax/negamax.js";
66
export { NegamaxResult, NegamaxOpts } from "./negamax/index.js";

src/negamax/index.ts

+16
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ import { SearchOpts, SearchResult } from "../tree/search.js";
2828
* If the value is negative, the one that
2929
* is more moves away. The opposite holds if the parent is a minimising node.
3030
*
31+
* Does not work with {@link NegamaxOpts.optimal} set to `true`.
32+
*
33+
* ### Node removal
34+
* The {@link NegamaxOpts.removalMethod} option allows for trimming nodes
35+
* that aren't needed from the game tree in between successive searches in
36+
* {@link SearchMethod.DEEPENING} and {@link SearchMethod.TIME} modes.
37+
* This can greatly reduce memory usage and allow
38+
* for far deeper searches, but with more computation required.
39+
*
40+
* There are 3 options:
41+
* - {@link RemovalMethod.ALWAYS} = remove nodes after every depth search.
42+
* - {@link RemovalMethod.DEPTH} = remove nodes when search depth is
43+
* \>\= {@link NegamaxOpts.removalDepth}.
44+
* - {@link RemovalMethod.COUNT} = remove nodes when node count is \>\=
45+
* {@link NegamaxOpts.removalCount}.
46+
*
3147
* ## Random selections
3248
* These options can be useful for making the AI less 'robotic'. Neither of them
3349
* work with {@link NegamaxOpts.pruneByPathLength} or

src/tree/search.ts

+31
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,22 @@ export const enum PruningType {
3939
ALPHA_BETA,
4040
}
4141

42+
/**
43+
* Control the removal of nodes in between iterative searches when using
44+
* {@link SearchMethod.DEEPENING} or {@link SearchMethod.TIME}.
45+
*
46+
*/
47+
export const enum RemovalMethod {
48+
/** Disable node removal */
49+
NONE,
50+
/** Run node removal at the end of each depth search */
51+
ALWAYS,
52+
/** Run node removal above a certain depth ({@link SearchOpts.removalDepth}) */
53+
DEPTH,
54+
/** Run node removal when the node count exceeds a certain value ({@link SearchOpts.removalCount}) */
55+
COUNT,
56+
}
57+
4258
/**
4359
* Class representing common options for searching a {@link Tree}.
4460
*
@@ -114,6 +130,21 @@ export class SearchOpts {
114130
* specific support.
115131
*/
116132
randomWeight = 0;
133+
/**
134+
* For removing nodes in between iterative searches.
135+
* Allows for deeper searches that would otherwise be memory limited.
136+
*
137+
* Good for reduced memory usage but takes extra time.
138+
*/
139+
removalMethod = RemovalMethod.NONE;
140+
/**
141+
* Controls the way removal behaves for {@link RemovalMethod.DEPTH}
142+
*/
143+
removalDepth = 0;
144+
/**
145+
* Controls the way removal behaves for {@link RemovalMethod.COUNT}
146+
*/
147+
removalCount = 0;
117148
}
118149

119150
/**

src/tree/searchtree.ts

+61-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Tree } from "./tree.js";
22
import { Node, NodeAim } from "./node.js";
33
import { EvaluateNodeFunc } from "./interfaces.js";
4-
import { SearchExit, SearchMethod, SearchOpts, SearchResult } from "./search.js";
4+
import { RemovalMethod, SearchExit, SearchMethod, SearchOpts, SearchResult } from "./search.js";
55
import { bubbleSort, bubbleSortEfficient, defaultSort, SortMethod } from "./sorting.js";
66

77
/**
@@ -73,6 +73,17 @@ export class SearchTree<GS, M, D> extends Tree<GS, M, D> {
7373
return result;
7474
} else {
7575
prevResult = result;
76+
// Check if node removal is enabled
77+
if (this.opts.removalMethod != RemovalMethod.NONE) {
78+
if (
79+
this.opts.removalMethod == RemovalMethod.ALWAYS ||
80+
(this.opts.removalMethod == RemovalMethod.DEPTH && activeDepth >= this.opts.removalDepth) ||
81+
(this.opts.removalMethod == RemovalMethod.COUNT && this.nodeCount >= this.opts.removalCount)
82+
) {
83+
// Remove nodes to minimum required for next search depth
84+
this.removeNodes();
85+
}
86+
}
7687
}
7788
}
7889
}
@@ -299,4 +310,53 @@ export class SearchTree<GS, M, D> extends Tree<GS, M, D> {
299310
getOptimalMoves(): M[] {
300311
return [...this.optimalMoveGen(this.activeRoot)];
301312
}
313+
314+
removeNodes(): number {
315+
const removedCount = this.removeNonBestNodes(this.activeRoot, 0, true);
316+
this.nodeCount -= removedCount;
317+
return removedCount;
318+
}
319+
320+
/**
321+
* Recursively go through node and its children removing all
322+
* nodes that are not best
323+
*/
324+
protected removeNonBestNodes(node: Node<GS, M, D>, depth: number, keep: boolean): number {
325+
// Check if node has 0 children
326+
if (node.children.length == 0) {
327+
return 0;
328+
} else if (keep) {
329+
// keep all children, remove grand-children etc
330+
let removedCount = 0;
331+
// Iterate through children
332+
for (let i = 0; i < node.children.length; i++) {
333+
const child = node.children[i];
334+
removedCount += this.removeNonBestNodes(child, depth + 1, keep && i == 0);
335+
}
336+
node.descendantCount -= removedCount;
337+
return removedCount;
338+
} else {
339+
// Only keep the best child
340+
// Sort current children
341+
bubbleSort(node.children, false, false);
342+
// Check that best child is correct
343+
if (node.children[0] != node.child) {
344+
throw new Error("Node best child mismatch");
345+
}
346+
// Update moves and get number of nodes removed
347+
let removedCount = -node.children[0].descendantCount - 1;
348+
for (let i = 0; i < node.children.length; i++) {
349+
node.moves[i] = node.children[i].move;
350+
removedCount += node.children[i].descendantCount + 1;
351+
}
352+
// Remove the nodes
353+
node.children = [node.children[0]];
354+
// Recusrive call on remaining child
355+
removedCount += this.removeNonBestNodes(node.children[0], depth + 1, false);
356+
// Update counts and index of next move to generate from
357+
node.descendantCount -= removedCount;
358+
node.moveInd = 1;
359+
return removedCount;
360+
}
361+
}
302362
}

tests/node_removal.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as mancala from "../examples/games/mancala.js";
2+
import * as mx from "../dist/index.js";
3+
import { validateDescendants } from "../dist/tree/utils.js";
4+
5+
test("Remove every depth", () => {
6+
for (let depth = 1; depth <= 8; depth++) {
7+
const game = new mancala.mancala();
8+
game.start();
9+
10+
// Standard search
11+
let root = new mx.Node(mx.NodeType.ROOT, game.clone(), 0, 0, mx.NodeAim.MAX, game.moves);
12+
let tree = new mx.Negamax(root);
13+
tree.CreateChildNode = mancala.createChildCallback;
14+
tree.opts.optimal = true;
15+
tree.opts.method = mx.SearchMethod.DEEPENING;
16+
tree.opts.depth = depth;
17+
18+
let result = tree.evaluate();
19+
validateDescendants(tree.root);
20+
expect(tree.nodeCount).toBe(tree.root.descendantCount + 1);
21+
22+
// Removal search
23+
root = new mx.Node(mx.NodeType.ROOT, game.clone(), 0, 0, mx.NodeAim.MAX, game.moves);
24+
tree = new mx.Negamax(root);
25+
tree.CreateChildNode = mancala.createChildCallback;
26+
tree.opts.optimal = true;
27+
tree.opts.method = mx.SearchMethod.DEEPENING;
28+
tree.opts.depth = depth;
29+
tree.opts.removalMethod = mx.RemovalMethod.ALWAYS;
30+
31+
let result2 = tree.evaluate();
32+
33+
expect(result2.move).toBe(result.move);
34+
expect(result2.value).toBe(result.value);
35+
validateDescendants(tree.root);
36+
expect(tree.nodeCount).toBe(tree.root.descendantCount + 1);
37+
}
38+
});

0 commit comments

Comments
 (0)