diff --git a/CHANGES.md b/CHANGES.md index 2a751bb..a1b8061 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,79 @@ -# List of breaking changes +# Changelog + +## v0.1.1 ++ **Resolving and Reloading** v0.1.1 introduces capabilities to reload and + resolve. Partial saves and reloading were already possible, but there were not + good mechanisms in place to rebuild and resolve forgotten streets. Updates + include: + + - **Updated Invariants for `PostFlopGame`**: + + Previously `PostFlopGame::state` stored if a game had been allocated or solved. + This update expands `State` to account for partial solve information loaded + from disk. In particular, `State::Solved` has been expanded to + + + `State::SolvedFlop` + + `State::SolvedTurn` + + `State::Solved` + + While `PostFlopGame::state` tracks the current solve status, + `PostFlopGame::storage_mode` tracks how much memory is allocated. After a + reload, `storage_mode` might be less than `BoardState::River`, meaning that + some memory has not been allocated. + + For instance, if we run a flop solve (so a full tree startingat the flop) + and save it as a flop save (save flop data, discarding turn and river data), + only the flop data will be written to disk. After reloading from disk, say + into variable `game`, the following will be true: + + 1. `game.storage_mode == BoardState::Flop`: This represents that only flop + memory is allocated (though not necessarily solved). + + + 2. `game.state == State::SolvedFlop`: This represents that flop data is + solved (but not turn/river). + + Allocating memory to store turn and river will update `storage_mode` to be + `BoardState::River`. Thus `storage_mode == BoardState::Flop` together with + `state == State::SolvedFlop` can be interpreted as "we've allocated the full + game tree but only flop nodes have real data. + + - **Removed requirements for game to not be solved**: There were a lot of + places that panicked if the game was already solved (e.g., trying to solve + again, or node locking, etc). This felt like an unrealistic burden: we might + want to nodelock a game after solving it, for instance, to compute some + other results. + + - **Added `reload_and_resolve_copy()`**: This function does the following: + 1. Takes an input `g: PostFlopGame` that may be paritally loaded. + 2. Creates a new game `ng` from `g`'s configuration + 3. Initializes nodes and allocates memory for `ng` + 4. Copies loaded data from `g` (i.e., if `g.state == State::SolvedTurn`, + then copy all flop and turn data) + 5. Locks copied nodes + 6. Solves `ng` + 7. Unlocks (restoring previous locking to whatever was passed in from `g`) + + - **Added `reload_and_resolve()`**: Similar to `reload_and_resolve_copy`, this + modifies the supplied game in place. This is currently implemented using + `reload_and_resolve_copy()`, and required memory for both the input game and + the rebuilt game. This process overwrites the input game, so that memory + will be released. + ++ **Replacing panics with Result<(), String>**: we should be able to + handle many instances of errors gracefully, so we've begun replacing + `panic!()`s with `Result<>`s + ++ **Helper Functions**: We've added several helper functions, including + + - `PostFlopNode::action_index(action: Action) -> Option`: return the index into + this node of the specified action if it exists, and `None` otherwise. + + - `PostFlopNode::compute_history_recursive(&self, &PostFlopGame) -> Option>`: + Recursively compute the history of the given node as a path of action indices. + + - `PostFlopNode::actions() -> Vec`: compute the available actions of a given node + ## 2023-10-01 diff --git a/Cargo.toml b/Cargo.toml index 981906f..f163db5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "postflop-solver" -version = "0.1.0" +version = "0.1.1" authors = ["Wataru Inariba", "Ben Kushigian"] edition = "2021" description = "An open-source postflop solver for Texas hold'em poker" diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..3da8edc --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,200 @@ +# Design + +This document is a description, as far as I understand it, of the inner design +of the solver and PostFlopGame. This is a working document for me to get my +bearings. + +## PostFlopGame + +### Build/Allocate/Initialize + +To set up a `PostFlopGame` we need to **create a `PostFlopGame` instance**, +**allocate global storage and `PostFlopNode`s**, and **initialize the +`PostFlopNode` child/parent relationship**. This is done in several steps. + +We begin by creating a `PostFlopGame` instance. + +```rust +let mut game = PostFlopGame::with_config(card_config, action_tree).unwrap(); +``` + +A `PostFlopGame` requires an +`ActionTree` which describes all possible actions and lines (no runout +information), and a `CardConfig`, which describes player ranges and +flop/turn/river data. + +Once we have created a `PostFlopGame` instance we need to allocate the following +memory and initialize its values: + ++ `game.node_arena` ++ `game.storage1` ++ `game.storage2` ++ `game.storage_ip` ++ `game.storage_chance` + +These fields are not allocated/initialized at the same time: + ++ `game.node_arena` is allocated and initialized via `with_config()` (i.e., when + we created our `PostFlopGame`), ++ other storage is allocated via `game.allocate_memory()`. + +#### Allocating and Initializing `node_arena` + +We constructed a `PostFlopGame` by calling +`PostFlopGame::with_config(card_config, action_tree)`, which under the hood +actually calls: + +```rust + let mut game = Self::new(); + game.update_config(card_config, action_tree)?; +``` + +`PostFlopGame::update_config` sets up configuration data, sanity checks things +are correct, and then calls `self.init_root()`. + +`init_root` is responsible for: + +1. Counting number of `PostFlopNode`s to be allocated (`self.nodes_per_street`), + broken up by flop, turn, and river +2. Allocating `PostFlopNode`s in the `node_arena` field +3. Clearing storage: `self.clear_storage()` sets each storage item to a new + `Vec` +4. Invoking `build_tree_recursive` which initializes each node's child/parent + relationship via `child_offset` (through calls to `push_actions` and + `push_chances`). + +Each `PostFlopNode` points to node-specific data (e.g., strategies and +cfregrets) that is located inside of `PostFlopGame.storage*` fields (which is +currently unallocated) via similarly named fields `PostFlopNode.storage*`. + +Additionally, each node points to the children offset with `children_offset`, +which records where in `node_arena` relative to the current node that node's +children begin. We allocate this memory via: + +```rust +game.allocate_memory(false); // pass `true` to use compressed memory +``` + +This allocates the following memory: + ++ `self.storage1` ++ `self.storage2` ++ `self.storage3` ++ `self.storage_chance` + +Next, `allocate_memory()` calls `allocate_memory_nodes(&mut self)`, which +iterates through each node in `node_arena` and sets storage pointers. + +After `allocate_memory` returns we still need to set `child_offset`s. + +### Storage + +There are several fields marked as `// global storage` in `game::mod::PostFlopGame`: + +```rust + // global storage + // `storage*` are used as a global storage and are referenced by `PostFlopNode::storage*`. + // Methods like `PostFlopNode::strategy` define how the storage is used. + node_arena: Vec>, + storage1: Vec, + storage2: Vec, + storage_ip: Vec, + storage_chance: Vec, + locking_strategy: BTreeMap>, +``` + +These are referenced from `PostFlopNode`: + +```rust + storage1: *mut u8, // strategy + storage2: *mut u8, // regrets or cfvalues + storage3: *mut u8, // IP cfvalues +``` + ++ `storage1` seems to store the strategy ++ `storage2` seems to store regrets/cfvalues, and ++ `storage3` stores IP's cf values (does that make `storage2` store OOP's cfvalues?) + +Storage is a byte vector `Vec`, and these store floating point values. + +> [!IMPORTANT] +> Why are these stored as `Vec`s? Is this for swapping between +> `f16` and `f32`s? + +Some storage is allocated in `game::base::allocate_memory`: + +```rust + let storage_bytes = (num_bytes * self.num_storage) as usize; + let storage_ip_bytes = (num_bytes * self.num_storage_ip) as usize; + let storage_chance_bytes = (num_bytes * self.num_storage_chance) as usize; + + self.storage1 = vec![0; storage_bytes]; + self.storage2 = vec![0; storage_bytes]; + self.storage_ip = vec![0; storage_ip_bytes]; + self.storage_chance = vec![0; storage_chance_bytes]; +``` + +`node_arena` is allocated in `game::base::init_root()`: + +```rust + let num_nodes = self.count_nodes_per_street(); + let total_num_nodes = num_nodes[0] + num_nodes[1] + num_nodes[2]; + + if total_num_nodes > u32::MAX as u64 + || mem::size_of::() as u64 * total_num_nodes > isize::MAX as u64 + { + return Err("Too many nodes".to_string()); + } + + self.num_nodes = num_nodes; + self.node_arena = (0..total_num_nodes) + .map(|_| MutexLike::new(PostFlopNode::default())) + .collect::>(); + self.clear_storage(); +``` + +`locking_strategy` maps node indexes (`PostFlopGame::node_index`) to a locked +strategy. `locking_strategy` is initialized to an empty `BTreeMap>` by deriving Default. It is inserted into via +`PostFlopGame::lock_current_strategy` + +### Serialization/Deserialization + +Serialization relies on the `bincode` library's `Encode` and `Decode`. We can set +the `target_storage_mode` to allow for a non-full save. For instance, + +```rust +game.set_target_storage_mode(BoardState::Turn); +``` + +will ensure that when `game` is encoded, it will only save Flop and Turn data. +When a serialized tree is deserialized, if it is a partial save (e.g., a Turn +save) you will not be able to navigate to unsaved streets. + +Several things break when we deserialize a partial save: + ++ `node_arena` is only partially populated ++ `node.children()` points to raw data when `node` points to an street that is + not serialized (e.g., a chance node before the river for a Turn save). + +### Allocating `node_arena` + +We want to first allocate nodes for `node_arena`, and then run some form of +`build_tree_recursive`. This assumes that `node_arena` is already allocated, and +recursively visits children of nodes and modifies them to + +### Data Coupling/Relations/Invariants + ++ A node is locked IFF it is contained in the game's locking_strategy ++ `PostFlopGame.node_arena` is pointed to by `PostFlopNode.children_offset`. For + instance, this is the basic definition of the `PostFlopNode.children()` + function: + + ```rust + slice::from_raw_parts( + self_ptr.add(self.children_offset as usize), + self.num_children as usize, + ) + ``` + + We get a pointer to `self` and add children offset. diff --git a/README.md b/README.md index 7d88afd..5f0fde9 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ An open-source postflop solver library written in Rust Documentation: https://b-inary.github.io/postflop_solver/postflop_solver/ **Related repositories** -- Web app (WASM Postflop): https://github.com/b-inary/wasm-postflop -- Desktop app (Desktop Postflop): https://github.com/b-inary/desktop-postflop + +- Desktop app (Desktop Postflop): https://github.com/bkushigian/desktop-postflop (supported) +- Web app (WASM Postflop): https://github.com/b-inary/wasm-postflop (not supported) **Note:** The primary purpose of this library is to serve as a backend engine for the GUI applications ([WASM Postflop] and [Desktop Postflop]). @@ -25,7 +26,7 @@ Therefore, breaking changes are often made without version changes. See [CHANGES.md](CHANGES.md) for details about breaking changes. [WASM Postflop]: https://github.com/b-inary/wasm-postflop -[Desktop Postflop]: https://github.com/b-inary/desktop-postflop +[Desktop Postflop]: https://github.com/bkushigian/desktop-postflop ## Usage @@ -43,7 +44,7 @@ You can find examples in the [examples](examples) directory. If you have cloned this repository, you can run the example with the following command: ```sh -$ cargo run --release --example basic +cargo run --release --example basic ``` ## Implementation details diff --git a/examples/file_io_big.rs b/examples/file_io_big.rs new file mode 100644 index 0000000..a6df5ec --- /dev/null +++ b/examples/file_io_big.rs @@ -0,0 +1,147 @@ +use postflop_solver::*; +use std::time; + +fn main() { + // see `basic.rs` for the explanation of the following code + + let oop_range = "66+,A8s+,A5s-A4s,AJo+,K9s+,KQo,QTs+,JTs,96s+,85s+,75s+,65s,54s"; + let ip_range = "QQ-22,AQs-A2s,ATo+,K5s+,KJo+,Q8s+,J8s+,T7s+,96s+,86s+,75s+,64s+,53s+"; + let file_save_name = "test_save.pfs"; + + let card_config = CardConfig { + range: [oop_range.parse().unwrap(), ip_range.parse().unwrap()], + flop: flop_from_str("Td9d6h").unwrap(), + turn: NOT_DEALT, //card_from_str("Qc").unwrap(), + river: NOT_DEALT, + }; + + let bet_sizes = BetSizeOptions::try_from(("60%, e, a", "2.5x")).unwrap(); + + let tree_config = TreeConfig { + initial_state: BoardState::Flop, + starting_pot: 200, + effective_stack: 900, + rake_rate: 0.0, + rake_cap: 0.0, + flop_bet_sizes: [bet_sizes.clone(), bet_sizes.clone()], + turn_bet_sizes: [bet_sizes.clone(), bet_sizes.clone()], + river_bet_sizes: [bet_sizes.clone(), bet_sizes], + turn_donk_sizes: None, + river_donk_sizes: Some(DonkSizeOptions::try_from("50%").unwrap()), + add_allin_threshold: 1.5, + force_allin_threshold: 0.15, + merging_threshold: 0.1, + }; + + let action_tree = ActionTree::new(tree_config).unwrap(); + let mut game = PostFlopGame::with_config(card_config, action_tree).unwrap(); + game.allocate_memory(false); + + let max_num_iterations = 1000; + let target_exploitability = game.tree_config().starting_pot as f32 * 0.005; + let full_solve_start = time::Instant::now(); + solve(&mut game, max_num_iterations, target_exploitability, true); + + println!( + "Full solve: {:5.3} seconds", + full_solve_start.elapsed().as_secs_f64() + ); + + // save the solved game tree to a file + // 4th argument is zstd compression level (1-22); requires `zstd` feature to use + save_data_to_file(&game, "memo string", file_save_name, None).unwrap(); + + // load the solved game tree from a file + // 2nd argument is the maximum memory usage in bytes + let (mut game2, _memo_string): (PostFlopGame, _) = + load_data_from_file(file_save_name, None).unwrap(); + + // check if the loaded game tree is the same as the original one + game.cache_normalized_weights(); + game2.cache_normalized_weights(); + assert_eq!(game.equity(0), game2.equity(0)); + + println!("\n-----------------------------------------"); + println!("Saving [Turn Save] to {}", file_save_name); + // discard information after the river deal when serializing + // this operation does not lose any information of the game tree itself + game2.set_target_storage_mode(BoardState::Turn).unwrap(); + + // compare the memory usage for serialization + println!( + "Memory usage of the original game tree: {:.2}MB", // 11.50MB + game.target_memory_usage() as f64 / (1024.0 * 1024.0) + ); + println!( + "Memory usage of the truncated game tree: {:.2}MB", // 0.79MB + game2.target_memory_usage() as f64 / (1024.0 * 1024.0) + ); + + // Overwrite the file with the truncated game tree. The game tree + // constructed from this file cannot access information after the turn; this + // data will need to be recomputed via `PostFlopGame::reload_and_resolve`. + save_data_to_file(&game2, "memo string", file_save_name, None).unwrap(); + + println!("Reloading from Turn Save and Resolving..."); + let turn_solve_start = time::Instant::now(); + let game3 = + PostFlopGame::copy_reload_and_resolve(&game2, 100, target_exploitability, true).unwrap(); + for (i, (a, b)) in game2.strategy().iter().zip(game3.strategy()).enumerate() { + if (a - b).abs() > 0.001 { + println!("{i}: Oh no"); + } + } + println!( + "Turn solve: {:5.3} seconds", + turn_solve_start.elapsed().as_secs_f64() + ); + + println!("\n-----------------------------------------"); + println!("Saving [Flop Save] to {}", file_save_name); + // discard information after the flop deal when serializing + // this operation does not lose any information of the game tree itself + game2.set_target_storage_mode(BoardState::Flop).unwrap(); + + // compare the memory usage for serialization + println!( + "Memory usage of the original game tree: {:.2}MB", // 11.50MB + game.target_memory_usage() as f64 / (1024.0 * 1024.0) + ); + println!( + "Memory usage of the truncated game tree: {:.2}MB", // 0.79MB + game2.target_memory_usage() as f64 / (1024.0 * 1024.0) + ); + + // overwrite the file with the truncated game tree + // game tree constructed from this file cannot access information after the flop deal + save_data_to_file(&game2, "This is a flop save", file_save_name, None).unwrap(); + + println!("Reloading from Flop Save and Resolving..."); + let flop_solve_start = time::Instant::now(); + println!("Using copy_reload_and_resolve: this results in a new game"); + let game3 = + PostFlopGame::copy_reload_and_resolve(&game2, 100, target_exploitability, true).unwrap(); + + println!( + "\nFlop solve: {:5.3} seconds", + flop_solve_start.elapsed().as_secs_f64() + ); + + for (i, (a, b)) in game2.strategy().iter().zip(game3.strategy()).enumerate() { + if (a - b).abs() > 0.001 { + println!("{i}: Oh no"); + } + } + + println!(); + println!("Using reload_and_resolve: this overwrites the existing game"); + let flop_solve_start = time::Instant::now(); + let _ = PostFlopGame::reload_and_resolve(&mut game2, 1000, target_exploitability, true); + println!( + "\nFlop solve: {:5.3} seconds", + flop_solve_start.elapsed().as_secs_f64() + ); + + // delete the file + std::fs::remove_file(file_save_name).unwrap(); +} diff --git a/examples/node_locking.rs b/examples/node_locking.rs index 74ff464..f71c259 100644 --- a/examples/node_locking.rs +++ b/examples/node_locking.rs @@ -27,7 +27,7 @@ fn normal_node_locking() { // node locking must be performed after allocating memory and before solving game.play(1); // OOP all-in - game.lock_current_strategy(&[0.25, 0.75]); // lock IP's strategy: 25% fold, 75% call + let _ = game.lock_current_node(&[0.25, 0.75]); // lock IP's strategy: 25% fold, 75% call game.back_to_root(); solve(&mut game, 1000, 0.001, false); @@ -42,7 +42,7 @@ fn normal_node_locking() { game.allocate_memory(false); game.play(1); - game.lock_current_strategy(&[0.5, 0.5]); // lock IP's strategy: 50% fold, 50% call + let _ = game.lock_current_node(&[0.5, 0.5]); // lock IP's strategy: 50% fold, 50% call game.back_to_root(); solve(&mut game, 1000, 0.001, false); @@ -77,7 +77,7 @@ fn partial_node_locking() { game.allocate_memory(false); // lock OOP's strategy: only JJ is locked and the rest is not - game.lock_current_strategy(&[0.8, 0.0, 0.0, 0.2, 0.0, 0.0]); // JJ: 80% check, 20% all-in + let _ = game.lock_current_node(&[0.8, 0.0, 0.0, 0.2, 0.0, 0.0]); // JJ: 80% check, 20% all-in solve(&mut game, 1000, 0.001, false); game.cache_normalized_weights(); diff --git a/examples/simple.rs b/examples/simple.rs new file mode 100644 index 0000000..00782d0 --- /dev/null +++ b/examples/simple.rs @@ -0,0 +1,53 @@ +use postflop_solver::*; + +fn main() { + // ranges of OOP and IP in string format + // see the documentation of `Range` for more details about the format + let oop_range = "66+"; + let ip_range = "66+"; + + let card_config = CardConfig { + range: [oop_range.parse().unwrap(), ip_range.parse().unwrap()], + flop: flop_from_str("Td9d6h").unwrap(), + turn: NOT_DEALT, + river: NOT_DEALT, + }; + + // bet sizes -> 60% of the pot, geometric size, and all-in + // raise sizes -> 2.5x of the previous bet + // see the documentation of `BetSizeOptions` for more details + let bet_sizes = BetSizeOptions::try_from(("100%", "100%")).unwrap(); + + let tree_config = TreeConfig { + initial_state: BoardState::Flop, // must match `card_config` + starting_pot: 200, + effective_stack: 200, + rake_rate: 0.0, + rake_cap: 0.0, + flop_bet_sizes: [bet_sizes.clone(), bet_sizes.clone()], // [OOP, IP] + turn_bet_sizes: [bet_sizes.clone(), bet_sizes.clone()], + river_bet_sizes: [bet_sizes.clone(), bet_sizes], + turn_donk_sizes: None, // use default bet sizes + river_donk_sizes: Some(DonkSizeOptions::try_from("100%").unwrap()), + add_allin_threshold: 1.5, // add all-in if (maximum bet size) <= 1.5x pot + force_allin_threshold: 0.15, // force all-in if (SPR after the opponent's call) <= 0.15 + merging_threshold: 0.1, + }; + + // build the game tree + // `ActionTree` can be edited manually after construction + let action_tree = ActionTree::new(tree_config).unwrap(); + let mut game = PostFlopGame::with_config(card_config, action_tree).unwrap(); + + // allocate memory without compression (use 32-bit float) + game.allocate_memory(false); + + // solve the game + let max_num_iterations = 20; + let target_exploitability = game.tree_config().starting_pot as f32 * 0.100; // 10.0% of the pot + let exploitability = solve(&mut game, max_num_iterations, target_exploitability, true); + println!("Exploitability: {:.2}", exploitability); + + // get equity and EV of a specific hand + game.cache_normalized_weights(); +} diff --git a/src/card.rs b/src/card.rs index 2bba763..e36121a 100644 --- a/src/card.rs +++ b/src/card.rs @@ -11,6 +11,8 @@ use bincode::{Decode, Encode}; /// - `card_id = 4 * rank + suit` (where `0 <= card_id < 52`) /// - `rank`: 2 => `0`, 3 => `1`, 4 => `2`, ..., A => `12` /// - `suit`: club => `0`, diamond => `1`, heart => `2`, spade => `3` +/// +/// An undealt card is represented by Card::MAX (see `NOT_DEALT`). pub type Card = u8; /// Constant representing that the card is not yet dealt. diff --git a/src/file.rs b/src/file.rs index 17e7d5b..ccc0343 100644 --- a/src/file.rs +++ b/src/file.rs @@ -11,7 +11,6 @@ use crate::bunching::*; use crate::game::*; -use crate::interface::*; use bincode::{Decode, Encode}; use std::fs::File; use std::io::{BufReader, BufWriter, Read, Write}; @@ -166,7 +165,7 @@ pub fn load_data_from_std_read( ) -> Result<(T, String), String> { let magic: u32 = decode_from_std_read(reader, "Failed to read magic number")?; if magic != MAGIC { - return Err("Magic number is invalid".to_string()); + return Err("Unrecognized file format".to_string()); } let version: u8 = decode_from_std_read(reader, "Failed to read version number")?; @@ -241,7 +240,7 @@ impl FileData for PostFlopGame { } fn is_ready_to_save(&self) -> bool { - self.is_solved() + self.is_partially_solved() } fn estimated_memory_usage(&self) -> u64 { @@ -294,27 +293,28 @@ mod tests { finalize(&mut game); // save - save_data_to_file(&game, "", "tmpfile.flop", None).unwrap(); + let file_save_location = "tmpfile.pfs"; + save_data_to_file(&game, "", file_save_location, None).unwrap(); // load - let mut game: PostFlopGame = load_data_from_file("tmpfile.flop", None).unwrap().0; + let mut game: PostFlopGame = load_data_from_file(file_save_location, None).unwrap().0; // save (turn) game.set_target_storage_mode(BoardState::Turn).unwrap(); - save_data_to_file(&game, "", "tmpfile.flop", None).unwrap(); + save_data_to_file(&game, "", file_save_location, None).unwrap(); // load (turn) - let mut game: PostFlopGame = load_data_from_file("tmpfile.flop", None).unwrap().0; + let mut game: PostFlopGame = load_data_from_file(file_save_location, None).unwrap().0; // save (flop) game.set_target_storage_mode(BoardState::Flop).unwrap(); - save_data_to_file(&game, "", "tmpfile.flop", None).unwrap(); + save_data_to_file(&game, "", file_save_location, None).unwrap(); // load (flop) - let mut game: PostFlopGame = load_data_from_file("tmpfile.flop", None).unwrap().0; + let mut game: PostFlopGame = load_data_from_file(file_save_location, None).unwrap().0; // remove tmpfile - std::fs::remove_file("tmpfile.flop").unwrap(); + std::fs::remove_file(file_save_location).unwrap(); game.cache_normalized_weights(); let weights_oop = game.normalized_weights(0); @@ -330,6 +330,99 @@ mod tests { assert!((root_ev_ip - 15.0).abs() < 1e-4); } + #[test] + fn reload_and_resolve() { + let card_config = CardConfig { + range: [Range::ones(); 2], + flop: flop_from_str("Td9d6h").unwrap(), + ..Default::default() + }; + + let tree_config = TreeConfig { + starting_pot: 60, + effective_stack: 970, + flop_bet_sizes: [("50%", "").try_into().unwrap(), Default::default()], + turn_bet_sizes: [("50%", "").try_into().unwrap(), Default::default()], + ..Default::default() + }; + + let action_tree = ActionTree::new(tree_config).unwrap(); + let mut game = PostFlopGame::with_config(card_config, action_tree).unwrap(); + + game.allocate_memory(false); + + let target_exploitability = 0.0001; + let max_iters = 500; + let ev_threshold = 0.1; + + crate::solve(&mut game, max_iters, target_exploitability, false); + let file_save_name = "test_reload_and_resolve.pfs"; + + // save (turn) + game.set_target_storage_mode(BoardState::Turn).unwrap(); + save_data_to_file(&game, "", file_save_name, None).unwrap(); + + // load (turn) + let mut turn_game: PostFlopGame = load_data_from_file(file_save_name, None).unwrap().0; + turn_game.cache_normalized_weights(); + let ev_oop = turn_game.expected_values(0); + let ev_ip = turn_game.expected_values(1); + + assert!(PostFlopGame::reload_and_resolve( + &mut turn_game, + max_iters, + target_exploitability, + false + ) + .is_ok()); + turn_game.cache_normalized_weights(); + let resolved_ev_oop = turn_game.expected_values(0); + let resolved_ev_ip = turn_game.expected_values(1); + + for (ev1, ev2) in ev_oop.iter().zip(resolved_ev_oop) { + assert!((ev1 - ev2).abs() < ev_threshold); + } + for (ev1, ev2) in ev_ip.iter().zip(resolved_ev_ip) { + assert!((ev1 - ev2).abs() < ev_threshold); + } + // save (flop) + game.set_target_storage_mode(BoardState::Flop).unwrap(); + save_data_to_file(&game, "", file_save_name, None).unwrap(); + + // load (flop) + let mut flop_game: PostFlopGame = load_data_from_file(file_save_name, None).unwrap().0; + flop_game.cache_normalized_weights(); + let ev_oop = flop_game.expected_values(0); + let ev_ip = flop_game.expected_values(1); + + assert!(PostFlopGame::reload_and_resolve( + &mut flop_game, + max_iters, + target_exploitability, + false + ) + .is_ok()); + flop_game.cache_normalized_weights(); + let resolved_ev_oop = flop_game.expected_values(0); + let resolved_ev_ip = flop_game.expected_values(1); + + for (ev1, ev2) in ev_oop.iter().zip(resolved_ev_oop) { + let diff = (ev1 - ev2).abs(); + assert!( + (ev1 - ev2).abs() < ev_threshold, + "({ev1:0.6} - {ev2:0.6}).abs() == {diff:0.6}" + ); + } + for (ev1, ev2) in ev_ip.iter().zip(resolved_ev_ip) { + let diff = (ev1 - ev2).abs(); + assert!( + (ev1 - ev2).abs() < ev_threshold, + "({ev1:0.6} - {ev2:0.6}).abs() == {diff:0.6}" + ); + } + std::fs::remove_file(file_save_name).unwrap(); + } + #[test] #[cfg(feature = "zstd")] fn save_and_load_file_compressed() { diff --git a/src/game/base.rs b/src/game/base.rs index 383327a..d16234b 100644 --- a/src/game/base.rs +++ b/src/game/base.rs @@ -2,12 +2,13 @@ use super::*; use crate::bunching::*; use crate::interface::*; use crate::utility::*; +use std::collections::HashSet; use std::mem::{self, MaybeUninit}; #[cfg(feature = "rayon")] use rayon::prelude::*; -#[derive(Default)] +#[derive(Default, Debug)] struct BuildTreeInfo { flop_index: usize, turn_index: usize, @@ -519,10 +520,18 @@ impl PostFlopGame { ) = self.card_config.isomorphism(&self.private_cards); } - /// Initializes the root node of game tree. + /// Initializes the root node of game tree and recursively build the tree. + /// + /// This function is responsible for computing the number of nodes required + /// for each street (via `count_nodes_per_street()`), allocating + /// `PostFlopNode`s to `self.node_arena`, and calling `build_tree_recursive`, + /// which recursively visits all nodes and, among other things, initializes + /// the child/parent relation. + /// + /// This does _not_ allocate global storage (e.g., `self.storage1`, etc). fn init_root(&mut self) -> Result<(), String> { - let num_nodes = self.count_num_nodes(); - let total_num_nodes = num_nodes[0] + num_nodes[1] + num_nodes[2]; + let nodes_per_street = self.count_nodes_per_street(); + let total_num_nodes = nodes_per_street[0] + nodes_per_street[1] + nodes_per_street[2]; if total_num_nodes > u32::MAX as u64 || mem::size_of::() as u64 * total_num_nodes > isize::MAX as u64 @@ -530,15 +539,15 @@ impl PostFlopGame { return Err("Too many nodes".to_string()); } - self.num_nodes = num_nodes; + self.num_nodes_per_street = nodes_per_street; self.node_arena = (0..total_num_nodes) .map(|_| MutexLike::new(PostFlopNode::default())) .collect::>(); self.clear_storage(); let mut info = BuildTreeInfo { - turn_index: num_nodes[0] as usize, - river_index: (num_nodes[0] + num_nodes[1]) as usize, + turn_index: nodes_per_street[0] as usize, + river_index: (nodes_per_street[0] + nodes_per_street[1]) as usize, ..Default::default() }; @@ -584,9 +593,10 @@ impl PostFlopGame { self.storage_chance = Vec::new(); } - /// Counts the number of nodes in the game tree. + /// Counts the number of nodes in the game tree per street, accounting for + /// isomorphism. #[inline] - fn count_num_nodes(&self) -> [u64; 3] { + fn count_nodes_per_street(&self) -> [u64; 3] { let (turn_coef, river_coef) = match (self.card_config.turn, self.card_config.river) { (NOT_DEALT, _) => { let mut river_coef = 0; @@ -725,6 +735,7 @@ impl PostFlopGame { node.num_children += 1; let mut child = node.children().last().unwrap().lock(); child.prev_action = Action::Chance(card); + child.parent_node_index = node_index; child.turn = card; } } @@ -743,6 +754,7 @@ impl PostFlopGame { node.num_children += 1; let mut child = node.children().last().unwrap().lock(); child.prev_action = Action::Chance(card); + child.parent_node_index = node_index; child.turn = node.turn; child.river = card; } @@ -786,6 +798,7 @@ impl PostFlopGame { for (child, action) in node.children().iter().zip(action_node.actions.iter()) { let mut child = child.lock(); child.prev_action = *action; + child.parent_node_index = node_index; child.turn = node.turn; child.river = node.river; } @@ -801,6 +814,110 @@ impl PostFlopGame { info.num_storage_ip += node.num_elements_ip as u64; } + /// reload_and_resolve + /// + /// Reload forgotten streets and resolve to target exploitability. + /// + /// Note: This currently wraps [`Self::copy_reload_and_resolve`] which is + /// not as memory efficient as it could be. + pub fn reload_and_resolve( + game: &mut PostFlopGame, + max_iterations: u32, + target_exploitability: f32, + print_progress: bool, + ) -> Result<(), String> { + *game = PostFlopGame::copy_reload_and_resolve( + game, + max_iterations, + target_exploitability, + print_progress, + )?; + Ok(()) + } + + /// copy_reload_and_resolve + /// + /// Copy `game` into a new `PostFlopGame` and rebuild/resolve any forgotten + /// streets. + /// + /// The solver will run until either `max_iterations` iterations have passed or + /// + /// # Arguments + /// + /// * `game` - the game to copy, rebuild, and resolve + /// * `max_iterations` - the maximum number of iterations to run the solver for + /// * `target_exploitability` - target exploitability for a solution + /// * `print_progress` - print progress during the solve + pub fn copy_reload_and_resolve( + game: &PostFlopGame, + max_iterations: u32, + target_exploitability: f32, + print_progress: bool, + ) -> Result { + let card_config = game.card_config.clone(); + let action_tree = ActionTree::new(game.tree_config.clone())?; + let target_storage_mode = game.target_storage_mode(); + + let mut new_game = PostFlopGame::with_config(card_config, action_tree)?; + new_game.allocate_memory(game.is_compression_enabled()); + + // Copy data into new game + for (dst, src) in new_game.storage1.iter_mut().zip(&game.storage1) { + *dst = *src; + } + for (dst, src) in new_game.storage2.iter_mut().zip(&game.storage2) { + *dst = *src; + } + for (dst, src) in new_game.storage_chance.iter_mut().zip(&game.storage_chance) { + *dst = *src; + } + for (dst, src) in new_game.storage_ip.iter_mut().zip(&game.storage_ip) { + *dst = *src; + } + + if target_storage_mode == BoardState::River { + return Ok(new_game); + } + + // Nodelock and resolve + let num_nodes_to_lock = match target_storage_mode { + BoardState::River => 0, + BoardState::Turn => game.num_nodes_per_street[0] + game.num_nodes_per_street[1], + BoardState::Flop => game.num_nodes_per_street[0], + }; + + // We are about to node lock a bunch of nodes to resolve more + // efficiently, so we preserve which keys were already locked to the + // current strategy + let already_locked_nodes = game.locking_strategy.keys().collect::>(); + for node_index in 0..num_nodes_to_lock as usize { + // We can't ? because this tries to lock chance nodes + let _ = new_game.lock_node_at_index(node_index); + } + + crate::solve( + &mut new_game, + max_iterations, + target_exploitability, + print_progress, + ); + + // Remove node locking from resolving but retain node locking passed in + // with `game`. Note that we _have to maintain the invariant that a + // node's index is in the game's locking_strategy if and only if + // node.is_locked == true._ That is: + // + // game.node_arena[node_index].is_locked <=> node_index in game.locking_strategy.keys() + for node_index in 0..num_nodes_to_lock as usize { + if !already_locked_nodes.contains(&node_index) { + new_game.node_arena[node_index].lock().is_locked = false; + new_game.locking_strategy.remove(&node_index); + } + } + + Ok(new_game) + } + /// Sets the bunching effect. fn set_bunching_effect_internal(&mut self, bunching_data: &BunchingData) -> Result<(), String> { self.bunching_num_dead_cards = bunching_data.fold_ranges().len() * 2; @@ -988,7 +1105,7 @@ impl PostFlopGame { if self.card_config.river != NOT_DEALT { self.bunching_arena = arena; - self.assign_zero_weights(); + self.assign_zero_weights_to_dead_cards(); return Ok(()); } @@ -1043,7 +1160,7 @@ impl PostFlopGame { let player_swap = swap_option.map(|swap| { let mut tmp = (0..player_len).collect::>(); - apply_swap(&mut tmp, &swap[player]); + apply_swap_list(&mut tmp, &swap[player]); tmp }); @@ -1065,8 +1182,8 @@ impl PostFlopGame { let slices = if let Some(swap) = swap_option { tmp.0.extend_from_slice(&arena[index..index + opponent_len]); tmp.1.extend_from_slice(opponent_strength); - apply_swap(&mut tmp.0, &swap[player ^ 1]); - apply_swap(&mut tmp.1, &swap[player ^ 1]); + apply_swap_list(&mut tmp.0, &swap[player ^ 1]); + apply_swap_list(&mut tmp.1, &swap[player ^ 1]); (tmp.0.as_slice(), &tmp.1) } else { (&arena[index..index + opponent_len], opponent_strength) @@ -1103,7 +1220,7 @@ impl PostFlopGame { if self.card_config.turn != NOT_DEALT { self.bunching_arena = arena; - self.assign_zero_weights(); + self.assign_zero_weights_to_dead_cards(); return Ok(()); } @@ -1137,7 +1254,7 @@ impl PostFlopGame { let player_swap = swap_option.map(|swap| { let mut tmp = (0..player_len).collect::>(); - apply_swap(&mut tmp, &swap[player]); + apply_swap_list(&mut tmp, &swap[player]); tmp }); @@ -1154,7 +1271,7 @@ impl PostFlopGame { let slice = &arena[index..index + opponent_len]; let slice = if let Some(swap) = swap_option { tmp.extend_from_slice(slice); - apply_swap(&mut tmp, &swap[player ^ 1]); + apply_swap_list(&mut tmp, &swap[player ^ 1]); &tmp } else { slice @@ -1181,7 +1298,7 @@ impl PostFlopGame { } self.bunching_arena = arena; - self.assign_zero_weights(); + self.assign_zero_weights_to_dead_cards(); Ok(()) } @@ -1416,7 +1533,7 @@ impl PostFlopGame { Ok(info) } - /// Allocates memory recursively. + /// Assigns allocated storage memory. fn allocate_memory_nodes(&mut self) { let num_bytes = if self.is_compression_enabled { 2 } else { 4 }; let mut action_counter = 0; @@ -1447,4 +1564,13 @@ impl PostFlopGame { } } } + + pub fn get_state(&self) -> &State { + &self.state + } + + #[inline] + pub fn is_partially_solved(&self) -> bool { + self.state >= State::SolvedFlop + } } diff --git a/src/game/interpreter.rs b/src/game/interpreter.rs index d44d63a..8bafa4a 100644 --- a/src/game/interpreter.rs +++ b/src/game/interpreter.rs @@ -30,7 +30,7 @@ impl PostFlopGame { self.weights[0].copy_from_slice(&self.initial_weights[0]); self.weights[1].copy_from_slice(&self.initial_weights[1]); - self.assign_zero_weights(); + self.assign_zero_weights_to_dead_cards(); } /// Returns the history of the current node. @@ -366,7 +366,7 @@ impl PostFlopGame { } // update the weights - self.assign_zero_weights(); + self.assign_zero_weights_to_dead_cards(); } // player node else { @@ -659,7 +659,8 @@ impl PostFlopGame { /// [`cache_normalized_weights`]: #method.cache_normalized_weights /// [`expected_values_detail`]: #method.expected_values_detail pub fn expected_values(&self, player: usize) -> Vec { - if self.state != State::Solved { + if !self.is_partially_solved() { + println!("{:?}", self.state); panic!("Game is not solved"); } @@ -711,7 +712,7 @@ impl PostFlopGame { /// [`expected_values`]: #method.expected_value /// [`cache_normalized_weights`]: #method.cache_normalized_weights pub fn expected_values_detail(&self, player: usize) -> Vec { - if self.state != State::Solved { + if !self.is_partially_solved() { panic!("Game is not solved"); } @@ -813,10 +814,6 @@ impl PostFlopGame { /// /// **Time complexity:** *O*(#(actions) * #(private hands)). pub fn strategy(&self) -> Vec { - if self.state < State::MemoryAllocated { - panic!("Memory is not allocated"); - } - if self.is_terminal_node() { panic!("Terminal node is not allowed"); } @@ -826,6 +823,14 @@ impl PostFlopGame { } let node = self.node(); + self.strategy_at_node(&node) + } + + pub fn strategy_at_node(&self, node: &PostFlopNode) -> Vec { + if self.state < State::MemoryAllocated { + panic!("Memory is not allocated"); + } + let player = self.current_player(); let num_actions = node.num_actions(); let num_hands = self.num_private_hands(player); @@ -836,7 +841,7 @@ impl PostFlopGame { normalized_strategy(node.strategy(), num_actions) }; - let locking = self.locking_strategy(&node); + let locking = self.locking_strategy(node); apply_locking_strategy(&mut ret, locking); ret.chunks_exact_mut(num_hands).for_each(|chunk| { @@ -852,6 +857,44 @@ impl PostFlopGame { self.total_bet_amount } + /// Locks the strategy of the current node to the current strategy already in memory. + pub fn lock_current_strategy(&mut self) -> Result<(), String> { + if self.is_terminal_node() { + return Err("Cannot lock terminal nodes".to_string()); + } + + if self.is_chance_node() { + return Err("Cannot lock chance nodes".to_string()); + } + + let mut node = self.node(); + + let index = self.node_index(&node); + // Note: node.is_locked is tightly coupled with locking_strategy + // containing the node's index + node.is_locked = true; + self.locking_strategy + .insert(index, node.strategy().to_vec()); + Ok(()) + } + + pub fn lock_node_at_index(&mut self, index: usize) -> Result<(), String> { + let mut node = self.node_arena[index].lock(); + if node.is_terminal() { + return Err("Cannot lock terminal node".to_string()); + } + + if node.is_chance() { + return Err("Cannot lock chance node".to_string()); + } + + let strategy = self.strategy_at_node(&node); + + node.is_locked = true; + self.locking_strategy.insert(index, strategy); + Ok(()) + } + /// Locks the strategy of the current node. /// /// The `strategy` argument must be a slice of the length of `#(actions) * #(private hands)`. @@ -867,21 +910,17 @@ impl PostFlopGame { /// This method must be called after allocating memory and before solving the game. /// Panics if the memory is not yet allocated or the game is already solved. /// Also, panics if the current node is a terminal node or a chance node. - pub fn lock_current_strategy(&mut self, strategy: &[f32]) { + pub fn lock_current_node(&mut self, strategy: &[f32]) -> Result<(), String> { if self.state < State::MemoryAllocated { - panic!("Memory is not allocated"); - } - - if self.state == State::Solved { - panic!("Game is already solved"); + return Err("Memory is not allocated".to_string()); } if self.is_terminal_node() { - panic!("Terminal node is not allowed"); + return Err("Cannot lock terminal nodes".to_string()); } if self.is_chance_node() { - panic!("Chance node is not allowed"); + return Err("Cannot lock chance nodes".to_string()); } let mut node = self.node(); @@ -922,6 +961,7 @@ impl PostFlopGame { node.is_locked = true; let index = self.node_index(&node); self.locking_strategy.insert(index, locking); + Ok(()) } /// Unlocks the strategy of the current node. @@ -994,7 +1034,7 @@ impl PostFlopGame { /// Returns the reference to the current node. #[inline] - fn node(&self) -> MutexGuardLike { + pub fn node(&self) -> MutexGuardLike { self.node_arena[self.node_history.last().cloned().unwrap_or(0)].lock() } @@ -1005,8 +1045,9 @@ impl PostFlopGame { unsafe { node_ptr.offset_from(self.node_arena.as_ptr()) as usize } } - /// Assigns zero weights to the hands that are not possible. - pub(super) fn assign_zero_weights(&mut self) { + /// Assigns zero weights to the hands that are not possible (e.g, by a card + /// being removed by a turn or river). + pub(super) fn assign_zero_weights_to_dead_cards(&mut self) { if self.bunching_num_dead_cards == 0 { let mut board_mask: u64 = 0; if self.turn != NOT_DEALT { diff --git a/src/game/mod.rs b/src/game/mod.rs index 33d9a19..a7180aa 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -17,16 +17,18 @@ use std::collections::BTreeMap; #[cfg(feature = "bincode")] use bincode::{Decode, Encode}; -#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] #[repr(u8)] #[cfg_attr(feature = "bincode", derive(Decode, Encode))] -enum State { +pub enum State { ConfigError = 0, #[default] Uninitialized = 1, TreeBuilt = 2, MemoryAllocated = 3, - Solved = 4, + SolvedFlop = 4, + SolvedTurn = 5, + Solved = 6, } /// A struct representing a postflop game. @@ -81,8 +83,9 @@ pub struct PostFlopGame { // store options storage_mode: BoardState, + // NOTE: Only used for encoding target_storage_mode: BoardState, - num_nodes: [u64; 3], + num_nodes_per_street: [u64; 3], is_compression_enabled: bool, num_storage: u64, num_storage_ip: u64, @@ -121,6 +124,7 @@ pub struct PostFlopGame { #[repr(C)] pub struct PostFlopNode { prev_action: Action, + parent_node_index: usize, player: u8, turn: Card, river: Card, diff --git a/src/game/node.rs b/src/game/node.rs index e6bd7e3..c69e9cc 100644 --- a/src/game/node.rs +++ b/src/game/node.rs @@ -209,6 +209,7 @@ impl Default for PostFlopNode { fn default() -> Self { Self { prev_action: Action::None, + parent_node_index: usize::MAX, player: PLAYER_OOP, turn: NOT_DEALT, river: NOT_DEALT, @@ -240,4 +241,36 @@ impl PostFlopNode { ) } } + + /// Get a list of available actions at a given node + pub fn actions(&self) -> Vec { + self.children() + .iter() + .map(|n| n.lock().prev_action) + .collect::>() + } + + /// Find the index of a given action, if present + pub fn action_index(&self, action: Action) -> Option { + self.children() + .iter() + .position(|n| n.lock().prev_action == action) + } + + /// Recursively compute the current node's history + pub fn compute_history_recursive(&self, game: &PostFlopGame) -> Option> { + if self.parent_node_index == usize::MAX { + Some(vec![]) + } else { + let indx_parent = game.node_arena.get(self.parent_node_index)?; + let node_parent = indx_parent.lock(); + let mut history = node_parent.compute_history_recursive(game)?; + let idx = match self.prev_action { + Action::Chance(card_idx) => card_idx as usize, + _ => node_parent.action_index(self.prev_action)?, + }; + history.push(idx); + Some(history) + } + } } diff --git a/src/game/serialization.rs b/src/game/serialization.rs index a9a7858..ccc46cb 100644 --- a/src/game/serialization.rs +++ b/src/game/serialization.rs @@ -58,7 +58,13 @@ impl PostFlopGame { } } - /// Returns the number of storage elements required for the target storage mode. + /// Returns the number of storage elements required for the target storage mode: + /// `[|storage1|, |storage2|, |storage_ip|, |storage_chance|]` + /// + /// If this is a River save (`target_storage_mode == BoardState::River`) + /// then do not store cfvalues. + /// + /// If this is a Flop save, only store flop nodes fn num_target_storage(&self) -> [usize; 4] { if self.state <= State::TreeBuilt { return [0; 4]; @@ -71,8 +77,8 @@ impl PostFlopGame { } let mut node_index = match self.target_storage_mode { - BoardState::Flop => self.num_nodes[0], - _ => self.num_nodes[0] + self.num_nodes[1], + BoardState::Flop => self.num_nodes_per_street[0], + _ => self.num_nodes_per_street[0] + self.num_nodes_per_street[1], } as usize; let mut num_storage = [0; 4]; @@ -120,15 +126,26 @@ impl Encode for PostFlopGame { // version VERSION_STR.to_string().encode(encoder)?; + // Update state based on target storage whenever the state is solved + let saved_state = if self.is_partially_solved() { + match self.target_storage_mode { + BoardState::Flop => State::SolvedFlop, + BoardState::Turn => State::SolvedTurn, + BoardState::River => State::Solved, + } + } else { + self.state + }; + // contents - self.state.encode(encoder)?; + saved_state.encode(encoder)?; self.card_config.encode(encoder)?; self.tree_config.encode(encoder)?; self.added_lines.encode(encoder)?; self.removed_lines.encode(encoder)?; self.action_root.encode(encoder)?; self.target_storage_mode.encode(encoder)?; - self.num_nodes.encode(encoder)?; + self.num_nodes_per_street.encode(encoder)?; self.is_compression_enabled.encode(encoder)?; self.num_storage.encode(encoder)?; self.num_storage_ip.encode(encoder)?; @@ -140,8 +157,10 @@ impl Encode for PostFlopGame { self.storage_chance[0..num_storage[3]].encode(encoder)?; let num_nodes = match self.target_storage_mode { - BoardState::Flop => self.num_nodes[0] as usize, - BoardState::Turn => (self.num_nodes[0] + self.num_nodes[1]) as usize, + BoardState::Flop => self.num_nodes_per_street[0] as usize, + BoardState::Turn => { + (self.num_nodes_per_street[0] + self.num_nodes_per_street[1]) as usize + } BoardState::River => self.node_arena.len(), }; @@ -193,7 +212,7 @@ impl Decode for PostFlopGame { removed_lines: Decode::decode(decoder)?, action_root: Decode::decode(decoder)?, storage_mode: Decode::decode(decoder)?, - num_nodes: Decode::decode(decoder)?, + num_nodes_per_street: Decode::decode(decoder)?, is_compression_enabled: Decode::decode(decoder)?, num_storage: Decode::decode(decoder)?, num_storage_ip: Decode::decode(decoder)?, @@ -247,6 +266,8 @@ impl Decode for PostFlopGame { // restore the counterfactual values if game.storage_mode == BoardState::River && game.state == State::Solved { + // TODO(Jacob): Want to restructure finalize so that this hacky + // setting of game.state isn't necessary game.state = State::MemoryAllocated; finalize(&mut game); } diff --git a/src/game/tests.rs b/src/game/tests.rs index c00c947..a76ba7c 100644 --- a/src/game/tests.rs +++ b/src/game/tests.rs @@ -869,7 +869,7 @@ fn node_locking() { game.allocate_memory(false); game.play(1); // all-in - game.lock_current_strategy(&[0.25, 0.75]); // 25% fold, 75% call + let _ = game.lock_current_node(&[0.25, 0.75]); // 25% fold, 75% call game.back_to_root(); solve(&mut game, 1000, 0.0, false); @@ -889,7 +889,7 @@ fn node_locking() { game.allocate_memory(false); game.play(1); // all-in - game.lock_current_strategy(&[0.5, 0.5]); // 50% fold, 50% call + let _ = game.lock_current_node(&[0.5, 0.5]); // 50% fold, 50% call game.back_to_root(); solve(&mut game, 1000, 0.0, false); @@ -929,7 +929,7 @@ fn node_locking_partial() { let mut game = PostFlopGame::with_config(card_config, action_tree).unwrap(); game.allocate_memory(false); - game.lock_current_strategy(&[0.8, 0.0, 0.0, 0.2, 0.0, 0.0]); // JJ -> 80% check, 20% all-in + let _ = game.lock_current_node(&[0.8, 0.0, 0.0, 0.2, 0.0, 0.0]); // JJ -> 80% check, 20% all-in solve(&mut game, 1000, 0.0, false); game.cache_normalized_weights(); @@ -970,7 +970,7 @@ fn node_locking_isomorphism() { game.allocate_memory(false); game.apply_history(&[0, 0, 15, 0, 0, 14]); // Turn: Spades, River: Hearts - game.lock_current_strategy(&[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]); // AhKh -> check + let _ = game.lock_current_node(&[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]); // AhKh -> check finalize(&mut game); diff --git a/src/sliceop.rs b/src/sliceop.rs index bf726b6..f99ad70 100644 --- a/src/sliceop.rs +++ b/src/sliceop.rs @@ -1,16 +1,52 @@ use crate::utility::*; use std::mem::MaybeUninit; +/// Subtracts each element of the left-hand side (`lhs`) slice by the +/// corresponding element of the right-hand side (`rhs`) slice, modifying the +/// `lhs` slice in place. +/// +/// # Arguments +/// +/// - `lhs`: A mutable reference to the left-hand side slice, which will be +/// modified in place. +/// - `rhs`: A reference to the right-hand side slice, which provides the +/// values to be subtracted from each corresponding element in `lhs`. #[inline] pub(crate) fn sub_slice(lhs: &mut [f32], rhs: &[f32]) { lhs.iter_mut().zip(rhs).for_each(|(l, r)| *l -= *r); } +/// Multiplies each element of the left-hand side (`lhs`) slice by the +/// corresponding element of the right-hand side (`rhs`) slice, modifying the +/// `lhs` slice in place. +/// +/// # Arguments +/// +/// - `lhs`: A mutable reference to the left-hand side slice, which will be +/// modified in place. +/// - `rhs`: A reference to the right-hand side slice, which provides the +/// values to be multiplied against each corresponding element in `lhs`. #[inline] pub(crate) fn mul_slice(lhs: &mut [f32], rhs: &[f32]) { lhs.iter_mut().zip(rhs).for_each(|(l, r)| *l *= *r); } +/// Divides each element of the left-hand side (`lhs`) slice by the +/// corresponding element of the right-hand side (`rhs`) slice, modifying the +/// `lhs` slice in place. If an element in `rhs` is zero, the corresponding +/// element in `lhs` is set to a specified `default` value instead of performing +/// the division. +/// +/// # Arguments +/// +/// - `lhs`: A mutable reference to the left-hand side slice, which will be +/// modified in place. Each element of this slice is divided by the +/// corresponding element in the `rhs` slice, or set to `default` if the +/// corresponding element in `rhs` is zero. +/// - `rhs`: A reference to the right-hand side slice, which provides the +/// divisor for each element in `lhs`. +/// - `default: f32`: A fallback value that is used for elements in `lhs` where +/// the corresponding element in `rhs` is zero. #[inline] pub(crate) fn div_slice(lhs: &mut [f32], rhs: &[f32], default: f32) { lhs.iter_mut() @@ -18,6 +54,22 @@ pub(crate) fn div_slice(lhs: &mut [f32], rhs: &[f32], default: f32) { .for_each(|(l, r)| *l = if is_zero(*r) { default } else { *l / *r }); } +/// Divides each element of the left-hand side (`lhs`) slice by the +/// corresponding element of the right-hand side (`rhs`) slice, modifying the +/// `lhs` slice in place. If an element in `rhs` is zero, the corresponding +/// element in `lhs` is set to a specified `default` value instead of performing +/// the division. +/// +/// # Arguments +/// +/// - `lhs`: A mutable reference to the left-hand side slice, which will be +/// modified in place. Each element of this slice is divided by the +/// corresponding element in the `rhs` slice, or set to `default` if the +/// corresponding element in `rhs` is zero. +/// - `rhs`: A reference to the right-hand side slice, which provides the +/// divisor for each element in `lhs`. +/// - `default: f32`: A fallback value that is used for elements in `lhs` where +/// the corresponding element in `rhs` is zero. #[inline] pub(crate) fn div_slice_uninit( dst: &mut [MaybeUninit], @@ -32,6 +84,7 @@ pub(crate) fn div_slice_uninit( }); } +/// Multiply a source slice by a scalar and store in a destination slice #[inline] pub(crate) fn mul_slice_scalar_uninit(dst: &mut [MaybeUninit], src: &[f32], scalar: f32) { dst.iter_mut().zip(src).for_each(|(d, s)| { @@ -39,6 +92,17 @@ pub(crate) fn mul_slice_scalar_uninit(dst: &mut [MaybeUninit], src: &[f32], }); } +/// Compute a _strided summation_ of `f32` elements in `src`, where the stride +/// length is `dst.len()`. +/// +/// In more detail, break source slice `src` into `N` chunks `C0...CN-1`, where +/// `N = dst.len()`, and set the `i`th element of `dst` to be the sum of the +/// `i`th element of each chunk `Ck`: +/// +/// - `dst[0] = SUM(k=0..N-1, Ck[0])` +/// - `dst[1] = SUM(k=0..N-1, Ck[1])` +/// - `dst[2] = SUM(k=0..N-1, Ck[2])` +/// - ... #[inline] pub(crate) fn sum_slices_uninit<'a>(dst: &'a mut [MaybeUninit], src: &[f32]) -> &'a mut [f32] { let len = dst.len(); @@ -54,6 +118,17 @@ pub(crate) fn sum_slices_uninit<'a>(dst: &'a mut [MaybeUninit], src: &[f32] dst } +/// Compute a _strided summation_ of `f32` elements in `src`, where the stride +/// length is `dst.len()`, and store as `f64` in `dst`. +/// +/// In more detail, break source slice `src` into `N` chunks `C0...CN-1`, where +/// `N = dst.len()`, and set the `i`th element of `dst` to be the sum of the +/// `i`th element of each chunk `Ck`: +/// +/// - `dst[0] = SUM(k=0..N-1, Ck[0])` +/// - `dst[1] = SUM(k=0..N-1, Ck[1])` +/// - `dst[2] = SUM(k=0..N-1, Ck[2])` +/// - ... #[inline] pub(crate) fn sum_slices_f64_uninit<'a>( dst: &'a mut [MaybeUninit], @@ -72,6 +147,30 @@ pub(crate) fn sum_slices_f64_uninit<'a>( dst } +/// Performs a fused multiply-add (FMA) operation on slices, storing the result +/// in a destination slice. +/// +/// This function multiplies the first `dst.len()` corresponding elements of the +/// two source slices (`src1` and `src2`) and stores the results in the +/// destination slice (`dst`). After the initial multiplication, it continues +/// to perform additional multiply-add operations using subsequent chunks of +/// `src1` and `src2`, adding the products to the already computed values in +/// `dst`. +/// +/// # Arguments +/// +/// - `dst`: A mutable reference to a slice of uninitialized memory where the +/// results will be stored. The length of this slice dictates how many +/// elements are processed in the initial operation. +/// - `src1`: A reference to the first source slice, providing the +/// multiplicands. +/// - `src2`: A reference to the second source slice, providing the multipliers. +/// +/// # Returns +/// +/// A mutable reference to the `dst` slice, now reinterpreted as a fully +/// initialized slice of `f32` values, containing the results of the fused +/// multiply-add operations. #[inline] pub(crate) fn fma_slices_uninit<'a>( dst: &'a mut [MaybeUninit], @@ -236,11 +335,27 @@ pub(crate) fn inner_product_cond( acc.iter().sum::() as f32 } +/// Extract a reference to a specific "row" from a one-dimensional slice, where +/// the data is conceptually arranged as a two-dimensional array. +/// +/// # Arguments +/// +/// * `slice` - slice to extract a reference from +/// * `index` - the index of the conceptual "row" to reference +/// * `row_size` - the size of the conceptual "row" to reference #[inline] pub(crate) fn row(slice: &[T], index: usize, row_size: usize) -> &[T] { &slice[index * row_size..(index + 1) * row_size] } +/// Extract a mutable reference to a specific "row" from a one-dimensional +/// slice, where the data is conceptually arranged as a two-dimensional array. +/// +/// # Arguments +/// +/// * `slice` - slice to extract a mutable reference from +/// * `index` - the index of the conceptual "row" to reference +/// * `row_size` - the size of the conceptual "row" to reference #[inline] pub(crate) fn row_mut(slice: &mut [T], index: usize, row_size: usize) -> &mut [T] { &mut slice[index * row_size..(index + 1) * row_size] diff --git a/src/solver.rs b/src/solver.rs index 5a1cc5a..86fa129 100644 --- a/src/solver.rs +++ b/src/solver.rs @@ -9,8 +9,11 @@ use std::mem::MaybeUninit; use crate::alloc::*; struct DiscountParams { + // coefficient for accumulated positive regrets alpha_t: f32, + // coefficient for accumulated negative regrets beta_t: f32, + // contributions to average strategy gamma_t: f32, } @@ -55,6 +58,19 @@ pub fn solve( } let mut root = game.root(); + + // Run solve_recursive once to initialize uninitialized strat memory + for player in 0..2 { + let mut result = Vec::with_capacity(game.num_private_hands(player)); + solve_recursive( + result.spare_capacity_mut(), + game, + &mut root, + player, + game.initial_weights(player ^ 1), + &DiscountParams::new(0), + ); + } let mut exploitability = compute_exploitability(game); if print_progress { @@ -63,7 +79,7 @@ pub fn solve( io::stdout().flush().unwrap(); } - for t in 0..max_num_iterations { + for t in 1..max_num_iterations { if exploitability <= target_exploitability { break; } @@ -83,7 +99,7 @@ pub fn solve( ); } - if (t + 1) % 10 == 0 || t + 1 == max_num_iterations { + if t % 10 == 0 || t + 1 == max_num_iterations { exploitability = compute_exploitability(game); } @@ -132,7 +148,19 @@ pub fn solve_step(game: &T, current_iteration: u32) { } } -/// Recursively solves the counterfactual values. +/// Recursively solves the counterfactual values and store them in `result`. +/// +/// # Arguments +/// +/// * `result` - slice to store resulting counterfactual regret values +/// * `game` - reference to the game we are solving +/// * `node` - current node we are solving +/// * `player` - current player we are solving for +/// * `cfreach` - the probability of reaching this point with a particular private hand +/// * `params` - the DiscountParams that parametrize the solver +/// +/// This modifies `node`'s strategies and regrets when `node.player() == +/// player`. fn solve_recursive( result: &mut [MaybeUninit], game: &T, @@ -157,11 +185,19 @@ fn solve_recursive( return; } - // allocate memory for storing the counterfactual values + // Allocate memory for storing the counterfactual values. Conceptually this + // is a `num_actions * num_hands` 2-dimensional array, where the `i`th + // row (which has length `num_hands`) corresponds to the cfvalues of each + // hand after taking the `i`th action. + // + // Rows are obtained using operations from `sliceop` (e.g., `sliceop::row_mut()`). + // + // `cfv_actions_hands` will be written to by recursive calls to `solve_recursive`. #[cfg(feature = "custom-alloc")] - let cfv_actions = MutexLike::new(Vec::with_capacity_in(num_actions * num_hands, StackAlloc)); + let cfv_actions_hands = + MutexLike::new(Vec::with_capacity_in(num_actions * num_hands, StackAlloc)); #[cfg(not(feature = "custom-alloc"))] - let cfv_actions = MutexLike::new(Vec::with_capacity(num_actions * num_hands)); + let cfv_actions_hands = MutexLike::new(Vec::with_capacity(num_actions * num_hands)); // if the `node` is chance if node.is_chance() { @@ -180,7 +216,11 @@ fn solve_recursive( // compute the counterfactual values of each action for_each_child(node, |action| { solve_recursive( - row_mut(cfv_actions.lock().spare_capacity_mut(), action, num_hands), + row_mut( + cfv_actions_hands.lock().spare_capacity_mut(), + action, + num_hands, + ), game, &mut node.play(action), player, @@ -189,14 +229,16 @@ fn solve_recursive( ); }); - // use 64-bit floating point values + // use 64-bit floating point values for precision during summations + // before demoting back to f32 #[cfg(feature = "custom-alloc")] let mut result_f64 = Vec::with_capacity_in(num_hands, StackAlloc); #[cfg(not(feature = "custom-alloc"))] let mut result_f64 = Vec::with_capacity(num_hands); - // sum up the counterfactual values - let mut cfv_actions = cfv_actions.lock(); + // compute the strided summation of the counterfactual values for each + // hand and store in `result_f64` + let mut cfv_actions = cfv_actions_hands.lock(); unsafe { cfv_actions.set_len(num_actions * num_hands) }; sum_slices_f64_uninit(result_f64.spare_capacity_mut(), &cfv_actions); unsafe { result_f64.set_len(num_hands) }; @@ -209,25 +251,49 @@ fn solve_recursive( let swap_list = &game.isomorphic_swap(node, i)[player]; let tmp = row_mut(&mut cfv_actions, isomorphic_index as usize, num_hands); - apply_swap(tmp, swap_list); + apply_swap_list(tmp, swap_list); result_f64.iter_mut().zip(&*tmp).for_each(|(r, &v)| { *r += v as f64; }); - apply_swap(tmp, swap_list); + apply_swap_list(tmp, swap_list); } result.iter_mut().zip(&result_f64).for_each(|(r, &v)| { r.write(v as f32); }); } - // if the current player is `player` + // IF THE CURRENT PLAYER IS `player`: + // + // 1. Recursively compute cfvalues for each action/hand combination with + // `solve_recursive`. + // - `cfvalues` for action index `action` are stored in `cfv_actions` in + // row `action` + // - In particular, `cfv_actions[a * #(num_hands) + h]` gives the cfvalue + // for hand `h` taking action `a` + // 2. Compute the strategy using regret matching (`regret_matching` or + // `regret_matching_compressed`) + // 3. Apply any node locking + // 4. Compute the cfvalues for each hand (that is, accumulate + // across different actions taken). This is handled separately if + // the game is compressed or not. + // 1. Update the cumulative strategy with the new strategy, using + // `params.gamma_t` to discount previous cumulative strategy + // 2. Update the cumulative regret using `params.alpha_t` and + // `beta.alpha_t` + // - Question: strategy is not normalized: why? + // - Question: we are subtracting `result` from `row` (rows of + // `cum_regret`): why? else if node.player() == player { // compute the counterfactual values of each action for_each_child(node, |action| { solve_recursive( - row_mut(cfv_actions.lock().spare_capacity_mut(), action, num_hands), + row_mut( + cfv_actions_hands.lock().spare_capacity_mut(), + action, + num_hands, + ), game, &mut node.play(action), player, @@ -236,7 +302,7 @@ fn solve_recursive( ); }); - // compute the strategy by regret-maching algorithm + // compute the strategy by regret-matching algorithm let mut strategy = if game.is_compression_enabled() { regret_matching_compressed(node.regrets_compressed(), num_actions) } else { @@ -247,10 +313,20 @@ fn solve_recursive( let locking = game.locking_strategy(node); apply_locking_strategy(&mut strategy, locking); - // sum up the counterfactual values - let mut cfv_actions = cfv_actions.lock(); - unsafe { cfv_actions.set_len(num_actions * num_hands) }; - let result = fma_slices_uninit(result, &strategy, &cfv_actions); + // Compute the counterfactual values for each hand by 'fusing' together + // hands' cfvs across all actions. + // + // For hand `h` this is computed to be the sum over actions `a` of the + // frequency with which `h` takes action `a` times the regret of hand + // `h` taking action `a`: + // + // result[h] = sum([freq(h, a) * regret(h, a) for a in actions]) + // + // This sum-of-products us computed as a fused multiply-add using + // `fma_slices_uninit` and is stored in `result`. + let mut cfv_actions_hands = cfv_actions_hands.lock(); + unsafe { cfv_actions_hands.set_len(num_actions * num_hands) }; + let cfv_hands = fma_slices_uninit(result, &strategy, &cfv_actions_hands); if game.is_compression_enabled() { // update the cumulative strategy @@ -279,48 +355,72 @@ fn solve_recursive( let beta_decoder = params.beta_t * scale / i16::MAX as f32; let cum_regret = node.regrets_compressed_mut(); - cfv_actions.iter_mut().zip(&*cum_regret).for_each(|(x, y)| { - *x += *y as f32 * if *y >= 0 { alpha_decoder } else { beta_decoder }; - }); + cfv_actions_hands + .iter_mut() + .zip(&*cum_regret) + .for_each(|(x, y)| { + *x += *y as f32 * if *y >= 0 { alpha_decoder } else { beta_decoder }; + }); - cfv_actions.chunks_exact_mut(num_hands).for_each(|row| { - sub_slice(row, result); - }); + cfv_actions_hands + .chunks_exact_mut(num_hands) + .for_each(|row| { + sub_slice(row, cfv_hands); + }); if !locking.is_empty() { - cfv_actions.iter_mut().zip(locking).for_each(|(d, s)| { - if s.is_sign_positive() { - *d = 0.0; - } - }) + cfv_actions_hands + .iter_mut() + .zip(locking) + .for_each(|(d, s)| { + if s.is_sign_positive() { + *d = 0.0; + } + }) } - let new_scale = encode_signed_slice(cum_regret, &cfv_actions); + let new_scale = encode_signed_slice(cum_regret, &cfv_actions_hands); node.set_regret_scale(new_scale); } else { - // update the cumulative strategy + // update the node's cumulative strategy (`node.strategy_mut()`) + // - `gamma` is used to discount cumulative strategy contributions let gamma = params.gamma_t; let cum_strategy = node.strategy_mut(); cum_strategy.iter_mut().zip(&strategy).for_each(|(x, y)| { *x = *x * gamma + *y; }); - // update the cumulative regret + // update the node's cumulative regret `node.regrets_mut()` + // - alpha is used to discount positive cumulative regrets + // - beta is used to discount negative cumulative regrets let (alpha, beta) = (params.alpha_t, params.beta_t); let cum_regret = node.regrets_mut(); - cum_regret.iter_mut().zip(&*cfv_actions).for_each(|(x, y)| { - let coef = if x.is_sign_positive() { alpha } else { beta }; - *x = *x * coef + *y; - }); + cum_regret + .iter_mut() + .zip(&*cfv_actions_hands) + .for_each(|(x, y)| { + let coef = if x.is_sign_positive() { alpha } else { beta }; + *x = *x * coef + *y; + }); cum_regret.chunks_exact_mut(num_hands).for_each(|row| { - sub_slice(row, result); + sub_slice(row, cfv_hands); }); } } - // if the current player is not `player` + // IF THE CURRENT PLAYER IS NOT `player` + // + // 1. Compute strategy for villain with regret matching. We store the + // strategy in `cfreach_actions` (rather than, say, `strategy`) because + // this will be updated with to store the `cfreach` for each individual + // action (step 3) + // 2. Apply any locking to villain's strategy + // 3. Update strategy in `cfreach_actions` with reach probabilities in + // `cfreach`; `cfreach_actions` now stores rows of per-hand reach + // probabilities, where each row corresponds to an action. + // 4. recursively solve for `player` and store in `cfv_actions` else { // compute the strategy by regret-matching algorithm - let mut cfreach_actions = if game.is_compression_enabled() { + let mut cfreach_actions_hands = if game.is_compression_enabled() { regret_matching_compressed(node.regrets_compressed(), num_actions) } else { regret_matching(node.regrets(), num_actions) @@ -328,28 +428,34 @@ fn solve_recursive( // node-locking let locking = game.locking_strategy(node); - apply_locking_strategy(&mut cfreach_actions, locking); + apply_locking_strategy(&mut cfreach_actions_hands, locking); // update the reach probabilities let row_size = cfreach.len(); - cfreach_actions.chunks_exact_mut(row_size).for_each(|row| { - mul_slice(row, cfreach); - }); + cfreach_actions_hands + .chunks_exact_mut(row_size) + .for_each(|row| { + mul_slice(row, cfreach); + }); // compute the counterfactual values of each action for_each_child(node, |action| { solve_recursive( - row_mut(cfv_actions.lock().spare_capacity_mut(), action, num_hands), + row_mut( + cfv_actions_hands.lock().spare_capacity_mut(), + action, + num_hands, + ), game, &mut node.play(action), player, - row(&cfreach_actions, action, row_size), + row(&cfreach_actions_hands, action, row_size), params, ); }); // sum up the counterfactual values - let mut cfv_actions = cfv_actions.lock(); + let mut cfv_actions = cfv_actions_hands.lock(); unsafe { cfv_actions.set_len(num_actions * num_hands) }; sum_slices_uninit(result, &cfv_actions); } @@ -380,6 +486,18 @@ fn regret_matching(regret: &[f32], num_actions: usize) -> Vec { } /// Computes the strategy by regret-matching algorithm. +/// +/// The resulting strategy has each element (e.g., a hand like **AdQs**) take +/// an action proportional to its regret, where negative regrets are interpreted +/// as zero. +/// +/// # Arguments +/// +/// * `regret` - slice of regrets for the current decision point, one "row" of +/// for each action. The `i`th row contains the regrets of each strategically +/// distinct element (e.g., in holdem an element would be a hole card) for +/// taking the `i`th action. +/// * `num_actions` - the number of actions represented in `regret`. #[cfg(not(feature = "custom-alloc"))] #[inline] fn regret_matching(regret: &[f32], num_actions: usize) -> Vec { @@ -391,10 +509,15 @@ fn regret_matching(regret: &[f32], num_actions: usize) -> Vec { unsafe { strategy.set_len(regret.len()) }; let row_size = regret.len() / num_actions; + + // We want to normalize each element's strategy, so compute the element-wise + // denominator by computing the strided summation of strategy let mut denom = Vec::with_capacity(row_size); sum_slices_uninit(denom.spare_capacity_mut(), &strategy); unsafe { denom.set_len(row_size) }; + // We set the default to be equally distributed across all options. This is + // used when a strategy for a particular hand is uniformly zero. let default = 1.0 / num_actions as f32; strategy.chunks_exact_mut(row_size).for_each(|row| { div_slice(row, &denom, default); diff --git a/src/utility.rs b/src/utility.rs index c834de7..d38208b 100644 --- a/src/utility.rs +++ b/src/utility.rs @@ -227,9 +227,14 @@ pub(crate) fn encode_unsigned_slice(dst: &mut [u16], slice: &[f32]) -> f32 { scale } -/// Applies the given swap to the given slice. +/// Applies the given list of swaps to the given slice. +/// +/// # Arguments +/// +/// * `slice` - mutable slice to perform swaps on +/// * `swap_list` - a list of index pairs to swap #[inline] -pub(crate) fn apply_swap(slice: &mut [T], swap_list: &[(u16, u16)]) { +pub(crate) fn apply_swap_list(slice: &mut [T], swap_list: &[(u16, u16)]) { for &(i, j) in swap_list { unsafe { ptr::swap( @@ -425,13 +430,13 @@ fn compute_cfvalue_recursive( let swap_list = &game.isomorphic_swap(node, i)[player]; let tmp = row_mut(&mut cfv_actions, isomorphic_index as usize, num_hands); - apply_swap(tmp, swap_list); + apply_swap_list(tmp, swap_list); result_f64.iter_mut().zip(&*tmp).for_each(|(r, &v)| { *r += v as f64; }); - apply_swap(tmp, swap_list); + apply_swap_list(tmp, swap_list); } result.iter_mut().zip(&result_f64).for_each(|(r, &v)| { @@ -637,13 +642,13 @@ fn compute_best_cfv_recursive( let swap_list = &game.isomorphic_swap(node, i)[player]; let tmp = row_mut(&mut cfv_actions, isomorphic_index as usize, num_hands); - apply_swap(tmp, swap_list); + apply_swap_list(tmp, swap_list); result_f64.iter_mut().zip(&*tmp).for_each(|(r, &v)| { *r += v as f64; }); - apply_swap(tmp, swap_list); + apply_swap_list(tmp, swap_list); } result.iter_mut().zip(&result_f64).for_each(|(r, &v)| {